diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 3d21dca0..5e841ce8 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -41,6 +41,7 @@ jobs: run: | bash scripts/check-governance-contract.sh bash scripts/tests/test-governance-contract.sh + bash scripts/tests/test-hypatia-inherited-language-exception.sh bash scripts/tests/test-ci-logging.sh bash scripts/tests/test-k9-log-copy.sh bash scripts/tests/test-scanner-boundaries.sh diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index a71eee49..b797c2f6 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -4,7 +4,7 @@ # # Fork-local copy of hyperpolymath/standards/.github/workflows/ # hypatia-scan-reusable.yml at 01c9b0c9f2646ebea0b30a9588462642ac94aee9. -# Keep that source pin when updating this file and reapply the two fork deltas: +# Keep that source pin when updating this file and reapply the three fork deltas: # 1. Do not upload SARIF for pull_request events. This lane is advisory on # PRs; uploading error-level results creates a separate, blocking # "Code scanning results / Hypatia" check even when the scan job passes. @@ -12,7 +12,10 @@ # manual runs continue to reconcile code scanning on the default branch. # 2. Pin the standards checkout explicitly. job.workflow_sha now names a # MetaManifold commit, not a commit in hyperpolymath/standards. -# scripts/check-governance-contract.sh guards both deltas. +# 3. Filter only upstream-inherited TypeScript paths from the effective +# findings, validate the raw/effective JSON with pinned ijson-jcs, and keep +# raw scanner outputs plus fork-only paths/rules. +# scripts/check-governance-contract.sh guards these deltas. # # Hypatia Neurosymbolic CI/CD Security Scan — Reusable Workflow name: Hypatia Reusable Scan @@ -84,6 +87,56 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Read the pinned ijson-jcs identity + id: ijson_jcs_pin + run: | + python3 - <<'PY' + import os, re, tomllib + with open('config/format-tools.toml', 'rb') as stream: + pin = tomllib.load(stream)['ijson_jcs'] + if pin['repository'] != 'https://github.com/hyperpolymath/ijson-jcs': + raise SystemExit('unexpected ijson-jcs repository') + if not re.fullmatch(r'[0-9a-f]{40}', pin['revision']): + raise SystemExit('ijson-jcs must be pinned to an immutable commit') + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', pin['rust_toolchain']): + raise SystemExit('ijson-jcs Rust toolchain must be an explicit release') + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f"repository={pin['repository']}\n") + stream.write(f"revision={pin['revision']}\n") + stream.write(f"rust_toolchain={pin['rust_toolchain']}\n") + PY + + - name: Cache the pinned ijson-jcs binary + id: ijson_jcs_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cargo/bin/ijson-jcs + key: ijson-jcs-${{ steps.ijson_jcs_pin.outputs.revision }}-${{ steps.ijson_jcs_pin.outputs.rust_toolchain }}-${{ runner.os }}-${{ runner.arch }} + + - name: Install pinned ijson-jcs + if: steps.ijson_jcs_cache.outputs.cache-hit != 'true' + env: + IJSON_JCS_REPOSITORY: ${{ steps.ijson_jcs_pin.outputs.repository }} + IJSON_JCS_REVISION: ${{ steps.ijson_jcs_pin.outputs.revision }} + IJSON_JCS_RUST_TOOLCHAIN: ${{ steps.ijson_jcs_pin.outputs.rust_toolchain }} + run: | + set -euo pipefail + rustup toolchain install "$IJSON_JCS_RUST_TOOLCHAIN" --profile minimal + cargo "+$IJSON_JCS_RUST_TOOLCHAIN" install --quiet \ + --git "$IJSON_JCS_REPOSITORY" --rev "$IJSON_JCS_REVISION" \ + --locked --root "$HOME/.cargo" ijson-jcs + + - name: Verify ijson-jcs and test the exception output + run: | + set -euo pipefail + IJSON_JCS="$HOME/.cargo/bin/ijson-jcs" + test -x "$IJSON_JCS" + "$IJSON_JCS" --version + printf '%s' '{"b":2,"a":1}' | "$IJSON_JCS" canon > "$RUNNER_TEMP/ijson-jcs-smoke.json" + printf '%s' '{"a":1,"b":2}' | cmp - "$RUNNER_TEMP/ijson-jcs-smoke.json" + "$IJSON_JCS" check "$RUNNER_TEMP/ijson-jcs-smoke.json" >/dev/null + IJSON_JCS="$IJSON_JCS" bash scripts/tests/test-hypatia-inherited-language-exception.sh + - name: Setup Elixir for Hypatia scanner uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: @@ -201,7 +254,14 @@ jobs: # Missing/truncated/multi-document output is a scanner error and fails # closed; a single well-formed `[]` is a valid clean scan when the # scanner step succeeded (standards#1054). - if [ ! -s hypatia-findings.json ] || ! jq -e -s ' + if [ ! -s hypatia-findings.json ]; then + echo "::error::Hypatia did not produce hypatia-findings.json" + exit 2 + fi + # Validate original bytes as I-JSON before jq can parse or normalize + # them. The raw scanner artifact itself remains untouched. + "$HOME/.cargo/bin/ijson-jcs" canon < hypatia-findings.json > /dev/null + if ! jq -e -s ' length == 1 and (.[0] | type == "array" and all(.[]; type == "object" and (.severity as $s | ["critical", "high", "medium", "warn", "low", "info", "informational"] | index($s) != null))) @@ -224,7 +284,7 @@ jobs: } >> "$GITHUB_OUTPUT" { - echo "## Hypatia Scan Results" + echo "## Raw Hypatia Scan Results (before fork exception)" echo "" echo "| Severity | Count |" echo "|----------|-------|" @@ -232,6 +292,8 @@ jobs: echo "| High | $HIGH |" echo "| Medium | $MEDIUM |" echo "| **Total**| $FINDING_COUNT |" + echo "" + echo "These are the unmodified scanner counts. The fork-local upstream TypeScript exception and the effective counts used by the verdict grid and gates are reported below." } >> "$GITHUB_STEP_SUMMARY" - name: Relativize finding paths @@ -250,6 +312,51 @@ jobs: ' hypatia-findings.json > hypatia-findings.relativized.json fi + - name: Apply upstream-inherited TypeScript exception + id: inherited-language + if: ${{ always() }} + run: | + set -euo pipefail + # The raw scanner outputs stay intact for audit. This fork-local + # exception changes only the effective JSON/SARIF projections used by + # the verdict grid, severity advisory, baseline gate, and code scanning. + if [ ! -s hypatia-findings.relativized.json ] || [ ! -s hypatia.sarif ]; then + echo "::notice::Raw Hypatia output is unavailable; no inherited-TypeScript exception was applied." + exit 0 + fi + allowlist=config/hypatia/upstream-typescript-allowlist.txt + bash scripts/filter-hypatia-inherited-language.sh \ + hypatia-findings.relativized.json hypatia.sarif "$allowlist" \ + hypatia-findings.effective.json hypatia-effective.sarif \ + "$HOME/.cargo/bin/ijson-jcs" + + RAW_COUNT=$(jq 'length' hypatia-findings.relativized.json) + EFFECTIVE_COUNT=$(jq 'length' hypatia-findings.effective.json) + EXCLUDED_COUNT=$((RAW_COUNT - EFFECTIVE_COUNT)) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.effective.json) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.effective.json) + MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' hypatia-findings.effective.json) + { + echo "raw_findings_count=$RAW_COUNT" + echo "excluded_count=$EXCLUDED_COUNT" + echo "findings_count=$EFFECTIVE_COUNT" + echo "critical=$CRITICAL" + echo "high=$HIGH" + echo "medium=$MEDIUM" + } >> "$GITHUB_OUTPUT" + UPSTREAM_SOURCE=$(sed -n 's/^# Source: //p' "$allowlist") + { + echo "## Fork-local upstream TypeScript exception" + echo "" + echo "Source snapshot: $UPSTREAM_SOURCE. Only cicd_rules/banned_language_file findings for exact .ts/.tsx paths in $allowlist are exempted." + echo "" + echo "- Raw findings retained: $RAW_COUNT" + echo "- Inherited TypeScript findings excluded from effective results: $EXCLUDED_COUNT" + echo "- Findings sent to the verdict grid and gates: $EFFECTIVE_COUNT ($CRITICAL critical, $HIGH high, $MEDIUM medium/warn)" + echo "- Fork-only TypeScript paths and all other rules remain reportable." + echo "- Raw JSON remains unchanged in hypatia-findings.json; hypatia-findings.effective.json is I-JSON validated and JCS canonicalized with the repo-pinned tool." + } >> "$GITHUB_STEP_SUMMARY" + # panic-attack is ADVISORY: it never fails this job, because this # reusable is called estate-wide and the owner has not made it a gate. # It is never silent either. Until 2026-10 nothing installed it, so the @@ -549,7 +656,7 @@ jobs: # Use the CALLER's apply-baseline.sh — the same copy the gate runs, # so the SARIF and the gate cannot disagree about what is acknowledged. APPLY_BASELINE=scripts/apply-baseline.sh \ - bash "$FILTER" hypatia.sarif hypatia-findings.relativized.json .hypatia-baseline.json + bash "$FILTER" hypatia-effective.sarif hypatia-findings.effective.json .hypatia-baseline.json - name: Upload SARIF to code scanning # Advisory PR scans must not create a second, blocking code-scanning @@ -566,7 +673,7 @@ jobs: continue-on-error: true uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) with: - sarif_file: hypatia.sarif + sarif_file: hypatia-effective.sarif category: hypatia - name: Upload findings artifacts @@ -575,6 +682,7 @@ jobs: name: hypatia-scan-findings path: | hypatia-findings.json + hypatia-findings.effective.json panic-attack-findings.json pons-findings.json retention-days: 90 @@ -641,8 +749,12 @@ jobs: echo "state=orange" >> "$GITHUB_OUTPUT" exit 0 fi + # Prefer the exception-aware projection. If that step could not run, + # fall back to raw findings so a failed exception transform can never + # hide scanner output; the job itself still records the failure. findings=hypatia-findings.json [ -f hypatia-findings.relativized.json ] && findings=hypatia-findings.relativized.json + [ -f hypatia-findings.effective.json ] && findings=hypatia-findings.effective.json enrolment="" [ -f .gitbot-fleet/enrolment.json ] && enrolment=.gitbot-fleet/enrolment.json # The renderer appends the grid to the step summary and writes @@ -667,7 +779,7 @@ jobs: retention-days: 90 - name: Gate on baseline (blocking when a baseline is committed) - if: '!inputs.block-on-high && steps.scan.outputs.findings_count > 0' + if: '!inputs.block-on-high && (steps.inherited-language.outputs.findings_count > 0 || hashFiles(''.hypatia-baseline.json'') != '''')' run: | # Wave-8 gate promotion: when the calling repo commits a # .hypatia-baseline.json (schema: .machine_readable/hypatia-baseline. @@ -678,11 +790,10 @@ jobs: # itself the moment a baseline lands (standards#399/#437/#446). if [ -f scripts/apply-baseline.sh ] && [ -f .hypatia-baseline.json ]; then echo "Baseline present — running BLOCKING gate (threshold: high)." - # Paths were relativized in an earlier step so the SARIF filter and - # this gate agree on what a finding's path is. Two definitions of - # "the same finding" is how the baseline came to mean one thing to - # the gate and nothing to the security tab. - bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json blocking + # Use the exception-aware findings so inherited upstream TypeScript + # is treated consistently by the gate and the Security tab. Paths + # were relativized before applying the fork-local exception. + bash scripts/apply-baseline.sh hypatia-findings.effective.json .hypatia-baseline.json blocking else echo "No committed baseline — gate stays advisory (see next step)." fi @@ -709,7 +820,7 @@ jobs: echo "::error::A committed baseline requires scripts/apply-baseline.sh" exit 2 fi - bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json advisory >/dev/null + bash scripts/apply-baseline.sh hypatia-findings.effective.json .hypatia-baseline.json advisory >/dev/null if [ "${HYPATIA_BASELINE_FILTERED:-}" != "true" ]; then echo "::error::Baseline validation passed but SARIF filtering did not complete" exit 2 @@ -725,11 +836,11 @@ jobs: all(.results[]; type == "object" and (.ruleId | type == "string" and length > 0) and (.level as $level | ["error", "warning", "note", "none"] | index($level) != null)))) - ' hypatia.sarif >/dev/null; then - echo "::error::Hypatia did not produce one valid SARIF findings document" + ' hypatia-effective.sarif >/dev/null; then + echo "::error::Hypatia did not produce one valid exception-aware SARIF findings document" exit 2 fi - count=$(jq '[.runs[].results[] | select(.level == "error")] | length' hypatia.sarif) + count=$(jq '[.runs[].results[] | select(.level == "error")] | length' hypatia-effective.sarif) if [ "$count" -gt 0 ]; then echo "::error::Hypatia found $count current high or critical finding(s); see the SARIF artifact" exit 1 @@ -737,17 +848,17 @@ jobs: echo "Hypatia blocking gate: no current high or critical findings after baseline filtering" - name: Check for critical issues (ADVISORY — does not gate) - if: '!inputs.block-on-high && steps.scan.outputs.critical > 0' + if: '!inputs.block-on-high && steps.inherited-language.outputs.critical > 0' run: | # This scan is ADVISORY / fix-forward WHEN NO BASELINE IS COMMITTED # (the step above becomes the blocking gate once .hypatia-baseline.json # lands). The label and summary state that explicitly so a green check # carrying critical findings is not mistaken for "no critical findings". - echo "::warning title=Hypatia (ADVISORY, non-blocking)::${{ steps.scan.outputs.critical }} critical finding(s). This scan does not gate — review hypatia-findings.json and fix forward." + echo "::warning title=Hypatia (ADVISORY, non-blocking)::${{ steps.inherited-language.outputs.critical }} effective critical finding(s). This scan does not gate — review hypatia-findings.effective.json; raw findings remain in hypatia-findings.json." { echo "### Hypatia scan — ADVISORY (does not gate)" echo "" - echo "**${{ steps.scan.outputs.critical }} critical finding(s)** reported." + echo "**${{ steps.inherited-language.outputs.critical }} effective critical finding(s)** reported after the upstream TypeScript exception." echo "This scan is fix-forward and **never fails the build**; a green" echo "check here does not mean zero critical findings. See the" echo "\`hypatia-scan-findings\` artifact. Blocking promotion: standards#399/#437." diff --git a/config/hypatia/upstream-typescript-allowlist.txt b/config/hypatia/upstream-typescript-allowlist.txt new file mode 100644 index 00000000..4f43f08c --- /dev/null +++ b/config/hypatia/upstream-typescript-allowlist.txt @@ -0,0 +1,114 @@ +# Fork exception: TypeScript/TSX files already tracked by the upstream repository. +# Source: JoshuaJewell/MetaManifold-WebUI @ a325589d3d318847c15b21cd34fb7d6e7a6d2f7b (upstream main at audit time). +# Only cicd_rules/banned_language_file findings for these exact paths are exempted. +# Refresh deliberately when syncing upstream; local-only TypeScript paths stay flagged. +frontend/src/App.tsx +frontend/src/about.ts +frontend/src/api/client.ts +frontend/src/api/errorMessage.ts +frontend/src/api/events.ts +frontend/src/api/types.ts +frontend/src/components/AddToReport.tsx +frontend/src/components/AnalysisChart.tsx +frontend/src/components/AnalysisControls.tsx +frontend/src/components/AnalysisWorkspace.tsx +frontend/src/components/Breadcrumb.tsx +frontend/src/components/CardActions.tsx +frontend/src/components/CategorySetEditor.tsx +frontend/src/components/CompositionPanel.tsx +frontend/src/components/ComputingNote.tsx +frontend/src/components/ConfigAccordion.tsx +frontend/src/components/DataTable.tsx +frontend/src/components/DatabaseEditor.tsx +frontend/src/components/DifferentialPanel.tsx +frontend/src/components/DiversityPanel.tsx +frontend/src/components/EditorCard.tsx +frontend/src/components/ErrorBoundary.tsx +frontend/src/components/FilterEditor.tsx +frontend/src/components/JobBadge.tsx +frontend/src/components/NameDialog.tsx +frontend/src/components/PairEditor.tsx +frontend/src/components/PipelineStages.test.ts +frontend/src/components/PipelineStages.tsx +frontend/src/components/PlacementPanel.tsx +frontend/src/components/PlotlyChart.tsx +frontend/src/components/PrimerListEditor.tsx +frontend/src/components/PublicationTablesPanel.tsx +frontend/src/components/ReportPanel.tsx +frontend/src/components/Skeleton.tsx +frontend/src/components/TaxaCompositionChart.tsx +frontend/src/components/Toast.tsx +frontend/src/components/TreesPanel.tsx +frontend/src/components/VennPanel.tsx +frontend/src/components/alphaMetrics.tsx +frontend/src/components/analysisSource.ts +frontend/src/components/annotationShared.test.ts +frontend/src/components/annotationShared.ts +frontend/src/components/dataTable/ColumnDropdown.tsx +frontend/src/components/dataTable/NumericFilter.tsx +frontend/src/components/dataTable/RowPopup.tsx +frontend/src/components/dataTable/SampleReadsControl.tsx +frontend/src/components/dataTable/TextFilter.tsx +frontend/src/components/dataTable/copy.ts +frontend/src/components/differentialTable.test.ts +frontend/src/components/differentialTable.ts +frontend/src/components/sharedRanks.test.ts +frontend/src/components/sharedRanks.ts +frontend/src/figure/FigureBuilder.tsx +frontend/src/figure/FigureEditor.tsx +frontend/src/figure/PaneEditor.tsx +frontend/src/figure/charts.ts +frontend/src/figure/encode.ts +frontend/src/figure/export.ts +frontend/src/figure/figure.test.ts +frontend/src/figure/legend.ts +frontend/src/figure/types.ts +frontend/src/hooks/useAnalysis.ts +frontend/src/hooks/useApi.test.ts +frontend/src/hooks/useApi.ts +frontend/src/hooks/useJobEvents.ts +frontend/src/hooks/useNavRefresh.ts +frontend/src/hooks/useSSE.ts +frontend/src/hooks/useTabParam.ts +frontend/src/hooks/useUnsavedGuard.ts +frontend/src/layout/Layout.tsx +frontend/src/main.tsx +frontend/src/phylogeny/TrimEditor.tsx +frontend/src/phylogeny/common.tsx +frontend/src/phylogeny/qc.tsx +frontend/src/tree/ImportPanel.tsx +frontend/src/tree/Inspector.tsx +frontend/src/tree/Legend.tsx +frontend/src/tree/TreeShapes.tsx +frontend/src/tree/controls.tsx +frontend/src/tree/layout.ts +frontend/src/tree/model.test.ts +frontend/src/tree/model.ts +frontend/src/tree/placementLwr.test.ts +frontend/src/tree/ui.ts +frontend/src/tree/viewDoc.ts +frontend/src/utils/download.ts +frontend/src/utils/text.ts +frontend/src/utils/timeago.ts +frontend/src/views/AboutView.tsx +frontend/src/views/CompositionsView.tsx +frontend/src/views/DatabasesView.tsx +frontend/src/views/DefaultConfigView.tsx +frontend/src/views/GroupView.tsx +frontend/src/views/JobsView.tsx +frontend/src/views/NotFoundView.tsx +frontend/src/views/PrimersView.tsx +frontend/src/views/ReferenceTreesView.tsx +frontend/src/views/RunView.tsx +frontend/src/views/SlugResolver.tsx +frontend/src/views/StudiesView.tsx +frontend/src/views/StudyView.tsx +frontend/src/views/TreeView.tsx +frontend/src/views/run/DADA2Panel.tsx +frontend/src/views/run/QCPanel.tsx +frontend/src/views/run/ReadFunnel.tsx +frontend/src/views/run/RunAlpha.tsx +frontend/src/views/run/TablesPanel.tsx +frontend/src/views/run/parseFilterYaml.ts +frontend/src/vite-env.d.ts +frontend/vite.config.ts diff --git a/docs/release-notes/2026-10-23-governance-security.md b/docs/release-notes/2026-10-23-governance-security.md index 4d963640..01e7948c 100644 --- a/docs/release-notes/2026-10-23-governance-security.md +++ b/docs/release-notes/2026-10-23-governance-security.md @@ -123,6 +123,16 @@ It is historical evidence, not a freshly executed scan of this change. - **Estate applicability:** the fixed React/TypeScript architecture and the independent Python scientific reference oracles conflict with estate language-ban heuristics. Do not rewrite them to make a badge green. +- **Fork-local TypeScript exception:** `cicd_rules/banned_language_file` is + excluded from the effective grid, gate and SARIF only for exact `.ts`/`.tsx` + paths tracked by JoshuaJewell/MetaManifold-WebUI at upstream commit + `a325589d3d318847c15b21cd34fb7d6e7a6d2f7b`. Raw scanner findings remain + unchanged for audit; the effective findings JSON is validated as I-JSON and + canonicalized with the repository-pinned ijson-jcs tool. Fork-only TypeScript + paths and every other rule remain reportable. + Refresh the reviewed path snapshot deliberately when syncing upstream. The + pinned scanner does not honor `.hypatia-ignore` for this rule, so that file + would not implement this exception. - **Scanner discovery:** Hypatia is called by `governance.yml`, not the heuristic's expected `hypatia-scan.yml`. CodeQL is already supplied by the GitHub App (Actions, JS/TS and Python checks on #227); do not run a second diff --git a/scripts/check-governance-contract.sh b/scripts/check-governance-contract.sh index 4fc7c897..639bc945 100755 --- a/scripts/check-governance-contract.sh +++ b/scripts/check-governance-contract.sh @@ -2,9 +2,9 @@ # SPDX-License-Identifier: AGPL-3.0-only # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) # -# Guard the fork-local Hypatia advisory-scan contract. In particular, a PR scan -# must remain visible in artifacts/the verdict grid without uploading SARIF to -# GitHub's independent, blocking code-scanning-results check. +# Guard the fork-local Hypatia scan contract. A PR scan remains visible without +# creating an independent blocking SARIF check, and the upstream TypeScript +# exception stays exact-path, narrow-rule, and audit-preserving. set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" @@ -12,7 +12,12 @@ cd "$ROOT" CALLER=.github/workflows/governance.yml REUSABLE=.github/workflows/hypatia-scan-reusable.yml +ALLOWLIST=config/hypatia/upstream-typescript-allowlist.txt +FORMAT_TOOLS=config/format-tools.toml STANDARDS_SHA=01c9b0c9f2646ebea0b30a9588462642ac94aee9 +UPSTREAM_SHA=a325589d3d318847c15b21cd34fb7d6e7a6d2f7b +# SHA-256 of the sorted unique paths audited at UPSTREAM_SHA, with a final LF. +ALLOWLIST_SHA256=531f92fdf33487a03b03381c40a6cc9a53a771b870b92fc18feed18c9c1798a7 failures=0 checks=0 @@ -58,8 +63,52 @@ for file in "$CALLER" "$REUSABLE"; do fail "$file is missing or empty" fi done +if [ -s "$ALLOWLIST" ]; then + pass "$ALLOWLIST exists" +else + fail "$ALLOWLIST is missing or empty" +fi +if [ -s "$FORMAT_TOOLS" ]; then + pass "$FORMAT_TOOLS exists" +else + fail "$FORMAT_TOOLS is missing or empty" +fi + +# Do not continue with misleading secondary errors if an input is absent. +if [ "$failures" -gt 0 ]; then + exit 1 +fi + +if grep -Fq "# Source: JoshuaJewell/MetaManifold-WebUI @ $UPSTREAM_SHA " "$ALLOWLIST"; then + pass 'the TypeScript exception records its immutable upstream source snapshot' +else + fail "the TypeScript exception must record upstream commit $UPSTREAM_SHA" +fi +if grep -Fqx 'frontend/src/App.tsx' "$ALLOWLIST"; then + pass 'an upstream TypeScript path is included in the exact-path allowlist' +else + fail 'the exact-path allowlist includes the known upstream App.tsx path' +fi +if grep -Fqx 'frontend/src/utils/text.test.ts' "$ALLOWLIST"; then + fail 'the fork-only TypeScript path must remain outside the upstream allowlist' +else + pass 'the fork-only TypeScript path remains outside the upstream allowlist' +fi +allowlist_entries="$(grep -Ev '^(#|$)' "$ALLOWLIST" || true)" +allowlist_digest="$(printf '%s\n' "$allowlist_entries" | LC_ALL=C sort -u | sha256sum)" +if [ "${allowlist_digest%% *}" = "$ALLOWLIST_SHA256" ]; then + pass 'the complete allowlist matches the pinned upstream TypeScript snapshot' +else + fail 'the complete allowlist must match the pinned upstream TypeScript snapshot' +fi +if [ -n "$allowlist_entries" ] && ! grep -Eq '[*?]' <<<"$allowlist_entries" \ + && ! grep -Eqv '\.(ts|tsx)$' <<<"$allowlist_entries"; then + pass 'the allowlist contains exact TypeScript paths, not globs or other languages' +else + fail 'the allowlist must contain only exact .ts/.tsx paths, without glob syntax' +fi -# Do not continue with misleading secondary errors if either input is absent. +# Stop after a broken exception manifest rather than emitting secondary noise. if [ "$failures" -gt 0 ]; then exit 1 fi @@ -89,11 +138,40 @@ contains "$reusable" "hypatia-scan-reusable.yml at $STANDARDS_SHA." \ not_contains "$reusable" 'ref: ${{ job.workflow_sha }}' \ 'a standards checkout cannot use the MetaManifold workflow SHA' +if jcs_pin="$(step_block "$REUSABLE" 'Read the pinned ijson-jcs identity')"; then + contains "$jcs_pin" 'config/format-tools.toml' \ + 'the JCS tool pin comes from the repository format-tools manifest' + contains "$jcs_pin" "['ijson_jcs']" \ + 'the JCS installer reads the ijson-jcs identity from the manifest' + contains "$jcs_pin" "pin['rust_toolchain']" \ + 'the JCS Rust toolchain is resolved from the same manifest' +else + fail 'the pinned ijson-jcs identity step exists' +fi +if jcs_install="$(step_block "$REUSABLE" 'Install pinned ijson-jcs')"; then + contains "$jcs_install" 'cargo "+$IJSON_JCS_RUST_TOOLCHAIN" install --quiet' \ + 'ijson-jcs is built with the pinned Rust toolchain' + contains "$jcs_install" '--git "$IJSON_JCS_REPOSITORY" --rev "$IJSON_JCS_REVISION"' \ + 'ijson-jcs is built from the immutable repository revision' + contains "$jcs_install" '--locked' \ + 'ijson-jcs dependencies are installed from Cargo.lock' +else + fail 'the pinned ijson-jcs install step exists' +fi +if jcs_verify="$(step_block "$REUSABLE" 'Verify ijson-jcs and test the exception output')"; then + contains "$jcs_verify" 'IJSON_JCS="$IJSON_JCS" bash scripts/tests/test-hypatia-inherited-language-exception.sh' \ + 'the exception transform is tested with the real pinned canonicalizer' +else + fail 'the pinned ijson-jcs smoke and exception test step exists' +fi + if upload="$(step_block "$REUSABLE" 'Upload SARIF to code scanning')"; then contains "$upload" "if: github.event_name != 'pull_request'" \ 'advisory pull-request scans skip SARIF upload' contains "$upload" 'uses: github/codeql-action/upload-sarif@' \ 'push and manual scans retain code-scanning reconciliation' + contains "$upload" 'sarif_file: hypatia-effective.sarif' \ + 'code scanning receives the exception-aware SARIF projection' else fail 'the SARIF upload step exists' fi @@ -107,9 +185,32 @@ else fail 'the Hypatia scan step exists' fi +if exception="$(step_block "$REUSABLE" 'Apply upstream-inherited TypeScript exception')"; then + contains "$exception" 'if: ${{ always() }}' \ + 'the exception step records a result even after scan failures' + contains "$exception" 'scripts/filter-hypatia-inherited-language.sh' \ + 'the fork-local allowlist transform is applied after path normalization' + contains "$exception" 'hypatia-findings.effective.json' \ + 'the exception creates a distinct effective findings projection' + contains "$exception" 'hypatia-effective.sarif' \ + 'the exception creates a distinct effective SARIF projection' + contains "$exception" '"$HOME/.cargo/bin/ijson-jcs"' \ + 'the effective findings are canonicalized with the pinned CLI' +else + fail 'the upstream-inherited TypeScript exception step exists' +fi +if raw_validation="$(step_block "$REUSABLE" 'Validate findings and count severities')"; then + contains "$raw_validation" 'ijson-jcs" canon < hypatia-findings.json' \ + 'raw findings are strict I-JSON validated before jq parses them' +else + fail 'raw I-JSON validation is part of the findings check' +fi + if artifacts="$(step_block "$REUSABLE" 'Upload findings artifacts')"; then contains "$artifacts" 'hypatia-findings.json' \ 'raw findings remain available on advisory pull-request runs' + contains "$artifacts" 'hypatia-findings.effective.json' \ + 'the exception-aware findings remain available alongside raw evidence' not_contains "$artifacts" 'if: github.event_name' \ 'artifact upload is not accidentally restricted by event' else @@ -121,10 +222,39 @@ if grid="$(step_block "$REUSABLE" 'Render the verdict grid')"; then 'the verdict grid still renders after scanner failures' contains "$grid" '--hypatia' \ 'the verdict grid still consumes Hypatia findings' + contains "$grid" 'hypatia-findings.effective.json' \ + 'the verdict grid prefers the exception-aware projection' else fail 'the verdict-grid render step exists' fi +if baseline="$(step_block "$REUSABLE" 'Filter SARIF through the baseline before upload')"; then + contains "$baseline" 'hypatia-effective.sarif hypatia-findings.effective.json' \ + 'baseline filtering uses the same exception-aware findings as the grid' +else + fail 'the SARIF baseline-filter step exists' +fi +if gate="$(step_block "$REUSABLE" 'Gate on baseline (blocking when a baseline is committed)')"; then + contains "$gate" 'hypatia-findings.effective.json' \ + 'the baseline gate consumes exception-aware findings' +else + fail 'the baseline gate step exists' +fi +if blocking="$(step_block "$REUSABLE" 'Block high and critical findings when requested')"; then + contains "$blocking" 'hypatia-effective.sarif' \ + 'the blocking gate evaluates exception-aware SARIF' +else + fail 'the requested high/critical gate step exists' +fi +if advisory="$(step_block "$REUSABLE" 'Check for critical issues (ADVISORY — does not gate)')"; then + contains "$advisory" 'steps.inherited-language.outputs.critical' \ + 'the critical advisory count excludes only the inherited TypeScript exception' +else + fail 'the critical advisory step exists' +fi +not_contains "$reusable" 'steps.scan.outputs.critical > 0' \ + 'the raw critical count cannot drive an exception-inconsistent advisory' + if renderer="$(step_block "$REUSABLE" 'Check out the verdict grid renderer at this workflow'"'"'s commit')"; then contains "$renderer" 'repository: hyperpolymath/standards' \ 'the renderer comes from the standards repository' diff --git a/scripts/filter-hypatia-inherited-language.sh b/scripts/filter-hypatia-inherited-language.sh new file mode 100755 index 00000000..f6758c21 --- /dev/null +++ b/scripts/filter-hypatia-inherited-language.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Apply the fork-local, upstream-inherited TypeScript exception without mutating +# Hypatia's raw JSON or SARIF outputs. Only exact allowlisted .ts/.tsx paths and +# the cicd_rules/banned_language_file rule are affected. CI passes the pinned +# ijson-jcs CLI to validate raw inputs and canonicalize the effective JSON. +set -euo pipefail + +# Write the command synopsis to standard error and exit the script with status 2. +usage() { + echo "Usage: $0 RAW_FINDINGS.json RAW.sarif ALLOWLIST.txt EFFECTIVE_FINDINGS.json EFFECTIVE.sarif [IJSON_JCS]" >&2 + exit 2 +} + +[[ $# -eq 5 || $# -eq 6 ]] || usage +raw_findings=$1 +raw_sarif=$2 +allowlist=$3 +effective_findings=$4 +effective_sarif=$5 +canonicalizer=${6:-} + +for input in "$raw_findings" "$raw_sarif" "$allowlist"; do + if [[ ! -s "$input" ]]; then + echo "Required input is missing or empty: $input" >&2 + exit 1 + fi +done + +# Resolve relative components and symlinks even when output directories do not +# exist yet. Neither output may overwrite either raw audit artifact. +raw_findings=$(realpath -m -- "$raw_findings") +raw_sarif=$(realpath -m -- "$raw_sarif") +effective_findings=$(realpath -m -- "$effective_findings") +effective_sarif=$(realpath -m -- "$effective_sarif") +for output in "$effective_findings" "$effective_sarif"; do + if [[ "$output" == "$raw_findings" || "$output" == "$raw_sarif" || "$output" -ef "$raw_findings" || "$output" -ef "$raw_sarif" ]]; then + echo "Raw inputs and effective outputs must use distinct paths." >&2 + exit 1 + fi +done +if [[ "$effective_findings" == "$effective_sarif" || "$effective_findings" -ef "$effective_sarif" ]]; then + echo "Raw inputs and effective outputs must use distinct paths." >&2 + exit 1 +fi + +if [[ -n "$canonicalizer" ]]; then + if [[ ! -x "$canonicalizer" ]]; then + echo "ijson-jcs canonicalizer is missing or not executable: $canonicalizer" >&2 + exit 1 + fi + # Validate raw inputs before jq sees them, so unsafe I-JSON numbers or + # duplicate keys cannot be silently normalized during the transform. + "$canonicalizer" canon < "$raw_findings" >/dev/null + "$canonicalizer" canon < "$raw_sarif" >/dev/null +fi +jq -e 'type == "array" and all(.[]; type == "object")' "$raw_findings" >/dev/null +jq -e 'type == "object" and (.runs | type == "array") and all(.runs[]; type == "object" and ((.results // []) | type == "array"))' "$raw_sarif" >/dev/null + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Comments and blank lines make the tracked allowlist self-documenting. +jq -Rn '[inputs | sub("\\r$"; "") | select(length > 0) | select((startswith("#")) | not)] | unique' "$allowlist" > "$tmpdir/allowlist.json" +jq -e 'length > 0 and all(.[]; type == "string" and (startswith("/") | not) and (contains("\\") | not) and (test("(^|/)\\.\\.?(/|$)") | not) and test("\\.(ts|tsx)$"))' "$tmpdir/allowlist.json" >/dev/null + +# The input to this step is already repository-relative. Normalize harmless +# leading ./ and Windows separators, but otherwise require an exact path match. +jq --argjson allowed "$(cat "$tmpdir/allowlist.json")" ' + # Yield a path with forward slashes and one leading ./ prefix removed, + # including repeated slashes in that prefix. Yield "" for non-string inputs. + def norm_path: + if type == "string" then gsub("\\\\"; "/") | sub("^\\./+"; "") else "" end; + map( + . as $finding + | (($finding.file // "") | norm_path) as $path + | if $finding.rule_module == "cicd_rules" + and $finding.type == "banned_language_file" + and ($path | test("\\.(ts|tsx)$")) + and (($allowed | index($path)) != null) + then empty + else $finding + end + ) +' "$raw_findings" > "$tmpdir/effective-findings.json" + +jq --argjson allowed "$(cat "$tmpdir/allowlist.json")" ' + # Yield a path with forward slashes and one leading ./ prefix removed, + # including repeated slashes in that prefix. Yield "" for non-string inputs. + def norm_path: + if type == "string" then gsub("\\\\"; "/") | sub("^\\./+"; "") else "" end; + .runs |= map( + . as $run + | .results = (($run.results // []) | map( + . as $result + | (($result.locations[0].physicalLocation.artifactLocation.uri // "") | norm_path) as $path + | select( + ( + $result.ruleId == "hypatia/cicd_rules/banned_language_file" + and ($path | test("\\.(ts|tsx)$")) + and (($allowed | index($path)) != null) + ) | not + ) + )) + ) +' "$raw_sarif" > "$tmpdir/effective.sarif" + +if [[ -n "$canonicalizer" ]]; then + "$canonicalizer" canon < "$tmpdir/effective-findings.json" > "$tmpdir/effective-findings.jcs.json" + "$canonicalizer" check "$tmpdir/effective-findings.jcs.json" >/dev/null + mv "$tmpdir/effective-findings.jcs.json" "$tmpdir/effective-findings.json" +fi + +mkdir -p "$(dirname "$effective_findings")" "$(dirname "$effective_sarif")" +cp "$tmpdir/effective-findings.json" "$effective_findings" +cp "$tmpdir/effective.sarif" "$effective_sarif" + +raw_count=$(jq 'length' "$raw_findings") +effective_count=$(jq 'length' "$effective_findings") +if [[ -n "$canonicalizer" ]]; then + echo 'Effective findings are I-JSON validated and JCS canonicalized with the supplied pinned ijson-jcs CLI.' >&2 +fi +printf 'Upstream-inherited TypeScript exception: excluded %d of %d findings; raw scan outputs were preserved.\n' \ + "$((raw_count - effective_count))" "$raw_count" >&2 diff --git a/scripts/tests/test-governance-contract.sh b/scripts/tests/test-governance-contract.sh index 08f5d71e..7c80eedb 100644 --- a/scripts/tests/test-governance-contract.sh +++ b/scripts/tests/test-governance-contract.sh @@ -5,15 +5,25 @@ set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" fixture=$(mktemp -d) trap 'rm -rf "$fixture"' EXIT -mkdir -p "$fixture/scripts" "$fixture/.github/workflows" +mkdir -p "$fixture/scripts" "$fixture/config/hypatia" "$fixture/.github/workflows" cp "$ROOT/scripts/check-governance-contract.sh" "$fixture/scripts/" -reset_fixture() { cp "$ROOT"/.github/workflows/*.yml "$fixture/.github/workflows/"; } +cp "$ROOT/config/format-tools.toml" "$fixture/config/" +reset_fixture() { + cp "$ROOT"/.github/workflows/*.yml "$fixture/.github/workflows/" + cp "$ROOT/config/hypatia/upstream-typescript-allowlist.txt" "$fixture/config/hypatia/" +} reset_fixture bash "$fixture/scripts/check-governance-contract.sh" > "$fixture/log" 2>&1 -for mutation in sarif k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do +for mutation in allowlist-addition sarif upstream-exception effective-grid jcs-manifest jcs-canonicalizer raw-ijson k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do reset_fixture case "$mutation" in + allowlist-addition) echo 'frontend/src/fork-only.ts' >> "$fixture/config/hypatia/upstream-typescript-allowlist.txt" ;; sarif) sed -i "/if: github.event_name != 'pull_request'/d" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + upstream-exception) sed -i 's@bash scripts/filter-hypatia-inherited-language.sh@bash scripts/no-op-filter.sh@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + effective-grid) sed -i '/- name: Render the verdict grid/,/- name: Upload the verdict grid/s/hypatia-findings.effective.json/hypatia-findings.relativized.json/g' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + jcs-manifest) sed -i '/- name: Read the pinned ijson-jcs identity/,/- name: Cache the pinned ijson-jcs binary/s@config/format-tools.toml@config/other-tools.toml@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + jcs-canonicalizer) sed -i '/- name: Apply upstream-inherited TypeScript exception/,/- name: Restore panic-attack build cache/s@"$HOME/.cargo/bin/ijson-jcs"@@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + raw-ijson) sed -i '/- name: Validate findings and count severities/,/- name: Relativize finding paths/s@"$HOME/.cargo/bin/ijson-jcs" canon < hypatia-findings.json > /dev/null@@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; k9-pin) sed -i 's/ref: c6d78606f3928c22c3b0bdc6f5f5b47b96e05a5d/ref: main/' "$fixture/.github/workflows/k9-conformance-reusable.yml" ;; k9-require) sed -i 's/require-files: true/require-files: false/g' "$fixture/.github/workflows/governance.yml" ;; k9-indeterminate) sed -i 's/fail-on-indeterminate: true/fail-on-indeterminate: false/' "$fixture/.github/workflows/governance.yml" ;; @@ -29,5 +39,8 @@ for mutation in sarif k9-pin k9-require k9-indeterminate k9-verdict scanner-advi cat "$fixture/log" >&2 exit 1 fi + if [[ "$mutation" == allowlist-addition ]]; then + grep -Fq 'ERROR: the complete allowlist must match the pinned upstream TypeScript snapshot' "$fixture/log" + fi echo "ok - rejected $mutation" done diff --git a/scripts/tests/test-hypatia-inherited-language-exception.sh b/scripts/tests/test-hypatia-inherited-language-exception.sh new file mode 100755 index 00000000..f556bd37 --- /dev/null +++ b/scripts/tests/test-hypatia-inherited-language-exception.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Tests the narrow upstream-inherited TypeScript exception for both Hypatia +# findings JSON and SARIF, including the negative cases that must remain visible. +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +FILTER="$ROOT_DIR/scripts/filter-hypatia-inherited-language.sh" +canonicalizer_arg=${IJSON_JCS:-} +if [[ -n "$canonicalizer_arg" && ! -x "$canonicalizer_arg" ]]; then + echo "IJSON_JCS is not executable: $canonicalizer_arg" >&2 + exit 1 +fi +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +cat > "$tmpdir/allowlist.txt" <<'EOF' +# Test snapshot +frontend/src/App.tsx +frontend/src/api/client.ts +EOF + +cat > "$tmpdir/raw-findings.json" <<'EOF' +[ + {"rule_module":"cicd_rules","type":"banned_language_file","file":"./frontend/src/App.tsx","severity":"critical"}, + {"rule_module":"cicd_rules","type":"banned_language_file","file":"frontend/src/api/client.ts","severity":"critical"}, + {"rule_module":"cicd_rules","type":"banned_language_file","file":"frontend/src/utils/text.test.ts","severity":"critical"}, + {"rule_module":"code_safety","type":"unsafe_call","file":"frontend/src/App.tsx","severity":"high"}, + {"rule_module":"cicd_rules","type":"banned_language_file","file":"scripts/new-tool.py","severity":"high"} +] +EOF + +cat > "$tmpdir/raw.sarif" <<'EOF' +{ + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [{ + "tool": {"driver": {"name": "Hypatia"}}, + "results": [ + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"./frontend/src/App.tsx"}}}]}, + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"frontend/src/api/client.ts"}}}]}, + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"frontend/src/utils/text.test.ts"}}}]}, + {"ruleId":"hypatia/code_safety/unsafe_call","locations":[{"physicalLocation":{"artifactLocation":{"uri":"frontend/src/App.tsx"}}}]}, + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"scripts/new-tool.py"}}}]} + ] + }] +} +EOF + +cp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" +cp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" +bash "$FILTER" \ + "$tmpdir/raw-findings.json" \ + "$tmpdir/raw.sarif" \ + "$tmpdir/allowlist.txt" \ + "$tmpdir/effective-findings.json" \ + "$tmpdir/effective.sarif" \ + "$canonicalizer_arg" +if [[ -n "${IJSON_JCS:-}" ]]; then + "$IJSON_JCS" check "$tmpdir/effective-findings.json" >/dev/null +fi + +# Only the two exact inherited TypeScript paths are removed from findings. +jq -e 'length == 3' "$tmpdir/effective-findings.json" >/dev/null +jq -e 'any(.[]; .file == "frontend/src/utils/text.test.ts" and .rule_module == "cicd_rules" and .type == "banned_language_file")' "$tmpdir/effective-findings.json" >/dev/null +jq -e 'any(.[]; .file == "frontend/src/App.tsx" and .rule_module == "code_safety")' "$tmpdir/effective-findings.json" >/dev/null +jq -e 'any(.[]; .file == "scripts/new-tool.py" and .type == "banned_language_file")' "$tmpdir/effective-findings.json" >/dev/null +jq -e 'all(.[]; .file != "./frontend/src/App.tsx" and .file != "frontend/src/api/client.ts" or .rule_module != "cicd_rules" or .type != "banned_language_file")' "$tmpdir/effective-findings.json" >/dev/null + +# The corresponding SARIF filter has the same narrow scope. +jq -e '.runs[0].results | length == 3' "$tmpdir/effective.sarif" >/dev/null +jq -e '[.runs[0].results[] | select(.ruleId == "hypatia/cicd_rules/banned_language_file" and .locations[0].physicalLocation.artifactLocation.uri == "frontend/src/utils/text.test.ts")] | length == 1' "$tmpdir/effective.sarif" >/dev/null +jq -e '[.runs[0].results[] | select(.ruleId == "hypatia/code_safety/unsafe_call")] | length == 1' "$tmpdir/effective.sarif" >/dev/null +jq -e '[.runs[0].results[] | select(.ruleId == "hypatia/cicd_rules/banned_language_file" and .locations[0].physicalLocation.artifactLocation.uri == "scripts/new-tool.py")] | length == 1' "$tmpdir/effective.sarif" >/dev/null + +# Exercise the committed upstream snapshot itself: App.tsx is inherited, while +# the fork-only test file is intentionally absent from the allowlist. +cat > "$tmpdir/repo-findings.json" <<'EOF' +[ + {"rule_module":"cicd_rules","type":"banned_language_file","file":"frontend/src/App.tsx","severity":"critical"}, + {"rule_module":"cicd_rules","type":"banned_language_file","file":"frontend/src/utils/text.test.ts","severity":"critical"} +] +EOF +cat > "$tmpdir/repo.sarif" <<'EOF' +{ + "version": "2.1.0", + "runs": [{"results": [ + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"frontend/src/App.tsx"}}}]}, + {"ruleId":"hypatia/cicd_rules/banned_language_file","locations":[{"physicalLocation":{"artifactLocation":{"uri":"frontend/src/utils/text.test.ts"}}}]} + ]}] +} +EOF +bash "$FILTER" \ + "$tmpdir/repo-findings.json" \ + "$tmpdir/repo.sarif" \ + "$ROOT_DIR/config/hypatia/upstream-typescript-allowlist.txt" \ + "$tmpdir/repo-effective-findings.json" \ + "$tmpdir/repo-effective.sarif" \ + "$canonicalizer_arg" +if [[ -n "${IJSON_JCS:-}" ]]; then + "$IJSON_JCS" check "$tmpdir/repo-effective-findings.json" >/dev/null +fi +jq -e 'length == 1 and .[0].file == "frontend/src/utils/text.test.ts"' "$tmpdir/repo-effective-findings.json" >/dev/null +jq -e '.runs[0].results | length == 1 and .[0].locations[0].physicalLocation.artifactLocation.uri == "frontend/src/utils/text.test.ts"' "$tmpdir/repo-effective.sarif" >/dev/null + +# The source artifacts are immutable audit evidence. +cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" +cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" + +# Every output/input pairing must reject aliases before writing either output. +mkdir -p "$tmpdir/child" +ln -s "$tmpdir" "$tmpdir/parent-alias" +for raw in raw-findings.json raw.sarif; do + ln -s "$tmpdir/$raw" "$tmpdir/symlink-$raw" + ln "$tmpdir/$raw" "$tmpdir/hardlink-$raw" + for alias in "$tmpdir/$raw" "$tmpdir/./$raw" "$tmpdir/child/../$raw" \ + "$tmpdir/parent-alias/$raw" "$tmpdir/symlink-$raw" "$tmpdir/hardlink-$raw"; do + for output_kind in findings sarif; do + printf 'untouched companion output\n' > "$tmpdir/companion" + cp "$tmpdir/companion" "$tmpdir/companion.original" + outputs=("$alias" "$tmpdir/companion") + if [[ "$output_kind" == sarif ]]; then + outputs=("$tmpdir/companion" "$alias") + fi + if bash "$FILTER" "$tmpdir/raw-findings.json" "$tmpdir/raw.sarif" \ + "$tmpdir/allowlist.txt" "${outputs[@]}" "$canonicalizer_arg" > "$tmpdir/collision.log" 2>&1; then + echo "ERROR: accepted $output_kind output colliding with $alias" >&2 + exit 1 + fi + grep -Fq 'Raw inputs and effective outputs must use distinct paths.' "$tmpdir/collision.log" + cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" + cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" + cmp "$tmpdir/companion" "$tmpdir/companion.original" + done + done +done + +# Canonicalize raw input aliases too, and reject aliased effective outputs. +for collision in raw-alias output-alias; do + inputs=("$tmpdir/raw-findings.json" "$tmpdir/raw.sarif") + outputs=("$tmpdir/companion" "$tmpdir/./companion") + if [[ "$collision" == raw-alias ]]; then + inputs=("$tmpdir/symlink-raw-findings.json" "$tmpdir/symlink-raw.sarif") + outputs=("$tmpdir/raw-findings.json" "$tmpdir/companion") + fi + if bash "$FILTER" "${inputs[@]}" "$tmpdir/allowlist.txt" \ + "${outputs[@]}" "$canonicalizer_arg" > "$tmpdir/collision.log" 2>&1; then + echo "ERROR: accepted $collision collision" >&2 + exit 1 + fi + grep -Fq 'Raw inputs and effective outputs must use distinct paths.' "$tmpdir/collision.log" + cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" + cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" + cmp "$tmpdir/companion" "$tmpdir/companion.original" +done + +# Missing output directories remain supported after canonicalization. +bash "$FILTER" "$tmpdir/raw-findings.json" "$tmpdir/raw.sarif" \ + "$tmpdir/allowlist.txt" "$tmpdir/new/findings/effective.json" \ + "$tmpdir/new/sarif/effective.sarif" "$canonicalizer_arg" +cmp "$tmpdir/effective-findings.json" "$tmpdir/new/findings/effective.json" +cmp "$tmpdir/effective.sarif" "$tmpdir/new/sarif/effective.sarif" +echo 'PASS: path collisions are rejected before writes; distinct new output paths remain supported.' + +if [[ -n "$canonicalizer_arg" ]]; then + echo "PASS: inherited TypeScript paths are narrowly filtered; raw, fork-only, and unrelated findings remain; effective JSON is I-JSON/JCS checked." +else + echo "PASS: inherited TypeScript paths are narrowly filtered; raw, fork-only, and unrelated findings remain (JCS check skipped: no ijson-jcs CLI supplied)." +fi