diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5b1d363f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{jl,R,r,py}] +indent_size = 4 + +[*.{md,adoc}] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fe2029b..d3800093 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,9 @@ jobs: # for 24.04, and a runner that silently rolled to the next LTS would take that pin # with it. `runs-on` cannot read `env`, so this is a literal. runs-on: ubuntu-24.04 + # Bound a hung toolchain install or test run. The full R/Julia suite normally + # completes well inside this budget; the GitHub default is six hours. + timeout-minutes: 60 # The repository's root .Rprofile sources renv/activate.R, so R started from # the checkout auto-activates renv and rewrites the library paths. renv is a # local-development convenience; CI installs into the system library and @@ -417,10 +420,12 @@ jobs: # failure but the last one. Publish a digest of all failing # assertions first - the testset, the expression and the evaluated # values are what identify the bug - then the end of the log. + # `set +e` above is deliberate: diagnostic helpers may find no + # matches, and only the captured test status controls this step. grep -E "Test Failed at |Error During Test at |^ Expression: |^ Evaluated: |^ Test threw exception" \ - /tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log || true + /tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log rm -f /tmp/validated-statistics-digest-* - split -b 2500 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest- || true + split -b 2500 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest- index=0 for chunk in /tmp/validated-statistics-digest-*; do [ -e "$chunk" ] || continue diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..c01b774d --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MPL-2.0 +# Pinned reusable implementation; do not replace with a floating branch tag. +name: Scorecard + +# Scorecard publishes SARIF for the default branch. Keep its required +# security-events upload permission off pull requests: otherwise Scorecard +# scans this caller, flags that necessary permission, and creates a blocking +# code-scanning check on its own security workflow. Scheduled and main-branch +# scans retain the findings without that self-referential PR gate. +on: + push: + branches: [main] + schedule: + - cron: '0 6 1 * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scorecard: + name: OpenSSF Scorecard + permissions: + actions: read + contents: read + security-events: write + id-token: write + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml new file mode 100644 index 00000000..5bf9eb3a --- /dev/null +++ b/.github/workflows/secret-scanner.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# Full-history and working-tree secret scanning from the pinned estate reusable. +name: Secret Scanner + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + name: Secret history and working tree + permissions: + contents: read + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 + # Deliberately no secrets: the pinned reusable declares and needs none. diff --git a/.gitignore b/.gitignore index 3af5a950..bb8f8512 100644 --- a/.gitignore +++ b/.gitignore @@ -320,6 +320,8 @@ archive/ ### Exceptions !.github !.github/** +!.editorconfig +!.gitleaks.toml !.Rprofile # Brainstorming visual-companion scratch (mock-ups, server state) diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..a8c979bd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MPL-2.0 +# Repository-specific secret-scanning rules. The reusable stages the pinned +# estate baseline at this path; no path- or commit-wide exceptions are made. + +[extend] +path = ".gitleaks-estate.toml" + +[allowlist] +description = "Exact public schema and UI callback identifiers misread as generic keys" +regexes = [ + '''^Associated_material$''', + '''^Associated_organism$''', + '''^allowPreviousMonth$''', + '''^associated_organism$''', + '''^canAddSubplot$''', + '''^focusPreviousDay$''', + '''^getInitialMonthFromProps$''', + '''^getPreviousNavigableMonth$''', + '''^handleDayClick$''', + '''^handleMouseEnter$''', +] + +# Backport the upstream v8.30.1 AWS rule for the CI-pinned v8.18.4 engine. +# The older rule accepts arbitrary A-Z0-9 suffixes with no entropy threshold, +# which turns ordinary A/C/G/T sequences into AWS access-token findings. The +# upstream boundaries, suffix alphabet, and entropy requirement retain real +# credential detection without excluding biological data by path or commit. +[[rules]] +id = "aws-access-token" +description = "Identified a pattern that may indicate AWS credentials, risking unauthorized cloud resource access and data breaches on AWS platforms." +regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b''' +entropy = 3 +keywords = ["a3t", "akia", "asia", "abia", "acca"] + +# Likewise, upstream now requires a realistic Facebook token length and +# entropy. Short printable FASTQ quality strings otherwise match the legacy +# twenty-character minimum even though they are sequencing data. +[[rules]] +id = "facebook-page-access-token" +description = "Discovered a Facebook Page Access Token, posing a risk of unauthorized access to Facebook accounts and personal data exposure." +regex = '''\b(EAA[MC](?i)[a-z0-9]{100,})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["eaam", "eaac"] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f4f9e18d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ + +# Security policy + +## Supported version + +Security fixes are applied to the current `main` branch. Update to the latest +revision before reporting a problem that may already have been fixed. + +## Report a vulnerability + +Use [GitHub private vulnerability reporting](https://github.com/hyperpolymath/MetaManifold-WebUI/security/advisories/new) +for suspected vulnerabilities in this fork. If private reporting is not +available, contact the fork maintainer at `j.d.a.jewell@open.ac.uk` (the +contact already published in `MetaManifold-WebUI_chora.deed`). Do not disclose +an unpatched vulnerability in a public issue or pull request. + +Include the affected revision, a minimal reproduction, the security impact, and +any suggested mitigation. Remove real study data, credentials, access tokens, +and other secrets from the report. The maintainers will assess the report and +coordinate a fix and disclosure with the reporter. + +For vulnerabilities confined to upstream code that are not specific to this +fork, also consult the upstream project's reporting process rather than +assuming the fork can publish an upstream fix. diff --git a/frontend/src/figure/PaneEditor.tsx b/frontend/src/figure/PaneEditor.tsx index daac6d6a..3d59ec53 100644 --- a/frontend/src/figure/PaneEditor.tsx +++ b/frontend/src/figure/PaneEditor.tsx @@ -42,8 +42,7 @@ export function PaneEditor({ study, runs, source, pane, onChange }: { const selected = useMemo(() => { const keys = new Set((chart?.runs ?? []).map(specKey)) return runs.filter(r => keys.has(specKey(r))) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runs, chart?.runs.map(specKey).join(',')]) + }, [runs, chart?.runs]) const tables = useSharedResultsTables(study, selected) const [ranks, setRanks] = useState([]) diff --git a/frontend/src/tree/TreeShapes.tsx b/frontend/src/tree/TreeShapes.tsx index f176a1db..73922373 100644 --- a/frontend/src/tree/TreeShapes.tsx +++ b/frontend/src/tree/TreeShapes.tsx @@ -22,11 +22,12 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o const branchPath = (n: LNode) => mode === 'rectangular' ? `M${n.px},${n.y}H${n.x}` : `M${n.ex},${n.ey}L${n.x},${n.y}` const right = aligned && settings.tipJustify === 'right' - // Branches are batched into one path per colour. + // Branches are batched into one path per colour. Accumulate path fragments + // in arrays and join once; repeated string concatenation is quadratic for + // large trees in engines that cannot flatten the intermediate strings. const { strokes, selected, leaders, caps } = useMemo(() => { - const byColour = new Map() - let sel = '', lead = '' - let caps = '' + const byColour = new Map() + const selectedParts: string[] = [], leaderParts: string[] = [], capParts: string[] = [] const arc = (n: LNode) => { const first = n.kids[0], last = n.kids[n.kids.length - 1] if (mode === 'slanted' || mode === 'unrooted') return '' @@ -38,9 +39,10 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o return `M${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)}A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a2)},${cy + r * Math.sin(a2)}` } for (const n of layout.nodes) { - let p = '' - if (n.d.parent) p += branchPath(n) - if (n.kids.length) p += arc(n) + const p = [ + n.d.parent ? branchPath(n) : '', + n.kids.length ? arc(n) : '', + ].join('') if (n.capped) { // Two slashes across the middle of a branch drawn shorter than it is. const sx = mode === 'rectangular' ? n.px : n.ex, sy = mode === 'rectangular' ? n.y : n.ey @@ -50,27 +52,34 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o const vx = 4 * (-uy + 0.5 * ux), vy = 4 * (ux + 0.5 * uy) for (const o of [-2, 2]) { const qx = mx + o * ux, qy = my + o * uy - caps += `M${qx - vx},${qy - vy}L${qx + vx},${qy + vy}` + capParts.push(`M${qx - vx},${qy - vy}L${qx + vx},${qy + vy}`) } } const c = n.d.style.branch ?? null const key = c ? `${c.hex}/${c.alpha}` : '' const entry = byColour.get(key) - if (entry) entry.d += p - else byColour.set(key, { rgba: c, d: p }) - if (selSubtree.has(n.d.id)) sel += p + if (entry) entry.parts.push(p) + else byColour.set(key, { rgba: c, parts: [p] }) + if (selSubtree.has(n.d.id)) selectedParts.push(p) if (aligned && !n.kids.length) { const end = n.wedge ? farthest(n.wedge, circular, cx, cy) : null if (circular) { const r0 = end ? end.r : n.radius - if (layout.alignAt - 4 > r0) lead += `M${cx + r0 * Math.cos(n.angle)},${cy + r0 * Math.sin(n.angle)}L${cx + (layout.alignAt - 4) * Math.cos(n.angle)},${cy + (layout.alignAt - 4) * Math.sin(n.angle)}` + if (layout.alignAt - 4 > r0) { + leaderParts.push(`M${cx + r0 * Math.cos(n.angle)},${cy + r0 * Math.sin(n.angle)}L${cx + (layout.alignAt - 4) * Math.cos(n.angle)},${cy + (layout.alignAt - 4) * Math.sin(n.angle)}`) + } } else { const x0 = end ? end.x : n.x - if (layout.alignAt - 4 > x0) lead += `M${x0},${n.y}H${layout.alignAt - 4}` + if (layout.alignAt - 4 > x0) leaderParts.push(`M${x0},${n.y}H${layout.alignAt - 4}`) } } } - return { strokes: [...byColour.values()], selected: sel, leaders: lead, caps } + return { + strokes: [...byColour.values()].map(({ rgba, parts }) => ({ rgba, d: parts.join('') })), + selected: selectedParts.join(''), + leaders: leaderParts.join(''), + caps: capParts.join(''), + } }, [layout, circular, cx, cy, selSubtree, aligned, mode]) // Label anchor: beside the node, or in the aligned column. diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index dec731e1..624322a3 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -2181,7 +2181,11 @@ function _build_phylo_or_combined_tree( Epistemic.cloud_size_by_residual(res), is_root ? 0.0 : max(0.0, u.branch_length), u.support === nothing ? sup : u.support, - 0.0, + # Keep the same node-local convention as clade_faith_pd and + # _reaggregate_after_move: an active non-root tip carries its + # incoming edge. This makes an unchanged tip's PD comparable + # before and after an accepted drag-and-drop proposal. + cnt > 0.0 && !is_root ? max(0.0, u.branch_length) : 0.0, tx, tx === nothing ? nothing : 1.0, tx === nothing ? nothing : (total_tips_per_taxon[tx] == 1), diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 670d119f..37c1bb1b 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -610,6 +610,18 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test low_clade.epistemic_status == "present_in_some" @test low_clade.colour == "#f9a825" + # Node-local PD has the same meaning on tips as on internal clades: + # an active non-root tip includes its incoming branch. Every tip is + # active in at least one sample in this fixture. + for tip_id in ("ASV_1", "ASV_2", "ASV_3", "ASV_4") + tip = _cc_find(comb.root, tip_id) + @test tip !== nothing + if tip !== nothing + @test Float64(tip.cumulative_count) > 0.0 + @test Float64(tip.subtree_pd) ≈ Float64(tip.branch_length) atol = 1e-10 + end + end + # 3. Validate drag-and-drop: moving ASV_3 into low-support clade fails warrant check! r_deny = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus/validate-move", JSON3.write(Dict( "table" => "merged", @@ -677,6 +689,22 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test Float64(proposed_target.subtree_pd) != before_target_pd end + # Reaggregation must not change the PD stored on a tip merely + # because a proposal was accepted. This covers unchanged tips and + # the moved tip, whose incoming branch itself is unchanged. + for tip_id in ("ASV_1", "ASV_2", "ASV_3", "ASV_4") + original_tip = _cc_find(comb.root, tip_id) + proposed_tip = _cc_find_dict(v_allow.proposed_root, tip_id) + @test original_tip !== nothing + @test proposed_tip !== nothing + if original_tip !== nothing && proposed_tip !== nothing + @test Float64(proposed_tip["subtree_pd"]) ≈ + Float64(original_tip.subtree_pd) atol = 1e-10 + @test Float64(proposed_tip["branch_length"]) ≈ + Float64(original_tip.branch_length) atol = 1e-10 + end + end + # Re-saving under a different threshold writes a distinct immutable sidecar file r_allow2 = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus/validate-move", JSON3.write(Dict( "table" => "merged", @@ -843,6 +871,18 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 if relocated !== nothing @test Float64(relocated["cumulative_count"]) == moved_reads @test Float64(relocated["residual_count"]) == moved_residual + # The moved clade's 0.30 incoming branch and its two tip + # branches (0.10 and 0.20) each occur exactly once. + @test Float64(relocated["subtree_pd"]) ≈ 0.60 atol = 1e-9 + end + proposed_target = _cc_find_dict(proposed_root, String(target.id)) + @test proposed_target !== nothing + if proposed_target !== nothing + # The target contributes its own 0.40 edge, two original tip + # edges (0.15 and 0.25), and the relocated clade's complete + # 0.60 node-local PD. Adding the relocated incoming edge again + # at the parent would incorrectly produce 1.70 rather than 1.40. + @test Float64(proposed_target["subtree_pd"]) ≈ 1.40 atol = 1e-9 end @test Float64(proposed_root["cumulative_count"]) ≈ before_total atol = 1e-9 # The emptied wrapper is childless but is not a tip: it must not