From 211e67ec36b4e9ca83b83a3c76c0cdcb7d9929cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:24:51 +0000 Subject: [PATCH 1/3] fix(loop): rebuild-semantics pattern registry + estate-rescan workflow + FP classifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security reports the whole pipeline reasons over were stale and inflated, so nothing ever resolved and the fleet dispatched fixes for findings that no longer existed. Three coordinated fixes: 1. PatternRegistry.sync_from_scans/1 rebuilds per-repo state instead of appending: per-repo repo_occurrences as source of truth, suppressed weak_points rejected at intake (panic-attack consumer contract), vanished patterns marked trend=resolved with empty repo lists (so TriangleRouter dispatches nothing for them), heuristic UnboundedAllocation capped at Medium (one keyword matcher was ~70% of estate Criticals). Legacy entries migrate; inflated counts reset on first rebuild. New active_patterns/0 helper + 6 new tests. 2. estate-rescan.yml: daily + on-demand central rescan of all public org repos with current panic-attack, pushing refreshed scans and a regenerated index to verisimdb-data via HYPATIA_DISPATCH_PAT. Replaces the manual-local scan flow that left scans/ frozen at 2026-03 (with phantom Criticals for files deleted months ago) and never covered vcl-ut/bag-of-actions. repository_dispatch type estate-rescan lets gitbot-fleet flush fixed repos immediately. 3. audits/assail-classifications.a2ml: audited-FP registry for hypatia's own scan (soundness fixtures, remediation scripts, env-default binding, doc/test credentials, documented FFI unsafe). Plus: triage_issues parse_classes no longer atomizes CLI input (PA013) — resolves against the known-class table. Verified locally with panic-attack 2.5.5: hypatia unsuppressed non-heuristic Critical/High count is now 0 (was 19 Critical / 5 High in the stale store). --- .github/workflows/estate-rescan.yml | 245 ++++++++++++++++++++++ audits/assail-classifications.a2ml | 101 +++++++++ lib/mix/tasks/hypatia.triage_issues.ex | 23 ++- lib/pattern_registry.ex | 274 +++++++++++++++++++------ test/pattern_registry_test.exs | 184 ++++++++++++++++- 5 files changed, 753 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/estate-rescan.yml create mode 100644 audits/assail-classifications.a2ml diff --git a/.github/workflows/estate-rescan.yml b/.github/workflows/estate-rescan.yml new file mode 100644 index 00000000..aacf82f0 --- /dev/null +++ b/.github/workflows/estate-rescan.yml @@ -0,0 +1,245 @@ +# SPDX-License-Identifier: MPL-2.0 +# Hypatia Estate Rescan — refresh verisimdb-data/scans with current truth +# +# The canonical security reports (verisimdb-data/scans/*.json) were +# produced by manual local panic-attack runs and went stale: by 2026-06 +# the majority of recorded Critical/High findings referenced files that +# no longer existed, or content that had already been fixed, while two +# active repos (vcl-ut, bag-of-actions) had no scan at all. Hypatia kept +# reasoning over that snapshot and the fleet kept dispatching fixes for +# phantom findings. +# +# This workflow is the missing freshness link: it rebuilds the scan +# store centrally with the CURRENT panic-attack (which carries the +# kanren/context/classification FP-suppression absent from the old +# data), so the analyzer's input reflects reality on a daily cadence — +# and on demand the moment a fix lands. +# +# Triggers: +# - schedule: daily full sweep (staggered off remediation-sweep 03:17 +# and the .git-private-farm Monday maintenance run). +# - workflow_dispatch: manual, optionally targeted via `repos`. +# - repository_dispatch (type: estate-rescan): targeted refresh, fired +# by gitbot-fleet's dispatch-runner after it lands fixes, so resolved +# findings flush from the store without waiting for the nightly run. +# +# Scope guard: only PUBLIC, non-archived, non-fork repos are scanned — +# verisimdb-data is public, so private-repo findings must never be +# written into it. Repos named 007* are excluded per the owner's +# licence/scanning policy (out of scope for any scanning). + +name: Hypatia Estate Rescan + +on: + schedule: + - cron: '47 4 * * *' + workflow_dispatch: + inputs: + repos: + description: 'Space-separated repo names to rescan (default: all public org repos)' + required: false + default: '' + type: string + dry_run: + description: 'Scan and report, but do not push to verisimdb-data' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + repository_dispatch: + types: [estate-rescan] + +permissions: + contents: read + +# Serialize: concurrent sweeps would race on the verisimdb-data push. +# cancel-in-progress: false so a targeted post-fix rescan request queues +# behind a running nightly sweep instead of killing it. +concurrency: + group: estate-rescan + cancel-in-progress: false + +jobs: + rescan: + name: Rescan estate with panic-attack + runs-on: ubuntu-latest + timeout-minutes: 300 + + steps: + - name: Resolve panic-attack version + id: pa_ref + run: echo "sha=$(git ls-remote https://github.com/hyperpolymath/panic-attack.git HEAD | cut -f1)" >> "$GITHUB_OUTPUT" + + - name: Cache built panic-attack + id: pa_cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/panic-attack-bin + # Exact-SHA key, no restore-keys: a partial hit would scan with + # stale rules (same reasoning as the hypatia-scan build cache). + key: panic-attack-${{ runner.os }}-${{ steps.pa_ref.outputs.sha }} + + - name: Build panic-attack (if needed) + if: steps.pa_cache.outputs.cache-hit != 'true' + env: + PA_SHA: ${{ steps.pa_ref.outputs.sha }} + run: | + set -euo pipefail + git clone --depth 1 https://github.com/hyperpolymath/panic-attack.git "$RUNNER_TEMP/panic-attack" + cd "$RUNNER_TEMP/panic-attack" + git fetch --depth 1 origin "$PA_SHA" && git checkout "$PA_SHA" + cargo build --release + mkdir -p "$HOME/panic-attack-bin" + cp target/release/panic-attack "$HOME/panic-attack-bin/" + + - name: Resolve repo list + id: repos + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + INPUT_REPOS: ${{ inputs.repos }} + PAYLOAD_REPOS: ${{ github.event.client_payload.repos }} + run: | + set -euo pipefail + REQUESTED="${INPUT_REPOS:-${PAYLOAD_REPOS:-}}" + if [ -n "$REQUESTED" ]; then + # Targeted rescan: validate each requested name against the + # public-repo constraint rather than trusting the payload. + printf '%s' "$REQUESTED" | tr ' ,' '\n\n' | sed '/^$/d' | sort -u > /tmp/requested.txt + : > /tmp/repo-list.txt + while IFS= read -r name; do + # Names come from a repository_dispatch payload — enforce a + # strict charset before using them anywhere. + if ! printf '%s' "$name" | grep -Eq '^[A-Za-z0-9._-]+$'; then + echo "::warning::Skipping invalid repo name in payload: $name" + continue + fi + VISIBILITY=$(gh api "repos/hyperpolymath/$name" --jq '[.private, .archived, .fork] | @tsv' 2>/dev/null || echo "missing") + if [ "$VISIBILITY" = "$(printf 'false\tfalse\tfalse')" ]; then + echo "$name" >> /tmp/repo-list.txt + else + echo "::warning::Skipping $name (private/archived/fork/missing — not eligible for the public scan store)" + fi + done < /tmp/requested.txt + else + gh api "users/hyperpolymath/repos?type=public&per_page=100" --paginate \ + --jq '.[] | select(.archived == false and .fork == false and .disabled == false) | .name' \ + | grep -Ev '^007' \ + | grep -Ev '^(verisimdb-data|hyperpolymath-archive)' \ + | sort -u > /tmp/repo-list.txt + fi + COUNT=$(wc -l < /tmp/repo-list.txt) + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Rescanning $COUNT repos" + + - name: Checkout verisimdb-data + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + run: | + set -euo pipefail + git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/verisimdb-data.git" "$RUNNER_TEMP/verisimdb-data" + + - name: Scan repos + id: scan + run: | + set -uo pipefail + PA="$HOME/panic-attack-bin/panic-attack" + DATA="$RUNNER_TEMP/verisimdb-data" + FAILED=0 + SCANNED=0 + while IFS= read -r name; do + workdir="$RUNNER_TEMP/work/$name" + rm -rf "$workdir" + if ! git clone --depth 1 --quiet "https://github.com/hyperpolymath/$name.git" "$workdir" 2>/dev/null; then + echo "::warning::clone failed: $name" + FAILED=$((FAILED+1)) + continue + fi + if "$PA" assail "$workdir" --output "$RUNNER_TEMP/scan-raw.json" >/dev/null 2>&1; then + # Normalize program_path to the repo name: runner paths are + # meaningless to consumers and leaked local paths were one of + # the defects of the manual-scan era (`/var$REPOS_DIR/...`). + jq --arg name "$name" '.program_path = $name' \ + "$RUNNER_TEMP/scan-raw.json" > "$DATA/scans/$name.json" + SCANNED=$((SCANNED+1)) + else + echo "::warning::scan failed: $name" + FAILED=$((FAILED+1)) + fi + rm -rf "$workdir" + done < /tmp/repo-list.txt + echo "scanned=$SCANNED" >> "$GITHUB_OUTPUT" + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "Scanned $SCANNED repos ($FAILED failures)" + + - name: Regenerate index.json + run: | + set -euo pipefail + DATA="$RUNNER_TEMP/verisimdb-data" + NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) + # Preserve human/LLM-authored summaries from the previous index; + # recompute the live counts from the scan files themselves. + cp "$DATA/index.json" /tmp/old-index.json 2>/dev/null || echo '{}' > /tmp/old-index.json + REPOS_JSON='{}' + for f in "$DATA"/scans/*.json; do + name=$(basename "$f" .json) + ENTRY=$(jq --arg now "$NOW" --slurpfile old /tmp/old-index.json --arg name "$name" ' + { + last_scan: $now, + weak_points: ([.weak_points[]? | select((.suppressed // false) | not)] | length), + suppressed: ([.weak_points[]? | select(.suppressed // false)] | length), + critical: ([.weak_points[]? | select(((.suppressed // false) | not) and .severity == "Critical")] | length), + high: ([.weak_points[]? | select(((.suppressed // false) | not) and .severity == "High")] | length), + summary: ($old[0].repos[$name].summary // null) + }' "$f") + REPOS_JSON=$(jq --arg name "$name" --argjson entry "$ENTRY" '. + {($name): $entry}' <<<"$REPOS_JSON") + done + jq -n --arg now "$NOW" --argjson repos "$REPOS_JSON" \ + '{repos: $repos, total_scans: ($repos | length), last_updated: $now}' \ + > "$DATA/index.json" + jq -r '"index.json: \(.total_scans) repos, last_updated \(.last_updated)"' "$DATA/index.json" + + - name: Push to verisimdb-data + if: inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + SCANNED: ${{ steps.scan.outputs.scanned }} + run: | + set -euo pipefail + DATA="$RUNNER_TEMP/verisimdb-data" + cd "$DATA" + git config user.name "hypatia-estate-rescan" + git config user.email "hypatia-bot@users.noreply.github.com" + git add scans/ index.json + if git diff --cached --quiet; then + echo "No scan changes to push." + exit 0 + fi + git commit -m "scan: estate rescan ($SCANNED repos, panic-attack refresh)" + # Retry with rebase: another writer (ingest.yml, manual push) may + # land between our clone and push. + for delay in 2 4 8 16; do + if git push origin HEAD:main; then + exit 0 + fi + echo "push failed; rebasing and retrying in ${delay}s" + sleep "$delay" + git pull --rebase origin main || true + done + git push origin HEAD:main + + - name: Job summary + env: + COUNT: ${{ steps.repos.outputs.count }} + SCANNED: ${{ steps.scan.outputs.scanned }} + FAILED: ${{ steps.scan.outputs.failed }} + DRY: ${{ inputs.dry_run }} + run: | + { + echo "## Estate Rescan" + echo "- Repos targeted: $COUNT" + echo "- Scanned OK: $SCANNED" + echo "- Failures: $FAILED" + echo "- Dry run: ${DRY:-false}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/audits/assail-classifications.a2ml b/audits/assail-classifications.a2ml new file mode 100644 index 00000000..a037f083 --- /dev/null +++ b/audits/assail-classifications.a2ml @@ -0,0 +1,101 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; assail-classifications.a2ml — audited panic-attack findings for hypatia. +;; +;; Read by panic-attack >= 2.5.5 (load_user_classifications): findings +;; matching (file, category) are flipped to suppressed = true AFTER the +;; structural kanren suppression pass. Entries here are AUDITED-SOUND +;; residuals, not exemptions of convenience — every entry must cite its +;; rationale, and the registry lives apart from the source so a code +;; edit cannot self-suppress (see panic-attack CLAUDE.md, User- +;; Classification Registry). +;; +;; Audit trail: 2026-06-11 estate-loop session — fresh panic-attack +;; 2.5.5 scan of hypatia; every entry below was inspected at source. + +(assail-classifications + ;; ── Soundness-gate negative fixtures ───────────────────────────── + ;; test/soundness/fixtures/code_safety/ exists to PROVE the scanner + ;; catches these constructs; the findings are the fixtures working + ;; as designed. Mirrors lib/hypatia/scanner_suppression.ex, which + ;; already exempts these paths for hypatia's own scanner. + (classification + (file "test/soundness/fixtures/code_safety/transmute.rs") + (category "UnsafeCode") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects mem::transmute")) + (classification + (file "test/soundness/fixtures/code_safety/admitted.v") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Admitted")) + (classification + (file "test/soundness/fixtures/code_safety/believe_me.idr") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects believe_me")) + (classification + (file "test/soundness/fixtures/code_safety/sorry.lean") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects sorry")) + (classification + (file "test/soundness/fixtures/code_safety/obj_magic_ocaml.ml") + (category "UnsafeTypeCoercion") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Obj.magic")) + (classification + (file "test/soundness/fixtures/code_safety/unsafe_coerce.hs") + (category "UnsafeTypeCoercion") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects unsafeCoerce")) + (classification + (file "test/soundness/fixtures/code_safety/elixir_code_eval.ex") + (category "DynamicCodeExecution") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Code.eval_string")) + (classification + (file "test/soundness/fixtures/code_safety/zig_ptr_cast.zig") + (category "UnsafeCode") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Zig pointer casts")) + + ;; ── Remediation scripts: pattern strings, not vulnerabilities ──── + (classification + (file "scripts/fix-scripts/fix-deno-permissions.sh") + (category "ExcessivePermissions") + (audit "remediation tool — replaces `deno -A` with scoped permission flags; scripts/fix-scripts/ is already exempt in scanner_suppression.ex") + (rationale "contains -A as the SEARCH pattern it removes from other repos")) + + ;; ── FFI boundary unsafe: documented, load-bearing ──────────────── + (classification + (file "clients/rust/hypatia-client/src/ffi.rs") + (category "UnsafeCode") + (audit "in-file # Safety section (ffi.rs:7-30) enumerates the invariant for every unsafe block; carries hypatia allow pragmas") + (rationale "libloading C-ABI boundary calls against libhypatia_ffi.so; each block's invariant documented at source")) + + ;; ── Test harnesses ─────────────────────────────────────────────── + (classification + (file "tests/e2e.sh") + (category "CommandInjection") + (audit "tests/e2e.sh:88-93 — heredoc writes a deliberately insecure fixture") + (rationale "the eval is fixture CONTENT generated for the scanner-detection test, not executed by the harness")) + (classification + (file "integration/run-tests.sh") + (category "HardcodedSecret") + (audit "integration/run-tests.sh:268 — ARANGODB_PASSWORD=testpassword") + (rationale "throwaway credential for the ephemeral integration-test database container")) + + ;; ── Env-var binding misread as a secret ────────────────────────── + (classification + (file "hooks/lib/cache.sh") + (category "HardcodedSecret") + (audit "hooks/lib/cache.sh:17-22 — CICD_CACHE_PASSWORD=\"${CICD_CACHE_PASSWORD:-}\"") + (rationale "empty-default env-var binding, documented in-file; no literal secret present")) + + ;; ── Rule definitions detecting themselves ──────────────────────── + (classification + (file "lib/hypatia/scanner_suppression.ex") + (category "HardcodedSecret") + (audit "scanner suppression rule definitions contain secret-shaped pattern strings") + (rationale "detector/suppression pattern table, not credentials — same class as the scripts/fix-* carve-out in scanner_suppression.ex")) +) diff --git a/lib/mix/tasks/hypatia.triage_issues.ex b/lib/mix/tasks/hypatia.triage_issues.ex index 05d4b6aa..6e03c8ac 100644 --- a/lib/mix/tasks/hypatia.triage_issues.ex +++ b/lib/mix/tasks/hypatia.triage_issues.ex @@ -104,7 +104,10 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do labels = opts - |> Keyword.get(:labels, "hypatia,hypatia-security-alert,hypatia-advisory,automated-by-hypatia") + |> Keyword.get( + :labels, + "hypatia,hypatia-security-alert,hypatia-advisory,automated-by-hypatia" + ) |> String.split(",", trim: true) classes = parse_classes(Keyword.get(opts, :classes)) @@ -136,10 +139,18 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do defp parse_classes(nil), do: @close_classes defp parse_classes(csv) do + # Resolve against the known-class table instead of atomizing CLI + # input: dynamic atom creation from arbitrary input leaks atoms (PA013). + allowed = Map.new(@close_classes, fn c -> {Atom.to_string(c), c} end) + csv |> String.split(",", trim: true) - |> Enum.map(&String.to_atom/1) - |> Enum.filter(&(&1 in @close_classes)) + |> Enum.flat_map(fn s -> + case Map.fetch(allowed, s) do + {:ok, atom} -> [atom] + :error -> [] + end + end) end # ─── Discovery ───────────────────────────────────────────────────────── @@ -315,7 +326,8 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do Enum.map(candidates, fn %{issue: i, class: c, reason: r} -> %{ "number" => i["number"], - "repo" => i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last(), + "repo" => + i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last(), "title" => i["title"], "url" => i["html_url"], "class" => Atom.to_string(c), @@ -353,8 +365,7 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do {closed, failed} = Enum.reduce(candidates, {0, 0}, fn %{issue: i, reason: r}, {ok, bad} -> - repo = - i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last() + repo = i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last() case close_issue(repo, i["number"], r, token) do :ok -> {ok + 1, bad} diff --git a/lib/pattern_registry.ex b/lib/pattern_registry.ex index d92020f1..391d186f 100644 --- a/lib/pattern_registry.ex +++ b/lib/pattern_registry.ex @@ -7,6 +7,41 @@ defmodule Hypatia.PatternRegistry do Reads and writes to verisim-data/patterns/registry.json. Groups scan weak_points by (category, description_fingerprint), assigns pattern IDs, tracks occurrence counts, and detects trends. + + ## Sync semantics (rebuild, not append) + + `sync_from_scans/1` REBUILDS the per-repo state for every repo present + in the scan batch, instead of appending to it. The previous append-only + behaviour had two systemic defects (observed live on the 2026-06 + estate data): + + 1. **Counts inflated multiplicatively** — every sync re-added the + same weak_points, so `occurrences` grew with each pipeline run + rather than tracking reality. + 2. **Nothing ever resolved** — repos stayed in `repos_affected_list` + after going clean, so the fleet kept dispatching fixes for + findings (and files) that no longer existed. + + Now each pattern stores `repo_occurrences` (a `repo => count` map) as + the source of truth. A sync replaces the entries for scanned repos and + leaves unscanned repos' entries untouched, so partial syncs stay + correct. A pattern whose `repo_occurrences` empties out is kept for + history with `occurrences: 0` and `trend: "resolved"` — downstream + routing iterates `repos_affected_list`, so resolved patterns dispatch + nothing. + + ## Input hygiene + + Suppressed weak_points (`"suppressed": true` — panic-attack's + kanren/context/classification suppression verdict) are rejected at + intake. panic-attack's contract is explicit: consumers must filter on + `suppressed: false`; the suppressed entries remain in the scan JSON + only for auditability. + + Heuristic categories are severity-capped at intake (see + `@heuristic_severity_caps`): a "potential pattern detected" guess must + not enter the registry as Critical. Mirrors the precedent of + `license_finding_severity_cap/1` in `lib/rules/cicd_rules.ex`. """ require Logger @@ -34,6 +69,11 @@ defmodule Hypatia.PatternRegistry do "UncheckedError" => "PA018", "InfiniteRecursion" => "PA019", "UnsafeTypeCoercion" => "PA020", + "ProofDrift" => "PA021", + "CryptoMisuse" => "PA022", + "SupplyChain" => "PA023", + "InputBoundary" => "PA024", + "MutationGap" => "PA025", # UX patterns -- from cross-platform container test harness "HardcodedAbsolutePath" => "UX001", "MissingQuickstart" => "UX002", @@ -47,85 +87,49 @@ defmodule Hypatia.PatternRegistry do "MissingAdjustContractile" => "UX010" } + # Heuristic detectors whose findings are pattern guesses, not confirmed + # vulnerabilities. panic-attack 2.5.5 emits UnboundedAllocation + # ("Potential unbounded allocation pattern detected") at Critical, which + # made one keyword matcher ~70% of all estate Criticals. Cap such + # categories so prioritisation reflects confirmation strength. The cap + # only ever lowers severity, never raises it. + @heuristic_severity_caps %{"UnboundedAllocation" => "Medium"} + + @severity_rank %{"Critical" => 4, "High" => 3, "Medium" => 2, "Low" => 1, "Info" => 0} + @doc """ Build/update the pattern registry from scan data. Takes a list of scan results (from VerisimConnector.fetch_all_scans/0) - and groups weak_points into canonical patterns tracked in registry.json. + and rebuilds the per-repo pattern state for every repo in the batch. + See the moduledoc for the full sync semantics. """ def sync_from_scans(scans) do registry = load_registry() substitutions = load_substitutions() now = DateTime.utc_now() |> DateTime.to_iso8601() + scanned_repos = MapSet.new(scans, & &1.repo) + fresh = aggregate_fresh(scans, substitutions) + previous = Map.get(registry, "patterns", %{}) + + all_ids = MapSet.union(MapSet.new(Map.keys(previous)), MapSet.new(Map.keys(fresh))) + + patterns = + for id <- all_ids, into: %{} do + {id, merge_pattern(id, Map.get(previous, id), Map.get(fresh, id), scanned_repos, now)} + end + updated_registry = - Enum.reduce(scans, registry, fn scan, reg -> - weak_points = Map.get(scan.scan, "weak_points", []) - - program_path = Map.get(scan.scan, "program_path", "") - - Enum.reduce(weak_points, reg, fn wp, acc -> - category = Map.get(wp, "category", "unknown") - description = Map.get(wp, "description", category) - severity = Map.get(wp, "severity", "Medium") - repo = scan.repo - - pa_rule = Map.get(@pa_rule_map, category, "PA000") - slug = fingerprint(description) - pattern_id = "#{pa_rule}-#{slug}" - - tier = get_triangle_tier(category, substitutions) - - patterns = Map.get(acc, "patterns", %{}) - - updated_pattern = - case Map.get(patterns, pattern_id) do - nil -> - %{ - "id" => pattern_id, - "category" => category, - "severity" => severity, - "description" => description, - "pa_rule" => pa_rule, - "occurrences" => 1, - "repos_affected" => 1, - "repos_affected_list" => [repo], - "repo_paths" => %{repo => program_path}, - "first_seen" => now, - "last_seen" => now, - "trend" => "new", - "triangle_tier" => tier, - "recipe_id" => nil - } - - existing -> - repos_list = Map.get(existing, "repos_affected_list", []) - repo_paths = Map.get(existing, "repo_paths", %{}) - - {repos_list, repos_count} = - if repo in repos_list do - {repos_list, Map.get(existing, "repos_affected", 1)} - else - {repos_list ++ [repo], Map.get(existing, "repos_affected", 1) + 1} - end - - existing - |> Map.put("occurrences", Map.get(existing, "occurrences", 0) + 1) - |> Map.put("repos_affected", repos_count) - |> Map.put("repos_affected_list", repos_list) - |> Map.put("repo_paths", Map.put(repo_paths, repo, program_path)) - |> Map.put("last_seen", now) - end - - put_in(acc, ["patterns", pattern_id], updated_pattern) - end) - end) + registry + |> Map.put("patterns", patterns) + |> Map.put("last_updated", now) - updated_registry = Map.put(updated_registry, "last_updated", now) save_registry(updated_registry) - pattern_count = map_size(Map.get(updated_registry, "patterns", %{})) - Logger.info("Pattern registry synced: #{pattern_count} patterns tracked") + active = Enum.count(Map.values(patterns), &(Map.get(&1, "occurrences", 0) > 0)) + resolved = map_size(patterns) - active + Logger.info("Pattern registry synced: #{active} active, #{resolved} resolved patterns") {:ok, updated_registry} end @@ -144,13 +148,19 @@ defmodule Hypatia.PatternRegistry do |> Map.values() end + @doc "Get only patterns with at least one live occurrence." + def active_patterns do + all_patterns() + |> Enum.filter(fn p -> Map.get(p, "occurrences", 0) > 0 end) + end + @doc "Filter patterns by category (PA rule number)." def patterns_by_category(category) do all_patterns() |> Enum.filter(fn p -> Map.get(p, "category") == category end) end - @doc "Get patterns by trend direction: :increasing, :decreasing, :stable, :new" + @doc "Get patterns by trend direction: :increasing, :decreasing, :stable, :new, :resolved" def trending_patterns(direction) do direction_str = Atom.to_string(direction) @@ -168,6 +178,138 @@ defmodule Hypatia.PatternRegistry do # --- Private --- + # Aggregate the current scan batch into + # pattern_id => %{meta, repo_occurrences, repo_paths}. + defp aggregate_fresh(scans, substitutions) do + Enum.reduce(scans, %{}, fn scan, acc -> + weak_points = + scan.scan + |> Map.get("weak_points", []) + |> Enum.reject(&(Map.get(&1, "suppressed", false) == true)) + + program_path = Map.get(scan.scan, "program_path", "") + repo = scan.repo + + Enum.reduce(weak_points, acc, fn wp, inner -> + category = Map.get(wp, "category", "unknown") + description = Map.get(wp, "description", category) + severity = cap_severity(category, Map.get(wp, "severity", "Medium")) + + pa_rule = Map.get(@pa_rule_map, category, "PA000") + pattern_id = "#{pa_rule}-#{fingerprint(description)}" + + entry = + Map.get(inner, pattern_id, %{ + "category" => category, + "severity" => severity, + "description" => description, + "pa_rule" => pa_rule, + "triangle_tier" => get_triangle_tier(category, substitutions), + "repo_occurrences" => %{}, + "repo_paths" => %{} + }) + + entry = + entry + |> update_in(["repo_occurrences", repo], &((&1 || 0) + 1)) + |> put_in(["repo_paths", repo], program_path) + + Map.put(inner, pattern_id, entry) + end) + end) + end + + # Merge one pattern's previous state with its fresh aggregation. + # Repos in `scanned_repos` are authoritative from `fresh`; repos not + # scanned this batch carry over from `previous`. + defp merge_pattern(id, previous, fresh, scanned_repos, now) do + prev_ro = previous_repo_occurrences(previous) + carried_ro = Map.reject(prev_ro, fn {repo, _} -> MapSet.member?(scanned_repos, repo) end) + + # Nothing about this pattern was rescanned -> leave it untouched. + if fresh == nil and carried_ro == prev_ro do + previous + else + fresh_ro = if fresh, do: Map.get(fresh, "repo_occurrences", %{}), else: %{} + merged_ro = Map.merge(carried_ro, fresh_ro) + total = merged_ro |> Map.values() |> Enum.sum() + prev_total = if previous, do: Map.get(previous, "occurrences", 0), else: 0 + + prev_paths = if previous, do: Map.get(previous, "repo_paths", %{}), else: %{} + fresh_paths = if fresh, do: Map.get(fresh, "repo_paths", %{}), else: %{} + + base = previous || %{} + meta = fresh || %{} + + base + |> Map.merge( + Map.take(meta, ["category", "severity", "description", "pa_rule", "triangle_tier"]) + ) + |> Map.put("id", id) + |> Map.put_new("recipe_id", nil) + |> Map.put_new("first_seen", now) + |> Map.put("repo_occurrences", merged_ro) + |> Map.put( + "repo_paths", + Map.merge(prev_paths, fresh_paths) |> Map.take(Map.keys(merged_ro)) + ) + |> Map.put("occurrences", total) + |> Map.put("repos_affected", map_size(merged_ro)) + |> Map.put("repos_affected_list", merged_ro |> Map.keys() |> Enum.sort()) + |> Map.put("trend", trend(previous, prev_total, total)) + |> then(fn p -> + if total > 0 do + p |> Map.put("last_seen", now) |> Map.delete("resolved_at") + else + Map.put_new(p, "resolved_at", now) + end + end) + end + end + + # The previous schema had no `repo_occurrences`; synthesize one + # occurrence per affected repo. This deliberately resets the inflated + # legacy totals on first rebuild. + defp previous_repo_occurrences(nil), do: %{} + + defp previous_repo_occurrences(previous) do + case Map.get(previous, "repo_occurrences") do + ro when is_map(ro) and map_size(ro) > 0 -> + ro + + _ -> + previous + |> Map.get("repos_affected_list", []) + |> Map.new(&{&1, 1}) + end + end + + defp trend(nil, _prev_total, _total), do: "new" + defp trend(_previous, _prev_total, 0), do: "resolved" + + defp trend(_previous, prev_total, total) do + cond do + prev_total == 0 -> "increasing" + total > prev_total -> "increasing" + total < prev_total -> "decreasing" + true -> "stable" + end + end + + defp cap_severity(category, severity) do + case Map.get(@heuristic_severity_caps, category) do + nil -> + severity + + cap -> + if Map.get(@severity_rank, severity, 0) > Map.get(@severity_rank, cap, 0) do + cap + else + severity + end + end + end + defp registry_path do Path.join(Path.expand(@verisimdb_data_path), "patterns/registry.json") end diff --git a/test/pattern_registry_test.exs b/test/pattern_registry_test.exs index 5599eefc..3a059bdd 100644 --- a/test/pattern_registry_test.exs +++ b/test/pattern_registry_test.exs @@ -87,8 +87,7 @@ defmodule Hypatia.PatternRegistryTest do patterns = Map.get(registry, "patterns", %{}) |> Map.values() # Find the pattern with our unique description - matching = - Enum.filter(patterns, fn p -> Map.get(p, "description") == unique_desc end) + matching = Enum.filter(patterns, fn p -> Map.get(p, "description") == unique_desc end) assert length(matching) == 1 pattern = hd(matching) @@ -108,4 +107,185 @@ defmodule Hypatia.PatternRegistryTest do assert is_list(patterns) end end + + describe "rebuild semantics" do + test "rejects suppressed weak_points at intake" do + unique_desc = "test-suppressed-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: "suppress-repo", + scan: %{ + "weak_points" => [ + %{ + "category" => "HardcodedSecret", + "description" => unique_desc, + "severity" => "Critical", + "suppressed" => true + } + ] + } + } + ] + + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + patterns = Map.get(registry, "patterns", %{}) |> Map.values() + + assert Enum.filter(patterns, &(Map.get(&1, "description") == unique_desc)) == [] + end + + test "caps heuristic UnboundedAllocation severity at Medium" do + unique_desc = "test-heuristic-cap-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: "cap-repo-#{System.unique_integer([:positive])}", + scan: %{ + "weak_points" => [ + %{ + "category" => "UnboundedAllocation", + "description" => unique_desc, + "severity" => "Critical" + } + ] + } + } + ] + + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["severity"] == "Medium" + end + + test "identical re-sync does not inflate occurrence counts" do + unique_desc = "test-noinflate-#{System.unique_integer([:positive])}" + repo = "noinflate-repo-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: repo, + scan: %{ + "weak_points" => [ + %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"}, + %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + ] + } + } + ] + + {:ok, _} = PatternRegistry.sync_from_scans(scans) + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 2 + assert pattern["trend"] == "stable" + assert pattern["repo_occurrences"] == %{repo => 2} + end + + test "repo going clean resolves its patterns but carries unscanned repos" do + unique_desc = "test-resolve-#{System.unique_integer([:positive])}" + repo_a = "resolve-a-#{System.unique_integer([:positive])}" + repo_b = "resolve-b-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, _} = + PatternRegistry.sync_from_scans([ + %{repo: repo_a, scan: %{"weak_points" => [wp]}}, + %{repo: repo_b, scan: %{"weak_points" => [wp]}} + ]) + + # repo_a rescanned clean; repo_b NOT in this batch -> must carry over + {:ok, registry} = + PatternRegistry.sync_from_scans([%{repo: repo_a, scan: %{"weak_points" => []}}]) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 1 + assert pattern["repos_affected_list"] == [repo_b] + assert pattern["trend"] == "decreasing" + + # repo_b rescanned clean too -> fully resolved, no repos to dispatch to + {:ok, registry} = + PatternRegistry.sync_from_scans([%{repo: repo_b, scan: %{"weak_points" => []}}]) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 0 + assert pattern["repos_affected_list"] == [] + assert pattern["trend"] == "resolved" + assert pattern["resolved_at"] != nil + end + + test "legacy entries without repo_occurrences migrate and preserve identity" do + # Simulate a legacy append-era entry by syncing, then stripping + # repo_occurrences from the persisted registry is not possible via the + # public API; instead verify that a fresh finding for the same pattern + # in a NEW repo merges without disturbing first_seen. + unique_desc = "test-legacy-#{System.unique_integer([:positive])}" + repo_a = "legacy-a-#{System.unique_integer([:positive])}" + repo_b = "legacy-b-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, reg1} = + PatternRegistry.sync_from_scans([%{repo: repo_a, scan: %{"weak_points" => [wp]}}]) + + first_seen = + reg1 + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + |> Map.get("first_seen") + + {:ok, reg2} = + PatternRegistry.sync_from_scans([%{repo: repo_b, scan: %{"weak_points" => [wp]}}]) + + pattern = + reg2 + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["first_seen"] == first_seen + assert Enum.sort(pattern["repos_affected_list"]) == Enum.sort([repo_a, repo_b]) + assert pattern["occurrences"] == 2 + end + end + + describe "active_patterns/0" do + test "excludes resolved patterns" do + unique_desc = "test-active-#{System.unique_integer([:positive])}" + repo = "active-repo-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, _} = PatternRegistry.sync_from_scans([%{repo: repo, scan: %{"weak_points" => [wp]}}]) + {:ok, _} = PatternRegistry.sync_from_scans([%{repo: repo, scan: %{"weak_points" => []}}]) + + refute Enum.any?( + PatternRegistry.active_patterns(), + &(Map.get(&1, "description") == unique_desc) + ) + end + end end From 4f4ef3323467a31d84b547e15d82e46fa3c63bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:24:51 +0000 Subject: [PATCH 2/3] fix(loop): rebuild-semantics pattern registry + estate-rescan workflow + FP classifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security reports the whole pipeline reasons over were stale and inflated, so nothing ever resolved and the fleet dispatched fixes for findings that no longer existed. Three coordinated fixes: 1. PatternRegistry.sync_from_scans/1 rebuilds per-repo state instead of appending: per-repo repo_occurrences as source of truth, suppressed weak_points rejected at intake (panic-attack consumer contract), vanished patterns marked trend=resolved with empty repo lists (so TriangleRouter dispatches nothing for them), heuristic UnboundedAllocation capped at Medium (one keyword matcher was ~70% of estate Criticals). Legacy entries migrate; inflated counts reset on first rebuild. New active_patterns/0 helper + 6 new tests. 2. estate-rescan.yml: daily + on-demand central rescan of all public org repos with current panic-attack, pushing refreshed scans and a regenerated index to verisimdb-data via HYPATIA_DISPATCH_PAT. Replaces the manual-local scan flow that left scans/ frozen at 2026-03 (with phantom Criticals for files deleted months ago) and never covered vcl-ut/bag-of-actions. repository_dispatch type estate-rescan lets gitbot-fleet flush fixed repos immediately. 3. audits/assail-classifications.a2ml: audited-FP registry for hypatia's own scan (soundness fixtures, remediation scripts, env-default binding, doc/test credentials, documented FFI unsafe). Plus: triage_issues parse_classes no longer atomizes CLI input (PA013) — resolves against the known-class table. Verified locally with panic-attack 2.5.5: hypatia unsuppressed non-heuristic Critical/High count is now 0 (was 19 Critical / 5 High in the stale store). --- .github/workflows/estate-rescan.yml | 245 ++++++++++++++++++++++ audits/assail-classifications.a2ml | 101 +++++++++ lib/mix/tasks/hypatia.triage_issues.ex | 23 ++- lib/pattern_registry.ex | 274 +++++++++++++++++++------ test/pattern_registry_test.exs | 184 ++++++++++++++++- 5 files changed, 753 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/estate-rescan.yml create mode 100644 audits/assail-classifications.a2ml diff --git a/.github/workflows/estate-rescan.yml b/.github/workflows/estate-rescan.yml new file mode 100644 index 00000000..aacf82f0 --- /dev/null +++ b/.github/workflows/estate-rescan.yml @@ -0,0 +1,245 @@ +# SPDX-License-Identifier: MPL-2.0 +# Hypatia Estate Rescan — refresh verisimdb-data/scans with current truth +# +# The canonical security reports (verisimdb-data/scans/*.json) were +# produced by manual local panic-attack runs and went stale: by 2026-06 +# the majority of recorded Critical/High findings referenced files that +# no longer existed, or content that had already been fixed, while two +# active repos (vcl-ut, bag-of-actions) had no scan at all. Hypatia kept +# reasoning over that snapshot and the fleet kept dispatching fixes for +# phantom findings. +# +# This workflow is the missing freshness link: it rebuilds the scan +# store centrally with the CURRENT panic-attack (which carries the +# kanren/context/classification FP-suppression absent from the old +# data), so the analyzer's input reflects reality on a daily cadence — +# and on demand the moment a fix lands. +# +# Triggers: +# - schedule: daily full sweep (staggered off remediation-sweep 03:17 +# and the .git-private-farm Monday maintenance run). +# - workflow_dispatch: manual, optionally targeted via `repos`. +# - repository_dispatch (type: estate-rescan): targeted refresh, fired +# by gitbot-fleet's dispatch-runner after it lands fixes, so resolved +# findings flush from the store without waiting for the nightly run. +# +# Scope guard: only PUBLIC, non-archived, non-fork repos are scanned — +# verisimdb-data is public, so private-repo findings must never be +# written into it. Repos named 007* are excluded per the owner's +# licence/scanning policy (out of scope for any scanning). + +name: Hypatia Estate Rescan + +on: + schedule: + - cron: '47 4 * * *' + workflow_dispatch: + inputs: + repos: + description: 'Space-separated repo names to rescan (default: all public org repos)' + required: false + default: '' + type: string + dry_run: + description: 'Scan and report, but do not push to verisimdb-data' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + repository_dispatch: + types: [estate-rescan] + +permissions: + contents: read + +# Serialize: concurrent sweeps would race on the verisimdb-data push. +# cancel-in-progress: false so a targeted post-fix rescan request queues +# behind a running nightly sweep instead of killing it. +concurrency: + group: estate-rescan + cancel-in-progress: false + +jobs: + rescan: + name: Rescan estate with panic-attack + runs-on: ubuntu-latest + timeout-minutes: 300 + + steps: + - name: Resolve panic-attack version + id: pa_ref + run: echo "sha=$(git ls-remote https://github.com/hyperpolymath/panic-attack.git HEAD | cut -f1)" >> "$GITHUB_OUTPUT" + + - name: Cache built panic-attack + id: pa_cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/panic-attack-bin + # Exact-SHA key, no restore-keys: a partial hit would scan with + # stale rules (same reasoning as the hypatia-scan build cache). + key: panic-attack-${{ runner.os }}-${{ steps.pa_ref.outputs.sha }} + + - name: Build panic-attack (if needed) + if: steps.pa_cache.outputs.cache-hit != 'true' + env: + PA_SHA: ${{ steps.pa_ref.outputs.sha }} + run: | + set -euo pipefail + git clone --depth 1 https://github.com/hyperpolymath/panic-attack.git "$RUNNER_TEMP/panic-attack" + cd "$RUNNER_TEMP/panic-attack" + git fetch --depth 1 origin "$PA_SHA" && git checkout "$PA_SHA" + cargo build --release + mkdir -p "$HOME/panic-attack-bin" + cp target/release/panic-attack "$HOME/panic-attack-bin/" + + - name: Resolve repo list + id: repos + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + INPUT_REPOS: ${{ inputs.repos }} + PAYLOAD_REPOS: ${{ github.event.client_payload.repos }} + run: | + set -euo pipefail + REQUESTED="${INPUT_REPOS:-${PAYLOAD_REPOS:-}}" + if [ -n "$REQUESTED" ]; then + # Targeted rescan: validate each requested name against the + # public-repo constraint rather than trusting the payload. + printf '%s' "$REQUESTED" | tr ' ,' '\n\n' | sed '/^$/d' | sort -u > /tmp/requested.txt + : > /tmp/repo-list.txt + while IFS= read -r name; do + # Names come from a repository_dispatch payload — enforce a + # strict charset before using them anywhere. + if ! printf '%s' "$name" | grep -Eq '^[A-Za-z0-9._-]+$'; then + echo "::warning::Skipping invalid repo name in payload: $name" + continue + fi + VISIBILITY=$(gh api "repos/hyperpolymath/$name" --jq '[.private, .archived, .fork] | @tsv' 2>/dev/null || echo "missing") + if [ "$VISIBILITY" = "$(printf 'false\tfalse\tfalse')" ]; then + echo "$name" >> /tmp/repo-list.txt + else + echo "::warning::Skipping $name (private/archived/fork/missing — not eligible for the public scan store)" + fi + done < /tmp/requested.txt + else + gh api "users/hyperpolymath/repos?type=public&per_page=100" --paginate \ + --jq '.[] | select(.archived == false and .fork == false and .disabled == false) | .name' \ + | grep -Ev '^007' \ + | grep -Ev '^(verisimdb-data|hyperpolymath-archive)' \ + | sort -u > /tmp/repo-list.txt + fi + COUNT=$(wc -l < /tmp/repo-list.txt) + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Rescanning $COUNT repos" + + - name: Checkout verisimdb-data + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + run: | + set -euo pipefail + git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/verisimdb-data.git" "$RUNNER_TEMP/verisimdb-data" + + - name: Scan repos + id: scan + run: | + set -uo pipefail + PA="$HOME/panic-attack-bin/panic-attack" + DATA="$RUNNER_TEMP/verisimdb-data" + FAILED=0 + SCANNED=0 + while IFS= read -r name; do + workdir="$RUNNER_TEMP/work/$name" + rm -rf "$workdir" + if ! git clone --depth 1 --quiet "https://github.com/hyperpolymath/$name.git" "$workdir" 2>/dev/null; then + echo "::warning::clone failed: $name" + FAILED=$((FAILED+1)) + continue + fi + if "$PA" assail "$workdir" --output "$RUNNER_TEMP/scan-raw.json" >/dev/null 2>&1; then + # Normalize program_path to the repo name: runner paths are + # meaningless to consumers and leaked local paths were one of + # the defects of the manual-scan era (`/var$REPOS_DIR/...`). + jq --arg name "$name" '.program_path = $name' \ + "$RUNNER_TEMP/scan-raw.json" > "$DATA/scans/$name.json" + SCANNED=$((SCANNED+1)) + else + echo "::warning::scan failed: $name" + FAILED=$((FAILED+1)) + fi + rm -rf "$workdir" + done < /tmp/repo-list.txt + echo "scanned=$SCANNED" >> "$GITHUB_OUTPUT" + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "Scanned $SCANNED repos ($FAILED failures)" + + - name: Regenerate index.json + run: | + set -euo pipefail + DATA="$RUNNER_TEMP/verisimdb-data" + NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) + # Preserve human/LLM-authored summaries from the previous index; + # recompute the live counts from the scan files themselves. + cp "$DATA/index.json" /tmp/old-index.json 2>/dev/null || echo '{}' > /tmp/old-index.json + REPOS_JSON='{}' + for f in "$DATA"/scans/*.json; do + name=$(basename "$f" .json) + ENTRY=$(jq --arg now "$NOW" --slurpfile old /tmp/old-index.json --arg name "$name" ' + { + last_scan: $now, + weak_points: ([.weak_points[]? | select((.suppressed // false) | not)] | length), + suppressed: ([.weak_points[]? | select(.suppressed // false)] | length), + critical: ([.weak_points[]? | select(((.suppressed // false) | not) and .severity == "Critical")] | length), + high: ([.weak_points[]? | select(((.suppressed // false) | not) and .severity == "High")] | length), + summary: ($old[0].repos[$name].summary // null) + }' "$f") + REPOS_JSON=$(jq --arg name "$name" --argjson entry "$ENTRY" '. + {($name): $entry}' <<<"$REPOS_JSON") + done + jq -n --arg now "$NOW" --argjson repos "$REPOS_JSON" \ + '{repos: $repos, total_scans: ($repos | length), last_updated: $now}' \ + > "$DATA/index.json" + jq -r '"index.json: \(.total_scans) repos, last_updated \(.last_updated)"' "$DATA/index.json" + + - name: Push to verisimdb-data + if: inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} + SCANNED: ${{ steps.scan.outputs.scanned }} + run: | + set -euo pipefail + DATA="$RUNNER_TEMP/verisimdb-data" + cd "$DATA" + git config user.name "hypatia-estate-rescan" + git config user.email "hypatia-bot@users.noreply.github.com" + git add scans/ index.json + if git diff --cached --quiet; then + echo "No scan changes to push." + exit 0 + fi + git commit -m "scan: estate rescan ($SCANNED repos, panic-attack refresh)" + # Retry with rebase: another writer (ingest.yml, manual push) may + # land between our clone and push. + for delay in 2 4 8 16; do + if git push origin HEAD:main; then + exit 0 + fi + echo "push failed; rebasing and retrying in ${delay}s" + sleep "$delay" + git pull --rebase origin main || true + done + git push origin HEAD:main + + - name: Job summary + env: + COUNT: ${{ steps.repos.outputs.count }} + SCANNED: ${{ steps.scan.outputs.scanned }} + FAILED: ${{ steps.scan.outputs.failed }} + DRY: ${{ inputs.dry_run }} + run: | + { + echo "## Estate Rescan" + echo "- Repos targeted: $COUNT" + echo "- Scanned OK: $SCANNED" + echo "- Failures: $FAILED" + echo "- Dry run: ${DRY:-false}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/audits/assail-classifications.a2ml b/audits/assail-classifications.a2ml new file mode 100644 index 00000000..a037f083 --- /dev/null +++ b/audits/assail-classifications.a2ml @@ -0,0 +1,101 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; assail-classifications.a2ml — audited panic-attack findings for hypatia. +;; +;; Read by panic-attack >= 2.5.5 (load_user_classifications): findings +;; matching (file, category) are flipped to suppressed = true AFTER the +;; structural kanren suppression pass. Entries here are AUDITED-SOUND +;; residuals, not exemptions of convenience — every entry must cite its +;; rationale, and the registry lives apart from the source so a code +;; edit cannot self-suppress (see panic-attack CLAUDE.md, User- +;; Classification Registry). +;; +;; Audit trail: 2026-06-11 estate-loop session — fresh panic-attack +;; 2.5.5 scan of hypatia; every entry below was inspected at source. + +(assail-classifications + ;; ── Soundness-gate negative fixtures ───────────────────────────── + ;; test/soundness/fixtures/code_safety/ exists to PROVE the scanner + ;; catches these constructs; the findings are the fixtures working + ;; as designed. Mirrors lib/hypatia/scanner_suppression.ex, which + ;; already exempts these paths for hypatia's own scanner. + (classification + (file "test/soundness/fixtures/code_safety/transmute.rs") + (category "UnsafeCode") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects mem::transmute")) + (classification + (file "test/soundness/fixtures/code_safety/admitted.v") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Admitted")) + (classification + (file "test/soundness/fixtures/code_safety/believe_me.idr") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects believe_me")) + (classification + (file "test/soundness/fixtures/code_safety/sorry.lean") + (category "ProofDrift") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects sorry")) + (classification + (file "test/soundness/fixtures/code_safety/obj_magic_ocaml.ml") + (category "UnsafeTypeCoercion") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Obj.magic")) + (classification + (file "test/soundness/fixtures/code_safety/unsafe_coerce.hs") + (category "UnsafeTypeCoercion") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects unsafeCoerce")) + (classification + (file "test/soundness/fixtures/code_safety/elixir_code_eval.ex") + (category "DynamicCodeExecution") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Code.eval_string")) + (classification + (file "test/soundness/fixtures/code_safety/zig_ptr_cast.zig") + (category "UnsafeCode") + (audit "test/soundness — deliberate negative fixture") + (rationale "fixture asserting the scanner detects Zig pointer casts")) + + ;; ── Remediation scripts: pattern strings, not vulnerabilities ──── + (classification + (file "scripts/fix-scripts/fix-deno-permissions.sh") + (category "ExcessivePermissions") + (audit "remediation tool — replaces `deno -A` with scoped permission flags; scripts/fix-scripts/ is already exempt in scanner_suppression.ex") + (rationale "contains -A as the SEARCH pattern it removes from other repos")) + + ;; ── FFI boundary unsafe: documented, load-bearing ──────────────── + (classification + (file "clients/rust/hypatia-client/src/ffi.rs") + (category "UnsafeCode") + (audit "in-file # Safety section (ffi.rs:7-30) enumerates the invariant for every unsafe block; carries hypatia allow pragmas") + (rationale "libloading C-ABI boundary calls against libhypatia_ffi.so; each block's invariant documented at source")) + + ;; ── Test harnesses ─────────────────────────────────────────────── + (classification + (file "tests/e2e.sh") + (category "CommandInjection") + (audit "tests/e2e.sh:88-93 — heredoc writes a deliberately insecure fixture") + (rationale "the eval is fixture CONTENT generated for the scanner-detection test, not executed by the harness")) + (classification + (file "integration/run-tests.sh") + (category "HardcodedSecret") + (audit "integration/run-tests.sh:268 — ARANGODB_PASSWORD=testpassword") + (rationale "throwaway credential for the ephemeral integration-test database container")) + + ;; ── Env-var binding misread as a secret ────────────────────────── + (classification + (file "hooks/lib/cache.sh") + (category "HardcodedSecret") + (audit "hooks/lib/cache.sh:17-22 — CICD_CACHE_PASSWORD=\"${CICD_CACHE_PASSWORD:-}\"") + (rationale "empty-default env-var binding, documented in-file; no literal secret present")) + + ;; ── Rule definitions detecting themselves ──────────────────────── + (classification + (file "lib/hypatia/scanner_suppression.ex") + (category "HardcodedSecret") + (audit "scanner suppression rule definitions contain secret-shaped pattern strings") + (rationale "detector/suppression pattern table, not credentials — same class as the scripts/fix-* carve-out in scanner_suppression.ex")) +) diff --git a/lib/mix/tasks/hypatia.triage_issues.ex b/lib/mix/tasks/hypatia.triage_issues.ex index 05d4b6aa..6e03c8ac 100644 --- a/lib/mix/tasks/hypatia.triage_issues.ex +++ b/lib/mix/tasks/hypatia.triage_issues.ex @@ -104,7 +104,10 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do labels = opts - |> Keyword.get(:labels, "hypatia,hypatia-security-alert,hypatia-advisory,automated-by-hypatia") + |> Keyword.get( + :labels, + "hypatia,hypatia-security-alert,hypatia-advisory,automated-by-hypatia" + ) |> String.split(",", trim: true) classes = parse_classes(Keyword.get(opts, :classes)) @@ -136,10 +139,18 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do defp parse_classes(nil), do: @close_classes defp parse_classes(csv) do + # Resolve against the known-class table instead of atomizing CLI + # input: dynamic atom creation from arbitrary input leaks atoms (PA013). + allowed = Map.new(@close_classes, fn c -> {Atom.to_string(c), c} end) + csv |> String.split(",", trim: true) - |> Enum.map(&String.to_atom/1) - |> Enum.filter(&(&1 in @close_classes)) + |> Enum.flat_map(fn s -> + case Map.fetch(allowed, s) do + {:ok, atom} -> [atom] + :error -> [] + end + end) end # ─── Discovery ───────────────────────────────────────────────────────── @@ -315,7 +326,8 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do Enum.map(candidates, fn %{issue: i, class: c, reason: r} -> %{ "number" => i["number"], - "repo" => i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last(), + "repo" => + i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last(), "title" => i["title"], "url" => i["html_url"], "class" => Atom.to_string(c), @@ -353,8 +365,7 @@ defmodule Mix.Tasks.Hypatia.TriageIssues do {closed, failed} = Enum.reduce(candidates, {0, 0}, fn %{issue: i, reason: r}, {ok, bad} -> - repo = - i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last() + repo = i["repository_url"] |> to_string() |> String.split("/repos/") |> List.last() case close_issue(repo, i["number"], r, token) do :ok -> {ok + 1, bad} diff --git a/lib/pattern_registry.ex b/lib/pattern_registry.ex index d92020f1..391d186f 100644 --- a/lib/pattern_registry.ex +++ b/lib/pattern_registry.ex @@ -7,6 +7,41 @@ defmodule Hypatia.PatternRegistry do Reads and writes to verisim-data/patterns/registry.json. Groups scan weak_points by (category, description_fingerprint), assigns pattern IDs, tracks occurrence counts, and detects trends. + + ## Sync semantics (rebuild, not append) + + `sync_from_scans/1` REBUILDS the per-repo state for every repo present + in the scan batch, instead of appending to it. The previous append-only + behaviour had two systemic defects (observed live on the 2026-06 + estate data): + + 1. **Counts inflated multiplicatively** — every sync re-added the + same weak_points, so `occurrences` grew with each pipeline run + rather than tracking reality. + 2. **Nothing ever resolved** — repos stayed in `repos_affected_list` + after going clean, so the fleet kept dispatching fixes for + findings (and files) that no longer existed. + + Now each pattern stores `repo_occurrences` (a `repo => count` map) as + the source of truth. A sync replaces the entries for scanned repos and + leaves unscanned repos' entries untouched, so partial syncs stay + correct. A pattern whose `repo_occurrences` empties out is kept for + history with `occurrences: 0` and `trend: "resolved"` — downstream + routing iterates `repos_affected_list`, so resolved patterns dispatch + nothing. + + ## Input hygiene + + Suppressed weak_points (`"suppressed": true` — panic-attack's + kanren/context/classification suppression verdict) are rejected at + intake. panic-attack's contract is explicit: consumers must filter on + `suppressed: false`; the suppressed entries remain in the scan JSON + only for auditability. + + Heuristic categories are severity-capped at intake (see + `@heuristic_severity_caps`): a "potential pattern detected" guess must + not enter the registry as Critical. Mirrors the precedent of + `license_finding_severity_cap/1` in `lib/rules/cicd_rules.ex`. """ require Logger @@ -34,6 +69,11 @@ defmodule Hypatia.PatternRegistry do "UncheckedError" => "PA018", "InfiniteRecursion" => "PA019", "UnsafeTypeCoercion" => "PA020", + "ProofDrift" => "PA021", + "CryptoMisuse" => "PA022", + "SupplyChain" => "PA023", + "InputBoundary" => "PA024", + "MutationGap" => "PA025", # UX patterns -- from cross-platform container test harness "HardcodedAbsolutePath" => "UX001", "MissingQuickstart" => "UX002", @@ -47,85 +87,49 @@ defmodule Hypatia.PatternRegistry do "MissingAdjustContractile" => "UX010" } + # Heuristic detectors whose findings are pattern guesses, not confirmed + # vulnerabilities. panic-attack 2.5.5 emits UnboundedAllocation + # ("Potential unbounded allocation pattern detected") at Critical, which + # made one keyword matcher ~70% of all estate Criticals. Cap such + # categories so prioritisation reflects confirmation strength. The cap + # only ever lowers severity, never raises it. + @heuristic_severity_caps %{"UnboundedAllocation" => "Medium"} + + @severity_rank %{"Critical" => 4, "High" => 3, "Medium" => 2, "Low" => 1, "Info" => 0} + @doc """ Build/update the pattern registry from scan data. Takes a list of scan results (from VerisimConnector.fetch_all_scans/0) - and groups weak_points into canonical patterns tracked in registry.json. + and rebuilds the per-repo pattern state for every repo in the batch. + See the moduledoc for the full sync semantics. """ def sync_from_scans(scans) do registry = load_registry() substitutions = load_substitutions() now = DateTime.utc_now() |> DateTime.to_iso8601() + scanned_repos = MapSet.new(scans, & &1.repo) + fresh = aggregate_fresh(scans, substitutions) + previous = Map.get(registry, "patterns", %{}) + + all_ids = MapSet.union(MapSet.new(Map.keys(previous)), MapSet.new(Map.keys(fresh))) + + patterns = + for id <- all_ids, into: %{} do + {id, merge_pattern(id, Map.get(previous, id), Map.get(fresh, id), scanned_repos, now)} + end + updated_registry = - Enum.reduce(scans, registry, fn scan, reg -> - weak_points = Map.get(scan.scan, "weak_points", []) - - program_path = Map.get(scan.scan, "program_path", "") - - Enum.reduce(weak_points, reg, fn wp, acc -> - category = Map.get(wp, "category", "unknown") - description = Map.get(wp, "description", category) - severity = Map.get(wp, "severity", "Medium") - repo = scan.repo - - pa_rule = Map.get(@pa_rule_map, category, "PA000") - slug = fingerprint(description) - pattern_id = "#{pa_rule}-#{slug}" - - tier = get_triangle_tier(category, substitutions) - - patterns = Map.get(acc, "patterns", %{}) - - updated_pattern = - case Map.get(patterns, pattern_id) do - nil -> - %{ - "id" => pattern_id, - "category" => category, - "severity" => severity, - "description" => description, - "pa_rule" => pa_rule, - "occurrences" => 1, - "repos_affected" => 1, - "repos_affected_list" => [repo], - "repo_paths" => %{repo => program_path}, - "first_seen" => now, - "last_seen" => now, - "trend" => "new", - "triangle_tier" => tier, - "recipe_id" => nil - } - - existing -> - repos_list = Map.get(existing, "repos_affected_list", []) - repo_paths = Map.get(existing, "repo_paths", %{}) - - {repos_list, repos_count} = - if repo in repos_list do - {repos_list, Map.get(existing, "repos_affected", 1)} - else - {repos_list ++ [repo], Map.get(existing, "repos_affected", 1) + 1} - end - - existing - |> Map.put("occurrences", Map.get(existing, "occurrences", 0) + 1) - |> Map.put("repos_affected", repos_count) - |> Map.put("repos_affected_list", repos_list) - |> Map.put("repo_paths", Map.put(repo_paths, repo, program_path)) - |> Map.put("last_seen", now) - end - - put_in(acc, ["patterns", pattern_id], updated_pattern) - end) - end) + registry + |> Map.put("patterns", patterns) + |> Map.put("last_updated", now) - updated_registry = Map.put(updated_registry, "last_updated", now) save_registry(updated_registry) - pattern_count = map_size(Map.get(updated_registry, "patterns", %{})) - Logger.info("Pattern registry synced: #{pattern_count} patterns tracked") + active = Enum.count(Map.values(patterns), &(Map.get(&1, "occurrences", 0) > 0)) + resolved = map_size(patterns) - active + Logger.info("Pattern registry synced: #{active} active, #{resolved} resolved patterns") {:ok, updated_registry} end @@ -144,13 +148,19 @@ defmodule Hypatia.PatternRegistry do |> Map.values() end + @doc "Get only patterns with at least one live occurrence." + def active_patterns do + all_patterns() + |> Enum.filter(fn p -> Map.get(p, "occurrences", 0) > 0 end) + end + @doc "Filter patterns by category (PA rule number)." def patterns_by_category(category) do all_patterns() |> Enum.filter(fn p -> Map.get(p, "category") == category end) end - @doc "Get patterns by trend direction: :increasing, :decreasing, :stable, :new" + @doc "Get patterns by trend direction: :increasing, :decreasing, :stable, :new, :resolved" def trending_patterns(direction) do direction_str = Atom.to_string(direction) @@ -168,6 +178,138 @@ defmodule Hypatia.PatternRegistry do # --- Private --- + # Aggregate the current scan batch into + # pattern_id => %{meta, repo_occurrences, repo_paths}. + defp aggregate_fresh(scans, substitutions) do + Enum.reduce(scans, %{}, fn scan, acc -> + weak_points = + scan.scan + |> Map.get("weak_points", []) + |> Enum.reject(&(Map.get(&1, "suppressed", false) == true)) + + program_path = Map.get(scan.scan, "program_path", "") + repo = scan.repo + + Enum.reduce(weak_points, acc, fn wp, inner -> + category = Map.get(wp, "category", "unknown") + description = Map.get(wp, "description", category) + severity = cap_severity(category, Map.get(wp, "severity", "Medium")) + + pa_rule = Map.get(@pa_rule_map, category, "PA000") + pattern_id = "#{pa_rule}-#{fingerprint(description)}" + + entry = + Map.get(inner, pattern_id, %{ + "category" => category, + "severity" => severity, + "description" => description, + "pa_rule" => pa_rule, + "triangle_tier" => get_triangle_tier(category, substitutions), + "repo_occurrences" => %{}, + "repo_paths" => %{} + }) + + entry = + entry + |> update_in(["repo_occurrences", repo], &((&1 || 0) + 1)) + |> put_in(["repo_paths", repo], program_path) + + Map.put(inner, pattern_id, entry) + end) + end) + end + + # Merge one pattern's previous state with its fresh aggregation. + # Repos in `scanned_repos` are authoritative from `fresh`; repos not + # scanned this batch carry over from `previous`. + defp merge_pattern(id, previous, fresh, scanned_repos, now) do + prev_ro = previous_repo_occurrences(previous) + carried_ro = Map.reject(prev_ro, fn {repo, _} -> MapSet.member?(scanned_repos, repo) end) + + # Nothing about this pattern was rescanned -> leave it untouched. + if fresh == nil and carried_ro == prev_ro do + previous + else + fresh_ro = if fresh, do: Map.get(fresh, "repo_occurrences", %{}), else: %{} + merged_ro = Map.merge(carried_ro, fresh_ro) + total = merged_ro |> Map.values() |> Enum.sum() + prev_total = if previous, do: Map.get(previous, "occurrences", 0), else: 0 + + prev_paths = if previous, do: Map.get(previous, "repo_paths", %{}), else: %{} + fresh_paths = if fresh, do: Map.get(fresh, "repo_paths", %{}), else: %{} + + base = previous || %{} + meta = fresh || %{} + + base + |> Map.merge( + Map.take(meta, ["category", "severity", "description", "pa_rule", "triangle_tier"]) + ) + |> Map.put("id", id) + |> Map.put_new("recipe_id", nil) + |> Map.put_new("first_seen", now) + |> Map.put("repo_occurrences", merged_ro) + |> Map.put( + "repo_paths", + Map.merge(prev_paths, fresh_paths) |> Map.take(Map.keys(merged_ro)) + ) + |> Map.put("occurrences", total) + |> Map.put("repos_affected", map_size(merged_ro)) + |> Map.put("repos_affected_list", merged_ro |> Map.keys() |> Enum.sort()) + |> Map.put("trend", trend(previous, prev_total, total)) + |> then(fn p -> + if total > 0 do + p |> Map.put("last_seen", now) |> Map.delete("resolved_at") + else + Map.put_new(p, "resolved_at", now) + end + end) + end + end + + # The previous schema had no `repo_occurrences`; synthesize one + # occurrence per affected repo. This deliberately resets the inflated + # legacy totals on first rebuild. + defp previous_repo_occurrences(nil), do: %{} + + defp previous_repo_occurrences(previous) do + case Map.get(previous, "repo_occurrences") do + ro when is_map(ro) and map_size(ro) > 0 -> + ro + + _ -> + previous + |> Map.get("repos_affected_list", []) + |> Map.new(&{&1, 1}) + end + end + + defp trend(nil, _prev_total, _total), do: "new" + defp trend(_previous, _prev_total, 0), do: "resolved" + + defp trend(_previous, prev_total, total) do + cond do + prev_total == 0 -> "increasing" + total > prev_total -> "increasing" + total < prev_total -> "decreasing" + true -> "stable" + end + end + + defp cap_severity(category, severity) do + case Map.get(@heuristic_severity_caps, category) do + nil -> + severity + + cap -> + if Map.get(@severity_rank, severity, 0) > Map.get(@severity_rank, cap, 0) do + cap + else + severity + end + end + end + defp registry_path do Path.join(Path.expand(@verisimdb_data_path), "patterns/registry.json") end diff --git a/test/pattern_registry_test.exs b/test/pattern_registry_test.exs index 5599eefc..3a059bdd 100644 --- a/test/pattern_registry_test.exs +++ b/test/pattern_registry_test.exs @@ -87,8 +87,7 @@ defmodule Hypatia.PatternRegistryTest do patterns = Map.get(registry, "patterns", %{}) |> Map.values() # Find the pattern with our unique description - matching = - Enum.filter(patterns, fn p -> Map.get(p, "description") == unique_desc end) + matching = Enum.filter(patterns, fn p -> Map.get(p, "description") == unique_desc end) assert length(matching) == 1 pattern = hd(matching) @@ -108,4 +107,185 @@ defmodule Hypatia.PatternRegistryTest do assert is_list(patterns) end end + + describe "rebuild semantics" do + test "rejects suppressed weak_points at intake" do + unique_desc = "test-suppressed-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: "suppress-repo", + scan: %{ + "weak_points" => [ + %{ + "category" => "HardcodedSecret", + "description" => unique_desc, + "severity" => "Critical", + "suppressed" => true + } + ] + } + } + ] + + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + patterns = Map.get(registry, "patterns", %{}) |> Map.values() + + assert Enum.filter(patterns, &(Map.get(&1, "description") == unique_desc)) == [] + end + + test "caps heuristic UnboundedAllocation severity at Medium" do + unique_desc = "test-heuristic-cap-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: "cap-repo-#{System.unique_integer([:positive])}", + scan: %{ + "weak_points" => [ + %{ + "category" => "UnboundedAllocation", + "description" => unique_desc, + "severity" => "Critical" + } + ] + } + } + ] + + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["severity"] == "Medium" + end + + test "identical re-sync does not inflate occurrence counts" do + unique_desc = "test-noinflate-#{System.unique_integer([:positive])}" + repo = "noinflate-repo-#{System.unique_integer([:positive])}" + + scans = [ + %{ + repo: repo, + scan: %{ + "weak_points" => [ + %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"}, + %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + ] + } + } + ] + + {:ok, _} = PatternRegistry.sync_from_scans(scans) + {:ok, registry} = PatternRegistry.sync_from_scans(scans) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 2 + assert pattern["trend"] == "stable" + assert pattern["repo_occurrences"] == %{repo => 2} + end + + test "repo going clean resolves its patterns but carries unscanned repos" do + unique_desc = "test-resolve-#{System.unique_integer([:positive])}" + repo_a = "resolve-a-#{System.unique_integer([:positive])}" + repo_b = "resolve-b-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, _} = + PatternRegistry.sync_from_scans([ + %{repo: repo_a, scan: %{"weak_points" => [wp]}}, + %{repo: repo_b, scan: %{"weak_points" => [wp]}} + ]) + + # repo_a rescanned clean; repo_b NOT in this batch -> must carry over + {:ok, registry} = + PatternRegistry.sync_from_scans([%{repo: repo_a, scan: %{"weak_points" => []}}]) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 1 + assert pattern["repos_affected_list"] == [repo_b] + assert pattern["trend"] == "decreasing" + + # repo_b rescanned clean too -> fully resolved, no repos to dispatch to + {:ok, registry} = + PatternRegistry.sync_from_scans([%{repo: repo_b, scan: %{"weak_points" => []}}]) + + pattern = + registry + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["occurrences"] == 0 + assert pattern["repos_affected_list"] == [] + assert pattern["trend"] == "resolved" + assert pattern["resolved_at"] != nil + end + + test "legacy entries without repo_occurrences migrate and preserve identity" do + # Simulate a legacy append-era entry by syncing, then stripping + # repo_occurrences from the persisted registry is not possible via the + # public API; instead verify that a fresh finding for the same pattern + # in a NEW repo merges without disturbing first_seen. + unique_desc = "test-legacy-#{System.unique_integer([:positive])}" + repo_a = "legacy-a-#{System.unique_integer([:positive])}" + repo_b = "legacy-b-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, reg1} = + PatternRegistry.sync_from_scans([%{repo: repo_a, scan: %{"weak_points" => [wp]}}]) + + first_seen = + reg1 + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + |> Map.get("first_seen") + + {:ok, reg2} = + PatternRegistry.sync_from_scans([%{repo: repo_b, scan: %{"weak_points" => [wp]}}]) + + pattern = + reg2 + |> Map.get("patterns", %{}) + |> Map.values() + |> Enum.find(&(Map.get(&1, "description") == unique_desc)) + + assert pattern["first_seen"] == first_seen + assert Enum.sort(pattern["repos_affected_list"]) == Enum.sort([repo_a, repo_b]) + assert pattern["occurrences"] == 2 + end + end + + describe "active_patterns/0" do + test "excludes resolved patterns" do + unique_desc = "test-active-#{System.unique_integer([:positive])}" + repo = "active-repo-#{System.unique_integer([:positive])}" + + wp = %{"category" => "PanicPath", "description" => unique_desc, "severity" => "Medium"} + + {:ok, _} = PatternRegistry.sync_from_scans([%{repo: repo, scan: %{"weak_points" => [wp]}}]) + {:ok, _} = PatternRegistry.sync_from_scans([%{repo: repo, scan: %{"weak_points" => []}}]) + + refute Enum.any?( + PatternRegistry.active_patterns(), + &(Map.get(&1, "description") == unique_desc) + ) + end + end end From f2d00b0400b8f1c88c5a9d5e619199af69d02105 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:42:52 +0000 Subject: [PATCH 3/3] fix(scanner): byte-correct regex slices + single-source banned-language carve-outs + A2ML gate dialect fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scanner-precision fixes, each killing an estate-wide FP class observed live on this PR's own scan comments (and verisimdb#123 / standards#382): 1. Byte-vs-grapheme slicing: every `Regex.scan(..., return: :index)` consumer sliced with String.slice/3, which counts graphemes while the regex engine returns BYTE offsets. Any multi-byte character earlier in a file (em-dash in a comment) shifted every later slice: mangled duplicate findings ("tions/checkout@...", "urin 21 JRE"), and SHA-pinned actions flagged as unpinned because the shifted slug failed the 40-hex exemption. BH004 even sent the mangled owner/repo + sha to the GitHub API. Fixed across workflow_hardening (WH004/5/ 10/11), baseline_health (BH004), research_extensions (RE004/7/8/9) with binary_part/3 plus a clamped, UTF-8-boundary-safe byte_preview helper for fixed-length excerpts. Regression test included. 2. banned_language_file now honours the CicdRules path_allow_prefixes as the single source of truth for the CLAUDE.md exemption tables. The hand-copied @banned_lang_ts_carveouts list had drifted to 3 of the ~12 documented carve-outs, so k9-svc/bindings/deno/mod.ts was flagged Critical on standards#382 despite its documented interop exemption. Python/Go stay hard-refused (total bans). Tests included. 3. dogfood-gate A2ML validation: audits/assail-classifications.a2ml is panic-attack's user-classification registry — an S-expression dialect per that tool's spec, not the TOML-like manifest A2ML the gate validates. Excluded via the action's paths-ignore (defaults restated — a custom value replaces them). Also adds the REUSE copyright line to the registry. --- .github/workflows/dogfood-gate.yml | 19 +- audits/assail-classifications.a2ml | 1 + lib/hypatia/scanner_suppression.ex | 60 +++++-- lib/rules/baseline_health.ex | 11 +- lib/rules/research_extensions.ex | 74 ++++---- lib/rules/workflow_hardening.ex | 50 ++++-- test/scanner_suppression_test.exs | 34 ++++ test/workflow_hardening_test.exs | 272 ++++++++++++++++------------- 8 files changed, 345 insertions(+), 176 deletions(-) diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index b8178297..363b2452 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -35,7 +35,11 @@ jobs: - name: Check for A2ML files id: detect run: | - COUNT=$(find . -name '*.a2ml' -not -path './.git/*' | wc -l) + # audits/assail-classifications.a2ml is panic-attack's user-classification + # registry — an S-expression dialect by that tool's spec (see panic-attack + # CLAUDE.md), not the TOML-like manifest A2ML this gate validates. Exclude + # it rather than feeding it to a validator for a different dialect. + COUNT=$(find . -name '*.a2ml' -not -path './.git/*' -not -name 'assail-classifications.a2ml' | wc -l) echo "count=$COUNT" >> "$GITHUB_OUTPUT" if [ "$COUNT" -eq 0 ]; then echo "::warning::No .a2ml manifest files found. Every RSR repo should have 0-AI-MANIFEST.a2ml" @@ -47,6 +51,19 @@ jobs: with: path: '.' strict: 'false' + # Re-state the action's default carve-outs (a custom value REPLACES + # the default list), plus panic-attack's user-classification + # registry: that file is an S-expression dialect by panic-attack's + # spec, not the TOML-like manifest A2ML this action validates. + paths-ignore: | + vendor/ + vendored/ + verified-container-spec/ + .audittraining/ + integration/fixtures/ + test/fixtures/ + tests/fixtures/ + assail-classifications.a2ml - name: Write summary run: | diff --git a/audits/assail-classifications.a2ml b/audits/assail-classifications.a2ml index a037f083..eba4c4e6 100644 --- a/audits/assail-classifications.a2ml +++ b/audits/assail-classifications.a2ml @@ -1,4 +1,5 @@ ;; SPDX-License-Identifier: MPL-2.0 +;; Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ;; assail-classifications.a2ml — audited panic-attack findings for hypatia. ;; ;; Read by panic-attack >= 2.5.5 (load_user_classifications): findings diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index 491ea672..92bf486b 100644 --- a/lib/hypatia/scanner_suppression.ex +++ b/lib/hypatia/scanner_suppression.ex @@ -58,8 +58,9 @@ defmodule Hypatia.ScannerSuppression do @default_exemptions %{ # `:any` key under a module: applies to every rule type in that module. "security_errors" => %{ - :any => @training_corpus_paths ++ - [".github/workflows/integration.yml"] + :any => + @training_corpus_paths ++ + [".github/workflows/integration.yml"] }, "code_safety" => %{ :any => @training_corpus_paths @@ -148,14 +149,22 @@ defmodule Hypatia.ScannerSuppression do # 2026-05-27 on affinescript#357, panll#54, claude-integrations#43). def suppressed?(file, "cicd_rules", "banned_language_file", opts) do cond do - String.ends_with?(file, ".py") -> false - String.ends_with?(file, ".go") -> false - String.ends_with?(file, "/Makefile") or String.ends_with?(file, "Makefile") -> false - String.ends_with?(file, "/Gemfile") or String.ends_with?(file, "Gemfile") -> false + String.ends_with?(file, ".py") -> + false + + String.ends_with?(file, ".go") -> + false + + String.ends_with?(file, "/Makefile") or String.ends_with?(file, "Makefile") -> + false + + String.ends_with?(file, "/Gemfile") or String.ends_with?(file, "Gemfile") -> + false + true -> repo_path = Keyword.get(opts, :repo_path, nil) rel = relative(file, repo_path) - ts_carveout_match?(rel) + ts_carveout_match?(rel) or blocked_pattern_allow_match?(rel) end end @@ -186,6 +195,28 @@ defmodule Hypatia.ScannerSuppression do Enum.any?(@banned_lang_ts_carveouts, &Regex.match?(&1, rel)) end + # Honour the `path_allow_prefixes` of the matching CicdRules blocked + # pattern. Those lists are the SINGLE SOURCE OF TRUTH for the + # banned-language exemption tables in standards/.claude/CLAUDE.md + # (TypeScript/ReScript/V-lang carve-outs: /bindings/deno/, /vscode/, + # upstream forks, Coq `.v` proof scripts, …). The hand-copied + # @banned_lang_ts_carveouts above predates this delegation and had + # drifted to 3 of the ~12 documented carve-outs, so e.g. + # `k9-svc/bindings/deno/mod.ts` was flagged Critical on standards#382 + # despite its documented exemption. Python/Go stay hard-refused above + # (total bans, no prefixes to honour). + defp blocked_pattern_allow_match?(rel) do + Hypatia.Rules.CicdRules.blocked_patterns() + |> Enum.any?(fn p -> + glob = Map.get(p, :glob) + prefixes = Map.get(p, :path_allow_prefixes, []) + + glob != nil and prefixes != [] and + String.ends_with?(rel, String.replace(glob, "*", "")) and + Enum.any?(prefixes, &String.contains?(rel, &1)) + end) + end + @doc """ Return true if `line` is itself a non-finding even when the pattern matches (e.g. a GitHub Actions `${{ secrets.X }}` reference is *not* a leaked @@ -264,20 +295,24 @@ defmodule Hypatia.ScannerSuppression do # and can't tell #[cfg(test)] context); the `test-` prefix is the strong # signal that this is fixture data, not a real credential. defp rust_test_literal_re, - do: ~r/(?i)(?:password|secret|api[_-]?key|token)\s*[:=]\s*["'](?:test|dummy|fake|example|placeholder)[-_].*?["']/ + do: + ~r/(?i)(?:password|secret|api[_-]?key|token)\s*[:=]\s*["'](?:test|dummy|fake|example|placeholder)[-_].*?["']/ defp directive_re, do: ~r/(?:^|[\s#\/\-;])hypatia:\s*allow\s+([A-Za-z0-9_\*]+)(?:\/([A-Za-z0-9_\*]+))?/i defp directive_matches?(line, rule_module, rule_type) do case Regex.run(directive_re(), line) do - nil -> false + nil -> + false + [_full, declared_mod] -> # No slash: bare type form `hypatia: allow secret_detected` declared_mod in [rule_type, rule_module, "*"] + [_full, declared_mod, declared_type] -> - (declared_mod in [rule_module, "*"]) and - (declared_type in [rule_type, "*"]) + declared_mod in [rule_module, "*"] and + declared_type in [rule_type, "*"] end end @@ -341,12 +376,15 @@ defmodule Hypatia.ScannerSuppression do end defp relative(file, nil), do: file + defp relative(file, repo_path) do cond do String.starts_with?(file, repo_path <> "/") -> String.replace_prefix(file, repo_path <> "/", "") + String.starts_with?(file, repo_path) -> String.replace_prefix(file, repo_path, "") + true -> file end diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 306af927..af3e0687 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -312,8 +312,11 @@ defmodule Hypatia.Rules.BaselineHealth do Regex.scan(@uses_sha_pattern, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {repo_start, repo_len}, {sha_start, sha_len}] -> - action_repo = String.slice(content, repo_start, repo_len) - sha = String.slice(content, sha_start, sha_len) |> String.downcase() + # byte offsets from return: :index — String.slice counts graphemes, + # so any earlier multi-byte char shifted these and sent garbage + # owner/repo + sha pairs to the GitHub API (false BH004 criticals). + action_repo = binary_part(content, repo_start, repo_len) + sha = binary_part(content, sha_start, sha_len) |> String.downcase() line_no = line_number_for_offset(content, full_start) check_action_sha_alive(action_repo, sha, rel, line_no) end) @@ -699,9 +702,7 @@ defmodule Hypatia.Rules.BaselineHealth do names = recent_heads |> Enum.flat_map(fn sha -> - case curl_github( - "repos/#{owner}/#{repo}/commits/#{sha}/check-runs?per_page=100" - ) do + case curl_github("repos/#{owner}/#{repo}/commits/#{sha}/check-runs?per_page=100") do {:ok, %{"check_runs" => runs}} when is_list(runs) -> runs |> Enum.filter(&(&1["conclusion"] == "success")) diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index acb80bb0..8e3ef65b 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -108,7 +108,9 @@ defmodule Hypatia.Rules.ResearchExtensions do root = Path.join([repo_path, ".github", "workflows"]) cond do - not File.dir?(root) -> [] + not File.dir?(root) -> + [] + true -> root |> File.ls!() @@ -169,11 +171,9 @@ defmodule Hypatia.Rules.ResearchExtensions do content = File.read!(path) rel = Path.relative_to(path, repo_path) - touches_secrets? = - Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, content) + touches_secrets? = Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, content) - installs_harden? = - Regex.match?(~r/uses:\s*step-security\/harden-runner/, content) + installs_harden? = Regex.match?(~r/uses:\s*step-security\/harden-runner/, content) if touches_secrets? and not installs_harden? do [ @@ -220,11 +220,9 @@ defmodule Hypatia.Rules.ResearchExtensions do content = File.read!(path) rel = Path.relative_to(path, repo_path) - installs_harden? = - Regex.match?(~r/uses:\s*step-security\/harden-runner/, content) + installs_harden? = Regex.match?(~r/uses:\s*step-security\/harden-runner/, content) - audit_mode? = - Regex.match?(~r/egress-policy:\s*audit\b/, content) + audit_mode? = Regex.match?(~r/egress-policy:\s*audit\b/, content) protected_branch_trigger? = Regex.match?(~r/^\s*on:\s*push\b/m, content) or @@ -278,8 +276,7 @@ defmodule Hypatia.Rules.ResearchExtensions do content = File.read!(path) rel = Path.relative_to(path, repo_path) - uses_cache? = - Regex.match?(~r/uses:\s*actions\/cache(?:@|\s)/, content) + uses_cache? = Regex.match?(~r/uses:\s*actions\/cache(?:@|\s)/, content) if uses_cache? and (Regex.match?(untrusted_key_re, content) or @@ -287,7 +284,9 @@ defmodule Hypatia.Rules.ResearchExtensions do # Locate first match for line number. idx = case Regex.run(untrusted_key_re, content, return: :index) do - [{i, _} | _] -> i + [{i, _} | _] -> + i + _ -> case Regex.run(restore_keys_re, content, return: :index) do [{i, _} | _] -> i @@ -333,11 +332,9 @@ defmodule Hypatia.Rules.ResearchExtensions do """ def re004_container_tag_pin(repo_path) do # `docker://owner/image:tag` (no @sha256:) - docker_uses_re = - ~r/uses:\s*(docker:\/\/\S+?)(?:\s|$)/ + docker_uses_re = ~r/uses:\s*(docker:\/\/\S+?)(?:\s|$)/ - container_re = - ~r/^\s*(?:image|container):\s*(\S+)\s*$/m + container_re = ~r/^\s*(?:image|container):\s*(\S+)\s*$/m repo_path |> workflow_files() @@ -348,7 +345,7 @@ defmodule Hypatia.Rules.ResearchExtensions do uses_findings = Regex.scan(docker_uses_re, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {ref_start, ref_len}] -> - ref = String.slice(content, ref_start, ref_len) + ref = binary_part(content, ref_start, ref_len) if String.contains?(ref, "@sha256:") do [] @@ -361,7 +358,7 @@ defmodule Hypatia.Rules.ResearchExtensions do container_findings = Regex.scan(container_re, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {ref_start, ref_len}] -> - ref = String.slice(content, ref_start, ref_len) + ref = binary_part(content, ref_start, ref_len) cond do String.contains?(ref, "@sha256:") -> @@ -429,11 +426,9 @@ defmodule Hypatia.Rules.ResearchExtensions do is_binary(name) and Regex.match?(~r/(?:test|spec|check|lint|verify)/i, name) - has_continue_on_error? = - Regex.match?(~r/continue-on-error:\s*true\b/, body) + has_continue_on_error? = Regex.match?(~r/continue-on-error:\s*true\b/, body) - has_or_true? = - Regex.match?(~r/\|\|\s*true\b/, body) + has_or_true? = Regex.match?(~r/\|\|\s*true\b/, body) if is_test? and (has_continue_on_error? or has_or_true?) do mechanism = @@ -497,7 +492,8 @@ defmodule Hypatia.Rules.ResearchExtensions do if composite? do Regex.scan(~r/^\s*-?\s*uses:\s*(\S+)/m, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {slug_start, slug_len}] -> - slug = String.slice(content, slug_start, slug_len) + # byte offsets from return: :index (see WorkflowHardening.wh004) + slug = binary_part(content, slug_start, slug_len) cond do String.starts_with?(slug, "./") or String.starts_with?(slug, "docker://") -> @@ -560,7 +556,7 @@ defmodule Hypatia.Rules.ResearchExtensions do # until the next column-0 key. case Regex.run(~r/^env:\s*\n((?:[ \t]+[^\n]*\n?)+)/m, content, return: :index) do [{full_start, _}, {body_start, body_len}] -> - body = String.slice(content, body_start, body_len) + body = binary_part(content, body_start, body_len) if Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, body) do line_no = line_number_for_offset(content, full_start) @@ -609,8 +605,7 @@ defmodule Hypatia.Rules.ResearchExtensions do def re008_spoofable_bot_gate(repo_path) do # github.actor compared to any bot-identity string. Catch both # `==` and `!=` orientations and either quote style. - bot_gate_re = - ~r/github\.actor\s*(?:==|!=)\s*['"]([a-zA-Z0-9_-]+\[bot\])['"]/ + bot_gate_re = ~r/github\.actor\s*(?:==|!=)\s*['"]([a-zA-Z0-9_-]+\[bot\])['"]/ repo_path |> workflow_files() @@ -620,7 +615,7 @@ defmodule Hypatia.Rules.ResearchExtensions do Regex.scan(bot_gate_re, content, return: :index) |> Enum.map(fn [{idx, _}, {name_start, name_len}] -> - name = String.slice(content, name_start, name_len) + name = binary_part(content, name_start, name_len) line_no = line_number_for_offset(content, idx) %{ @@ -667,7 +662,7 @@ defmodule Hypatia.Rules.ResearchExtensions do Regex.scan(re, content, return: :index) |> Enum.map(fn [{idx, _} | _] -> line_no = line_number_for_offset(content, idx) - slice = String.slice(content, idx, 60) + slice = byte_preview(content, idx, 60) %{ rule: "RE009", @@ -712,8 +707,7 @@ defmodule Hypatia.Rules.ResearchExtensions do Regex.match?(~r/^\s*on:\s*workflow_run\b/m, content) or Regex.match?(~r/^\s+workflow_run:/m, content) - downloads_artifact? = - Regex.match?(~r/uses:\s*actions\/download-artifact/, content) + downloads_artifact? = Regex.match?(~r/uses:\s*actions\/download-artifact/, content) run_id_constrained? = Regex.match?(~r/run-id:\s*\$\{\{\s*github\.event\.workflow_run\.id/, content) or @@ -794,8 +788,7 @@ defmodule Hypatia.Rules.ResearchExtensions do Enum.reduce(lines, {[], nil}, fn {line, no}, {acc, current} -> # A new step begins on a `- ` token. We detect both # `- name: ...`, `- run: ...`, `- uses: ...` as openers. - new_step? = - Regex.match?(~r/^\s+-\s+(?:name|run|uses):/, line) + new_step? = Regex.match?(~r/^\s+-\s+(?:name|run|uses):/, line) cond do new_step? -> @@ -872,6 +865,23 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Kernel.+(1) end + # Fixed-length excerpt starting at a BYTE offset (regex match start, so + # always on a character boundary). Clamps to the remaining bytes and + # trims any trailing partial UTF-8 sequence (mirrors + # WorkflowHardening.byte_preview/3). + defp byte_preview(content, idx, len) do + avail = max(byte_size(content) - idx, 0) + trim_partial_utf8(binary_part(content, idx, min(len, avail))) + end + + defp trim_partial_utf8(bin) do + if String.valid?(bin) or byte_size(bin) == 0 do + bin + else + trim_partial_utf8(binary_part(bin, 0, byte_size(bin) - 1)) + end + end + defp group_by_severity(findings) do findings |> Enum.group_by(& &1.severity) diff --git a/lib/rules/workflow_hardening.ex b/lib/rules/workflow_hardening.ex index cdbcb961..2ce73695 100644 --- a/lib/rules/workflow_hardening.ex +++ b/lib/rules/workflow_hardening.ex @@ -99,7 +99,9 @@ defmodule Hypatia.Rules.WorkflowHardening do root = Path.join([repo_path, ".github", "workflows"]) cond do - not File.dir?(root) -> [] + not File.dir?(root) -> + [] + true -> root |> File.ls!() @@ -122,10 +124,10 @@ defmodule Hypatia.Rules.WorkflowHardening do github.head_ref github.ref ] @untrusted_pattern Regex.compile!( - "\\$\\{\\{\\s*(" <> - (@untrusted_contexts |> Enum.map(&Regex.escape/1) |> Enum.join("|")) <> - ")" - ) + "\\$\\{\\{\\s*(" <> + (@untrusted_contexts |> Enum.map(&Regex.escape/1) |> Enum.join("|")) <> + ")" + ) # ─── WH001: Template injection ────────────────────────────────────── @@ -146,6 +148,7 @@ defmodule Hypatia.Rules.WorkflowHardening do |> Enum.flat_map(fn path -> content = File.read!(path) rel = Path.relative_to(path, repo_path) + scan_run_blocks(content) |> Enum.flat_map(fn {line_no, run_text} -> if Regex.match?(@untrusted_pattern, run_text) do @@ -322,7 +325,13 @@ defmodule Hypatia.Rules.WorkflowHardening do def wh004_scan_content(filename, content) do Regex.scan(~r/^\s*-?\s*uses:\s*(\S+)/m, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {slug_start, slug_len}] -> - slug = String.slice(content, slug_start, slug_len) + # `return: :index` yields BYTE offsets; String.slice/3 counts + # graphemes. Any multi-byte character earlier in the file (em-dash + # in a comment, emoji) shifted the slice, producing mangled slugs + # ("tions/checkout@…", "urin 21 JRE…") that bypassed the + # 40-hex-pinned exemption below and flagged SHA-pinned actions as + # unpinned. binary_part/3 is the byte-correct slice. + slug = binary_part(content, slug_start, slug_len) cond do String.starts_with?(slug, "./") or String.starts_with?(slug, "docker://") -> @@ -377,7 +386,8 @@ defmodule Hypatia.Rules.WorkflowHardening do Regex.scan(~r/(?m)^\s*password:\s*(\S.*)$/, content, return: :index) |> Enum.flat_map(fn [{full_start, _}, {val_start, val_len}] -> - val = String.slice(content, val_start, val_len) + # byte offsets from return: :index — see wh004_scan_content + val = binary_part(content, val_start, val_len) line_no = line_number_for_offset(content, full_start) if String.contains?(val, "${{ secrets.") or String.contains?(val, "${{secrets.") do @@ -546,7 +556,9 @@ defmodule Hypatia.Rules.WorkflowHardening do content = File.read!(path) rel = Path.relative_to(path, repo_path) - Regex.scan(~r/\$\{\{\s*(toJSON|toJson|fromJSON|fromJson)\(\s*secrets\s*\)\s*\}\}/, content, return: :index) + Regex.scan(~r/\$\{\{\s*(toJSON|toJson|fromJSON|fromJson)\(\s*secrets\s*\)\s*\}\}/, content, + return: :index + ) |> Enum.map(fn [{idx, _} | _] -> line_no = line_number_for_offset(content, idx) @@ -587,7 +599,7 @@ defmodule Hypatia.Rules.WorkflowHardening do Regex.scan(~r/::(?:set-output|save-state|set-env|add-path)\b/, content, return: :index) |> Enum.map(fn [{idx, _}] -> line_no = line_number_for_offset(content, idx) - slice = String.slice(content, idx, 20) + slice = byte_preview(content, idx, 20) %{ rule: "WH010", @@ -631,7 +643,7 @@ defmodule Hypatia.Rules.WorkflowHardening do ) |> Enum.map(fn [{idx, _}] -> line_no = line_number_for_offset(content, idx) - slice = String.slice(content, idx, 60) + slice = byte_preview(content, idx, 60) %{ rule: "WH011", @@ -842,6 +854,7 @@ defmodule Hypatia.Rules.WorkflowHardening do defp append_line({id, no, body}, line), do: {id, no, [line | body]} defp flush(acc, nil), do: acc + defp flush(acc, {id, no, body}) do [{id, no, body |> Enum.reverse() |> Enum.join("\n")} | acc] end @@ -854,6 +867,23 @@ defmodule Hypatia.Rules.WorkflowHardening do |> Kernel.+(1) end + # Fixed-length excerpt starting at a BYTE offset (regex match start, so + # always on a character boundary). Clamps to the remaining bytes — + # binary_part/3 raises on overrun — and trims any trailing partial + # UTF-8 sequence so the preview stays Jason-encodable. + defp byte_preview(content, idx, len) do + avail = max(byte_size(content) - idx, 0) + trim_partial_utf8(binary_part(content, idx, min(len, avail))) + end + + defp trim_partial_utf8(bin) do + if String.valid?(bin) or byte_size(bin) == 0 do + bin + else + trim_partial_utf8(binary_part(bin, 0, byte_size(bin) - 1)) + end + end + defp group_by_severity(findings) do findings |> Enum.group_by(& &1.severity) diff --git a/test/scanner_suppression_test.exs b/test/scanner_suppression_test.exs index 5ac7afdf..830775ba 100644 --- a/test/scanner_suppression_test.exs +++ b/test/scanner_suppression_test.exs @@ -113,6 +113,40 @@ defmodule Hypatia.ScannerSuppressionTest do end end + describe "suppressed?/4 — banned_language_file honours CicdRules path_allow_prefixes" do + test "documented TS interop carve-out (bindings/deno) is suppressed" do + # Regression: the hand-copied @banned_lang_ts_carveouts list had + # drifted to 3 of the ~12 documented carve-outs, so + # k9-svc/bindings/deno/mod.ts was flagged Critical (standards#382) + # despite its CLAUDE.md exemption. The rule now delegates to the + # CicdRules path_allow_prefixes single source of truth. + assert ScannerSuppression.suppressed?( + "/repo/k9-svc/bindings/deno/mod.ts", + "cicd_rules", + "banned_language_file", + repo_path: "/repo" + ) + end + + test "non-carve-out TypeScript is still banned" do + refute ScannerSuppression.suppressed?( + "/repo/src/app.ts", + "cicd_rules", + "banned_language_file", + repo_path: "/repo" + ) + end + + test "python under a TS carve-out path is still hard-refused" do + refute ScannerSuppression.suppressed?( + "/repo/bindings/deno/tool.py", + "cicd_rules", + "banned_language_file", + repo_path: "/repo" + ) + end + end + describe "context_safe_line?/2 — line-level exemptions for secret_detected" do test "GitHub Actions secrets reference is not a leak" do assert ScannerSuppression.context_safe_line?( diff --git a/test/workflow_hardening_test.exs b/test/workflow_hardening_test.exs index a304f292..37bab4cb 100644 --- a/test/workflow_hardening_test.exs +++ b/test/workflow_hardening_test.exs @@ -30,15 +30,16 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh001_template_injection/1" do test "flags github.event.pull_request.title in run:" do - repo = create_repo_with_workflow(""" - name: Bad - on: [pull_request] - jobs: - x: - runs-on: ubuntu-latest - steps: - - run: echo "Title is ${{ github.event.pull_request.title }}" - """) + repo = + create_repo_with_workflow(""" + name: Bad + on: [pull_request] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo "Title is ${{ github.event.pull_request.title }}" + """) findings = WorkflowHardening.wh001_template_injection(repo) assert length(findings) == 1 @@ -49,12 +50,13 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "ignores safe ${{ secrets.X }} references in run:" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - run: deploy --token=${{ secrets.DEPLOY_KEY }} - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - run: deploy --token=${{ secrets.DEPLOY_KEY }} + """) assert WorkflowHardening.wh001_template_injection(repo) == [] File.rm_rf!(repo) @@ -65,15 +67,16 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh002_excessive_permissions/1" do test "flags workflows without a permissions block" do - repo = create_repo_with_workflow(""" - name: NoPerm - on: [push] - jobs: - x: - runs-on: ubuntu-latest - steps: - - run: echo hi - """) + repo = + create_repo_with_workflow(""" + name: NoPerm + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi + """) findings = WorkflowHardening.wh002_excessive_permissions(repo) assert length(findings) == 1 @@ -82,14 +85,15 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "flags permissions: write-all as high" do - repo = create_repo_with_workflow(""" - permissions: write-all - jobs: - x: - runs-on: ubuntu-latest - steps: - - run: echo hi - """) + repo = + create_repo_with_workflow(""" + permissions: write-all + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi + """) findings = WorkflowHardening.wh002_excessive_permissions(repo) assert length(findings) == 1 @@ -98,14 +102,15 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "passes when permissions block exists" do - repo = create_repo_with_workflow(""" - permissions: - contents: read - jobs: - x: - steps: - - run: echo hi - """) + repo = + create_repo_with_workflow(""" + permissions: + contents: read + jobs: + x: + steps: + - run: echo hi + """) assert WorkflowHardening.wh002_excessive_permissions(repo) == [] File.rm_rf!(repo) @@ -116,13 +121,14 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh004_unpinned_uses/1" do test "flags @v4 tag-pinned action" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - uses: actions/checkout@v4 - - uses: foo/bar@main - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - uses: actions/checkout@v4 + - uses: foo/bar@main + """) findings = WorkflowHardening.wh004_unpinned_uses(repo) assert length(findings) == 2 @@ -130,28 +136,53 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "accepts 40-char SHA-pinned actions" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - uses: actions/checkout@ea165f8d65b6e75b540449e92b4886f43607fa02 - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - uses: actions/checkout@ea165f8d65b6e75b540449e92b4886f43607fa02 + """) assert WorkflowHardening.wh004_unpinned_uses(repo) == [] File.rm_rf!(repo) end test "ignores local actions and docker refs" do - repo = create_repo_with_workflow(""" + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - uses: ./local-action + - uses: docker://alpine:3.21 + """) + + assert WorkflowHardening.wh004_unpinned_uses(repo) == [] + File.rm_rf!(repo) + end + + test "multi-byte chars before the match do not shift the slug slice" do + # Regression: `Regex.scan(…, return: :index)` yields BYTE offsets, + # but the slug was extracted with String.slice/3 (grapheme-counted). + # An em-dash in an earlier comment shifted every later slice, + # mangling slugs ("tions/checkout@…") and — worse — breaking the + # 40-hex pinned exemption so SHA-pinned actions were reported as + # unpinned (observed on verisimdb#123 / hypatia#458 scan comments). + content = """ + # security — hardening notes ✓ jobs: x: steps: - - uses: ./local-action - - uses: docker://alpine:3.21 - """) + - uses: actions/checkout@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: erlef/setup-beam@v1 + """ - assert WorkflowHardening.wh004_unpinned_uses(repo) == [] - File.rm_rf!(repo) + findings = WorkflowHardening.wh004_scan_content("ci.yml", content) + + assert [finding] = findings + assert finding.detail.uses == "erlef/setup-beam@v1" + assert finding.detail.line == 6 end end @@ -159,16 +190,17 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh005_hardcoded_credentials/1" do test "flags literal password" do - repo = create_repo_with_workflow(""" - jobs: - x: - services: - postgres: - image: postgres:14 - credentials: - username: admin - password: super-secret-literal - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + services: + postgres: + image: postgres:14 + credentials: + username: admin + password: super-secret-literal + """) findings = WorkflowHardening.wh005_hardcoded_credentials(repo) assert length(findings) == 1 @@ -177,14 +209,15 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "accepts secrets.* references" do - repo = create_repo_with_workflow(""" - jobs: - x: - services: - postgres: - credentials: - password: ${{ secrets.PG_PASSWORD }} - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + services: + postgres: + credentials: + password: ${{ secrets.PG_PASSWORD }} + """) assert WorkflowHardening.wh005_hardcoded_credentials(repo) == [] File.rm_rf!(repo) @@ -195,12 +228,13 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh009_overprovisioned_secrets/1" do test "flags toJSON(secrets) usage" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - run: echo "${{ toJSON(secrets) }}" >> /tmp/dump - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - run: echo "${{ toJSON(secrets) }}" >> /tmp/dump + """) findings = WorkflowHardening.wh009_overprovisioned_secrets(repo) assert length(findings) == 1 @@ -213,12 +247,13 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh010_deprecated_workflow_commands/1" do test "flags ::set-output::" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - run: echo "::set-output name=foo::bar" - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - run: echo "::set-output name=foo::bar" + """) findings = WorkflowHardening.wh010_deprecated_workflow_commands(repo) assert length(findings) == 1 @@ -230,12 +265,13 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "wh011_curl_pipe_shell/1" do test "flags curl | sh pattern" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - run: curl -fsSL https://example.com/install | sh - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - run: curl -fsSL https://example.com/install | sh + """) findings = WorkflowHardening.wh011_curl_pipe_shell(repo) assert length(findings) == 1 @@ -244,12 +280,13 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do end test "ignores curl without pipe-to-shell" do - repo = create_repo_with_workflow(""" - jobs: - x: - steps: - - run: curl -fsSL -o /tmp/file https://example.com/file - """) + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - run: curl -fsSL -o /tmp/file https://example.com/file + """) assert WorkflowHardening.wh011_curl_pipe_shell(repo) == [] File.rm_rf!(repo) @@ -260,22 +297,23 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do describe "scan/1" do test "returns the standard shape on a clean workflow" do - repo = create_repo_with_workflow(""" - permissions: - contents: read - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - on: [pull_request] - jobs: - x: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@ea165f8d65b6e75b540449e92b4886f43607fa02 - """) + repo = + create_repo_with_workflow(""" + permissions: + contents: read + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + on: [pull_request] + jobs: + x: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@ea165f8d65b6e75b540449e92b4886f43607fa02 + """) result = WorkflowHardening.scan(repo) assert is_map(result)