From 385ece5d3b0e8debfeea02af3003137a7b2e96e2 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sun, 1 Nov 2026 14:51:32 +0800 Subject: [PATCH] [node-maintenance-repair/8.2] Consume frozen normal-audit evidence and seal the repair cumulative report Consume sealed normal-audit evidence and prior repair evidence through native commit locators, then seal one cumulative report before project validation. Preserve original advisory rows across attempts, use fresh before/after proof for current fixes, and reject stale, ambiguous or forged producer job/Check/artifact provenance. Keep the three-file preparation artifact and separate validation boundary. Read full native history, preserve pnpm policy line endings, and expose only the original sealed report and its provenance for the later publisher slice. Also fix the Node release-index parser to skip explicitly non-security prereleases such as `v27.0.0-alpha.0.0.0`. Installed runtimes still require exact stable versions and the latest stable security floor. Validation: 1,582 Python tests; Ruff lint/format; Pyrefly; Bash syntax; real pnpm 11.20.0 and 11.21.0 hook controls, frozen validation, failure/drift rejection and original artifact preservation. Complete authored production growth is 547/600 lines with no exclusions. Independent review, exact-head PR review/CI and hosted acceptance are recorded separately. This slice prepares cumulative evidence. Candidate publication remains slice 8.3. No source PR merge or infrastructure rollout is included. --- .github/workflows/node-repair.yml | 13 + docs/node-maintenance-operator.md | 29 + node/repair/action.yml | 15 + node/runtime/node_security.py | 14 +- node/runtime/repair.py | 505 +++++++++++++++- node/runtime/templates/dependency_audit.py | 10 +- node/tests/test_dependency_audit.py | 12 +- node/tests/test_node_security.py | 47 ++ node/tests/test_selected_repairs.py | 672 ++++++++++++++++++++- node/tests/verify_node_project.py | 30 +- repair-app/pilot/README.md | 16 + 11 files changed, 1347 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node-repair.yml b/.github/workflows/node-repair.yml index cd16681..974a69f 100644 --- a/.github/workflows/node-repair.yml +++ b/.github/workflows/node-repair.yml @@ -62,11 +62,14 @@ jobs: expected-base: ${{ inputs.expected-base }} selections: ${{ inputs.selections }} prepare: + name: Node repair preparation needs: guard runs-on: ubuntu-latest timeout-minutes: 50 permissions: contents: read + actions: read + checks: read env: npm_config_ignore_scripts: 'true' pnpm_config_ignore_scripts: 'true' @@ -79,6 +82,7 @@ jobs: with: ref: ${{ inputs.expected-head }} persist-credentials: false + fetch-depth: 0 - name: Prepare the selected candidate using trusted workflow code id: prepare uses: $/node/repair @@ -89,3 +93,12 @@ jobs: expected-head: ${{ inputs.expected-head }} expected-base: ${{ inputs.expected-base }} selections: ${{ inputs.selections }} + - name: Seal uploaded repair evidence + env: + ARTIFACT_ID: ${{ steps.prepare.outputs.candidate-artifact-id }} + ARCHIVE_SHA256: ${{ steps.prepare.outputs.candidate-archive-sha256 }} + CONTENT_SHA256: ${{ steps.prepare.outputs.candidate-report-sha256 }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ && "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ && "$CONTENT_SHA256" =~ ^[a-f0-9]{64}$ ]] + printf '::notice title=Node repair evidence v1::v1:%s:%s:%s\n' "$ARTIFACT_ID" "$ARCHIVE_SHA256" "$CONTENT_SHA256" diff --git a/docs/node-maintenance-operator.md b/docs/node-maintenance-operator.md index b2b338f..ac5d671 100644 --- a/docs/node-maintenance-operator.md +++ b/docs/node-maintenance-operator.md @@ -290,3 +290,32 @@ Before the cumulative repair consumer begins, verify a distinct-run hosted readback against the reviewed source and prove a sibling-job forgery rejects. Local API fixtures do not satisfy that hosted gate. Missing or expired history must never be reported as an empty original advisory list. + +## Cumulative repair preparation + +Selected repair preparation reads the nearest native producer receipt on the +PR's first-parent history. The initial receipt names the immutable normal audit; +later receipts name the original repair preparation artifact. Editable PR text +is never used as historical audit evidence. The consumer verifies the producer +job, its Check annotation, workflow revision, artifact ID and archive/report +hashes, then checks the exact project and dependency-state binding. + +Each sealed `result.md` retains the earlier canonical audit records and renders +one cumulative table. Earlier rows keep their recorded outcomes with a historical +evidence qualifier. A current unresolved finding takes precedence. An advisory +missing from today's audit is only "not observed in the current audit"; absence +is not evidence of withdrawal or a newly confirmed repair. The preparation +notice names only the currently selected repair. + +The preparation runner needs Contents, Actions and Checks read access. It emits +one `Node repair evidence v1` notice after uploading exactly `candidate.patch`, +`audit.json` and `result.md`. The notice binds the immutable artifact and report +hashes. Project validation runs on a separate runner and cannot replace this +artifact. Missing, expired, ambiguous or mismatched history stops preparation +before sealing. Inspect and finish the existing proposal, or explicitly close it +and create a new normal-audit proposal; do not reset a branch containing repairs. + +This checkpoint prepares cumulative evidence. It does not publish dependency +changes or authorize merging. The later publisher must retain the validated +repair marker and exact native `Node-Repair-*` locator trailers for another +read-only attempt; producer job outputs alone do not persist across runs. diff --git a/node/repair/action.yml b/node/repair/action.yml index 607d73d..7686664 100644 --- a/node/repair/action.yml +++ b/node/repair/action.yml @@ -26,6 +26,12 @@ outputs: candidate-artifact-id: description: Immutable preparation artifact ID from this run. value: ${{ steps.candidate.outputs.artifact-id }} + candidate-archive-sha256: + description: Preparation archive SHA256 returned by immutable upload. + value: ${{ steps.candidate.outputs.artifact-digest }} + candidate-report-sha256: + description: Exact sealed cumulative result SHA256. + value: ${{ steps.report-digest.outputs.sha256 }} runs: using: composite steps: @@ -88,12 +94,21 @@ runs: if: ${{ inputs.operation == 'prepare' }} shell: bash env: + GH_TOKEN: ${{ github.token }} REPAIR_REQUEST: ${{ inputs.request-id }} REPAIR_PR: ${{ inputs.pull-request }} REPAIR_HEAD: ${{ inputs.expected-head }} REPAIR_BASE: ${{ inputs.expected-base }} REPAIR_SELECTIONS: ${{ inputs.selections }} run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" prepare --artifact "$RUNNER_TEMP/node-repair-candidate" + - name: Hash the sealed cumulative report + id: report-digest + if: ${{ inputs.operation == 'prepare' }} + shell: bash + run: | + set -euo pipefail + digest=$(sha256sum "$RUNNER_TEMP/node-repair-candidate/result.md") + printf 'sha256=%s\n' "${digest%% *}" >> "$GITHUB_OUTPUT" - name: Seal the preparation artifact id: candidate if: ${{ inputs.operation == 'prepare' }} diff --git a/node/runtime/node_security.py b/node/runtime/node_security.py index 43a5186..f842cbb 100644 --- a/node/runtime/node_security.py +++ b/node/runtime/node_security.py @@ -63,7 +63,19 @@ def security_releases(major, *, read=None): raise ValueError( "Cannot verify Node runtime security: invalid release record" ) - version = version_tuple(release.get("version")) + value = release.get("version") + if ( + isinstance(value, str) + and re.fullmatch( + r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-" + r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*", + value, + ) + and release.get("security") is False + ): + continue + version = version_tuple(value) if version[0] != int(major): continue if type(release.get("security")) is not bool: diff --git a/node/runtime/repair.py b/node/runtime/repair.py index 9e73f5c..7e74470 100644 --- a/node/runtime/repair.py +++ b/node/runtime/repair.py @@ -1,7 +1,10 @@ """Prepare a sealed pnpm candidate and validate it without publication.""" import argparse +import base64 import copy +import hashlib +import html import json import os import re @@ -12,7 +15,9 @@ from collections import Counter from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from io import BytesIO from pathlib import Path +from zipfile import ZipFile import yaml @@ -72,8 +77,8 @@ def gh(path): return json.loads(subprocess.check_output(["gh", "api", path], text=True)) -def git(*args): - return subprocess.check_output(["git", *args], text=True) +def git(*args, **kwargs): + return subprocess.check_output(["git", *args], text=True, **kwargs) def guard(expected): @@ -641,6 +646,489 @@ def unchanged(): return after +REPAIR_HISTORY_TITLE = "Node repair evidence v1" +LOCATOR_KEYS = ("run", "job", "check", "artifact", "archive_sha256", "content_sha256") + + +def producer_names(run, repository, target, workflow_sha, leaf): + """Resolve job display paths from immutable caller/reusable workflow bytes.""" + expected, ordinary = [], [] + references = run.get("referenced_workflows", []) + + def visit(repo, path, revision, prefix, ancestors): + identity = (repo, path, revision) + if ( + identity in ancestors + or len(ancestors) >= 8 + or not re.fullmatch(r"\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml", path) + ): + raise ValueError("Unverifiable reusable workflow ancestry") + data = ENGINE["history_api"](repo, f"contents/{path}?ref={revision}") + if data.get("type") != "file" or data.get("encoding") != "base64": + raise ValueError("Missing immutable workflow source") + raw = base64.b64decode(data["content"].replace("\n", ""), validate=True) + if len(raw) > 1048576: + raise ValueError("Workflow source exceeds its bound") + jobs = yaml.safe_load(raw)["jobs"] + if f"{repo}/{path}" == target and revision == workflow_sha: + expected.append(prefix + leaf) + return + for key, job in jobs.items(): + name = job.get("name", key) + if not isinstance(name, str) or "${{" in name: + raise ValueError("Dynamic producer ancestry is unverifiable") + name = prefix + name + uses = job.get("uses") + if not uses: + ordinary.append(name) + continue + source, _, ref = uses.partition("@") + if source.startswith("./"): + source = repo + "/" + source[2:] + matches = [ + r + for r in references + if r.get("path", "").split("@")[0] == source + and r.get("sha") == revision + ] + else: + matches = [ + r + for r in references + if r.get("path", "").split("@")[0] == source + and ref + in { + r.get("sha"), + r.get("ref"), + (r.get("ref") or "") + .removeprefix("refs/heads/") + .removeprefix("refs/tags/"), + r.get("path", "").partition("@")[2], + } + ] + if not matches: + continue + if len(matches) != 1: + raise ValueError("Ambiguous reusable workflow source") + owner, project, nested = source.split("/", 2) + visit( + owner + "/" + project, + nested, + matches[0]["sha"], + name + " / ", + (*ancestors, identity), + ) + + visit(repository, run.get("path", "").split("@")[0], run["head_sha"], "", ()) + if ( + not expected + or len(set(expected)) != len(expected) + or set(expected) & set(ordinary) + ): + raise ValueError("Missing or colliding immutable producer identity") + return expected + + +def native_history(expected, directory, snapshot): + """Read the nearest first-parent producer receipt, never editable PR prose.""" + root = Path.cwd().resolve() + for commit in git("rev-list", "--first-parent", expected["head"]).splitlines(): + body = git("show", "-s", "--format=%B", commit) + trailers = git("interpret-trailers", "--parse", input=body) + repair = "Node-Repair: validated-selected-pins-v1" in trailers.splitlines() + prefix = "Node-Repair-" if repair else "Node-Audit-" + if not repair and "Node-Audit-" not in trailers: + continue + fields = {} + for line in trailers.splitlines(): + if line.startswith(prefix): + key, _, value = line[len(prefix) :].partition(": ") + key = key.lower().replace("-", "_") + if key in fields: + raise ValueError("Duplicate native evidence trailer") + fields[key] = value + workflow = fields.pop("workflow_sha", "") + if set(fields) != set(LOCATOR_KEYS) or not re.fullmatch( + r"[a-f0-9]{40}", workflow + ): + raise ValueError("Missing exact native evidence locator") + locator = dict(fields) + for key in LOCATOR_KEYS[:4]: + if not re.fullmatch(r"[1-9][0-9]*", fields[key]): + raise ValueError("Invalid native evidence identifier") + locator[key] = int(fields[key]) + source = git("rev-parse", commit + "^").strip() + project = directory.relative_to(root).as_posix() + state = ENGINE["dependency_state"](snapshot) + if repair: + return verify_repair_history( + locator, expected["repository"], source, project, state, workflow + ) + job = ENGINE["history_api"]( + expected["repository"], f"actions/jobs/{locator['job']}" + ) + name = job.get("name", "") + suffix = name.rsplit(" / ", 1)[-1] + if suffix not in { + f"Node audit evidence ({p})" + for p in ("dependency-audit", "node-main", "node-security", "node-upgrades") + }: + raise ValueError("Unexpected normal audit producer") + run = ENGINE["history_api"]( + expected["repository"], f"actions/runs/{locator['run']}" + ) + if name not in producer_names( + run, + expected["repository"], + "hydra-ecosystem/github-actions/.github/workflows/node-audit.yml", + workflow, + suffix, + ): + raise ValueError("Normal history belongs to a sibling job") + record = ENGINE["verify_history"]( + locator, + repository=expected["repository"], + source=source, + project=project, + candidate_state=state, + trusted_workflow_sha=workflow, + expected_job_name=name, + ) + validate_history(record) + return record + raise ValueError( + "No sealed historical evidence; review or replace the old proposal" + ) + + +def history_attempts(record): + return record.get("attempts", [record]) + + +def validate_history(record): + attempts = history_attempts(record) + if not isinstance(attempts, list) or not attempts: + raise ValueError("Invalid cumulative attempt inventory") + prior = None + for attempt in attempts: + if ( + not isinstance(attempt, dict) + or "attempts" in attempt + or type(attempt.get("version")) is not int + or attempt["version"] != 1 + or attempt.get("manager") != "pnpm" + or any(attempt.get(k) != record.get(k) for k in ("repository", "project")) + or not isinstance(attempt.get("package_report"), str) + or not isinstance(attempt.get("fixed_ids"), list) + or not re.fullmatch(r"[a-f0-9]{40}", attempt.get("source", "")) + ): + raise ValueError("Malformed historical attempt") + for key in ("before", "after"): + validate_audit(attempt[key]) + for key in ("before_state", "candidate_state"): + if set(attempt[key]) != { + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + } or any( + not re.fullmatch(r"[a-f0-9]{64}", value) + for value in attempt[key].values() + ): + raise ValueError("Invalid historical dependency state") + if prior is not None and attempt["before_state"] != prior: + raise ValueError("Broken historical dependency chain") + prior = attempt["candidate_state"] + if prior != record["candidate_state"] or attempts[-1]["source"] != record["source"]: + raise ValueError("Cumulative history does not describe its final attempt") + + +def verify_repair_history(locator, repository, source, project, state, workflow): + if ( + set(locator) != set(LOCATOR_KEYS) + or any(type(locator[k]) is not int or locator[k] <= 0 for k in LOCATOR_KEYS[:4]) + or any(not re.fullmatch(r"[a-f0-9]{64}", locator[k]) for k in LOCATOR_KEYS[4:]) + ): + raise ValueError("Invalid repair evidence locator") + + def api(suffix, **kw): + return ENGINE["history_api"](repository, suffix, **kw) + + run = api(f"actions/runs/{locator['run']}") + job = api(f"actions/jobs/{locator['job']}") + jobs = api( + f"actions/runs/{locator['run']}/attempts/{run['run_attempt']}/jobs?per_page=100" + ) + name = job.get("name", "") + expected_steps = [ + ("Prepare the selected candidate using trusted workflow code", "success"), + ("Seal uploaded repair evidence", "success"), + ] + refs = [ + r + for r in run.get("referenced_workflows", []) + if r.get("path", "").split("@")[0] + == "hydra-ecosystem/github-actions/.github/workflows/node-repair.yml" + ] + if ( + run.get("repository", {}).get("full_name") != repository + or run.get("event") != "workflow_dispatch" + or not re.fullmatch(r"[a-f0-9]{40}", run.get("head_sha", "")) + or len(refs) != 1 + or refs[0].get("sha") != workflow + or job.get("id") != locator["job"] + or job.get("run_id") != locator["run"] + or job.get("head_sha") != run["head_sha"] + or job.get("run_attempt") != run["run_attempt"] + or job.get("check_run_url") + != f"https://api.github.com/repos/{repository}/check-runs/{locator['check']}" + or job.get("status") != "completed" + or job.get("conclusion") != "success" + or jobs.get("total_count", 100) >= 100 + or [j.get("id") for j in jobs.get("jobs", []) if j.get("name") == name] + != [locator["job"]] + or [ + (s.get("name"), s.get("conclusion")) + for s in job.get("steps", []) + if s.get("name") in {n for n, _ in expected_steps} + ] + != expected_steps + ): + raise ValueError("Repair evidence does not identify the trusted producer") + if name not in producer_names( + run, + repository, + "hydra-ecosystem/github-actions/.github/workflows/node-repair.yml", + workflow, + "Node repair preparation", + ): + raise ValueError("Repair history belongs to a sibling job") + check = api(f"check-runs/{locator['check']}") + notices = [ + a + for a in api(f"check-runs/{locator['check']}/annotations?per_page=100") + if a.get("title") == REPAIR_HISTORY_TITLE + ] + message = f"v1:{locator['artifact']}:{locator['archive_sha256']}:{locator['content_sha256']}" + if ( + check.get("id") != locator["check"] + or check.get("head_sha") != run["head_sha"] + or check.get("app", {}).get("id") != 15368 + or check.get("details_url") != job.get("html_url") + or check.get("output", {}).get("annotations_count", 100) >= 100 + or len(notices) != 1 + or notices[0].get("annotation_level") != "notice" + or notices[0].get("message") != message + ): + raise ValueError("Missing or ambiguous repair producer seal") + artifact = api(f"actions/artifacts/{locator['artifact']}") + if ( + artifact.get("id") != locator["artifact"] + or artifact.get("expired") is not False + or type(artifact.get("size_in_bytes")) is not int + or not 0 < artifact["size_in_bytes"] <= 8388608 + or artifact.get("digest") != "sha256:" + locator["archive_sha256"] + or artifact.get("workflow_run", {}).get("id") != locator["run"] + or artifact.get("workflow_run", {}).get("head_sha") != run["head_sha"] + ): + raise ValueError("Repair artifact metadata differs from its producer seal") + archive = api(f"actions/artifacts/{locator['artifact']}/zip", binary=True) + if ( + len(archive) != artifact["size_in_bytes"] + or hashlib.sha256(archive).hexdigest() != locator["archive_sha256"] + ): + raise ValueError("Repair archive digest differs") + with ZipFile(BytesIO(archive)) as bundle: + entries = bundle.infolist() + if ( + len(entries) != 3 + or {e.filename for e in entries} + != {"candidate.patch", "audit.json", "result.md"} + or any(e.file_size > 1048576 for e in entries) + ): + raise ValueError("Unexpected repair artifact members or size") + patch, audit, report = ( + bundle.read(n) for n in ("candidate.patch", "audit.json", "result.md") + ) + if not patch or hashlib.sha256(report).hexdigest() != locator["content_sha256"]: + raise ValueError("Repair report digest or patch differs") + record = ENGINE["history_record"](report) + envelope = json.loads(audit, object_pairs_hook=json_object) + validate_envelope(envelope, envelope["request"], project) + validate_history(record) + if ( + ( + record["repository"], + record["source"], + record["project"], + record["candidate_state"], + ) + != (repository, source, project, state) + or envelope["request"]["repository"] != repository + or envelope["request"]["head"] != source + or record["attempts"][-1]["before"] != envelope["before"] + or record["attempts"][-1]["after"] != envelope["candidate"] + ): + raise ValueError("Repair history belongs to different source or dependencies") + return record + + +def report_rows(report): + """Consume the two existing shared-renderer table formats.""" + rows = [] + if "" in report: + for row in re.findall(r"(.*?)", report, re.S): + cells = re.findall(r"", row, re.S) + if len(cells) != 6: + continue + cells = [ + re.sub(r'(.*?)', r"[\2](\1)", c) for c in cells + ] + cells = [ + html.unescape(re.sub(r"(.*?)", r"`\1`", c)) for c in cells + ] + rows.append(cells) + else: + rows = [ + line[2:-2].split(" | ") + for line in report.splitlines() + if line.startswith("| `") and line.endswith(" |") + ] + if any(len(row) != 6 or not re.fullmatch(r"`[^`\n|]+`", row[0]) for row in rows): + raise ValueError("Unrecognized historical report row") + return rows + + +def cumulative_report( + history, expected, directory, selected, snapshot, candidate, before, after, fixed +): + project = directory.relative_to(Path.cwd().resolve()).as_posix() + state = ENGINE["dependency_state"](snapshot) + validate_history(history) + if (history["repository"], history["project"], history["candidate_state"]) != ( + expected["repository"], + project, + state, + ): + raise ValueError("Historical dependency state differs from the selected head") + current = dict( + version=1, + repository=expected["repository"], + source=expected["head"], + project=project, + manager="pnpm", + before_state=state, + candidate_state=ENGINE["dependency_state"](candidate), + before=before, + after=after, + fixed_ids=sorted(fixed), + package_report=ENGINE["format_audit_summary"]( + before, after, "pnpm", fixed_ids=fixed, locks=(snapshot[3], candidate[3]) + ), + ) + attempts = history_attempts(history) + [current] + record = dict( + version=1, + repository=expected["repository"], + source=expected["head"], + project=project, + candidate_state=current["candidate_state"], + attempts=attempts, + ) + validate_history(record) + unresolved = { + (identity, name) for identity, name, _ in ENGINE["audit_entries"](after, "pnpm") + } + observed = unresolved | { + (identity, name) + for identity, name, _ in ENGINE["audit_entries"](before, "pnpm") + } + inventory, rows = set(), [] + for index, attempt in enumerate(attempts): + previous = index < len(attempts) - 1 + entries = ENGINE["audit_entries"](attempt["before"], "pnpm") + ENGINE[ + "audit_entries" + ](attempt["after"], "pnpm") + inventory.update((identity, name) for identity, name, _ in entries) + attempt_rows = report_rows(attempt["package_report"]) + if {name for _, name, _ in entries} - {row[0][1:-1] for row in attempt_rows}: + raise ValueError("Historical report omits advisory rows") + for row in attempt_rows: + name = row[0][1:-1] + if previous: + pairs = { + (identity, package) + for identity, package, _ in entries + if package == name + } + missing, pending = pairs - observed, pairs & unresolved + qualifier = "Current unresolved. " if pending else "" + if pending: + qualifier += ( + "Advisories: " + + ", ".join( + f"`{identity.rsplit('/', 1)[-1]}`" + for identity, _ in sorted(pending) + ) + + ". " + ) + if missing or not pairs: + qualifier += "Not observed in the current audit. " + if missing: + qualifier += ( + "Advisories: " + + ", ".join( + f"`{identity.rsplit('/', 1)[-1]}`" + for identity, _ in sorted(missing) + ) + + ". " + ) + if pairs & observed - unresolved: + qualifier += "See current attempt for current evidence. " + row[4] = ( + f"Historical evidence (attempt {index + 1}). " + qualifier + row[4] + ) + else: + row[4] = "Current attempt. " + row[4] + links = dict( + (detail["url"], detail.get("title", identity)) + for identity, package, detail in entries + if package == name + ) + if any(not re.fullmatch(r"https://[^\s<>()\[\]|`]+", url) for url in links): + raise ValueError("Unsafe historical advisory link") + row[5] += " " + " ".join( + f"[{url.rsplit('/', 1)[-1]}]({url})" + for url in links + if f"]({url})" not in row[5] + ) + rows.append( + "| " + + " | ".join( + c.replace("\n", " ").replace("\r", " ").replace("|", "|") + for c in row + ) + + " |" + ) + report = "## Package security candidate\n\nCandidate preparation succeeded. Project validation has not run. No dependency change has been published.\n\n" + report += ( + "Selected repair attempt: " + + ", ".join( + f"`{i['name']} {i['from']} → {i['to']}`" for i in expected["selections"] + ) + + ".\n\n" + ) + report += f"Cumulative evidence: {len(inventory)} advisory/package pairs across {len(attempts)} attempts. Historical outcomes are not fresh fix claims.\n\n" + report += ( + "| Package | Update | Severity | Advisories | Action / blocker | Security evidence |\n| --- | --- | --- | --- | --- | --- |\n" + + "\n".join(rows) + ) + encoded = base64.b64encode(ENGINE["canonical_history"](record)).decode("ascii") + report += f"\n\n\n" + if len(encoded) > 524288 or len(report.encode()) > 1048576: + raise ValueError("Cumulative history exceeds its bound; no artifact sealed") + return report + + def prepare(expected, artifact): with suppress_hooks(): return prepare_candidate(expected, artifact) @@ -654,6 +1142,7 @@ def prepare_candidate(expected, artifact): ): raise ValueError("Preparation requires a clean checkout of the expected head") directory, selected, snapshot = project_context() + history = native_history(expected, directory, snapshot) deadline = time.monotonic() + 40 * 60 before = ENGINE["audit"](directory, deadline=deadline) items = expected["selections"] @@ -661,7 +1150,9 @@ def prepare_candidate(expected, artifact): directory, before, snapshot, items, deadline, selected["minimumReleaseAge"] ) workspace = directory / "pnpm-workspace.yaml" - workspace.write_text(update_policy(workspace.read_text(), items)) + workspace.write_bytes( + update_policy(workspace.read_bytes().decode("utf-8"), items).encode("utf-8") + ) if ENGINE["run"](directory, *CANDIDATE_INSTALL, deadline=deadline).returncode: raise ValueError("Candidate lockfile resolution failed") candidate = ENGINE["valid_snapshot"](directory) @@ -703,12 +1194,8 @@ def prepare_candidate(expected, artifact): candidate=after, ) validate_envelope(envelope, expected, envelope["project_directory"]) - report = ( - "## Package security candidate\n\n" - "Candidate preparation succeeded. Project validation has not run. No dependency change has been published.\n\n" - + ENGINE["format_audit_summary"]( - proof, after, "pnpm", fixed_ids=fixed, locks=(snapshot[3], candidate[3]) - ) + report = cumulative_report( + history, expected, directory, selected, snapshot, candidate, proof, after, fixed ) if artifact.resolve() != artifact or artifact.is_relative_to(root): raise ValueError("Candidate artifacts must be outside the project checkout") diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index 85811ca..6633fb3 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -9,6 +9,7 @@ import os import re import runpy +import shutil import subprocess import time import zipfile @@ -468,9 +469,14 @@ def run( return subprocess.CompletedProcess( command, 124, "", "Dependency audit time budget exhausted." ) - # GNU timeout owns the process group and kills npm and lifecycle/build children. + # Some runners name GNU timeout gnutimeout; use its process-group kill. result = subprocess.run( - ["timeout", "--signal=KILL", f"{timeout}s", *command], + [ + shutil.which("gnutimeout") or "timeout", + "--signal=KILL", + f"{timeout}s", + *command, + ], cwd=directory, capture_output=True, text=True, diff --git a/node/tests/test_dependency_audit.py b/node/tests/test_dependency_audit.py index 6eb0f9f..66a8990 100644 --- a/node/tests/test_dependency_audit.py +++ b/node/tests/test_dependency_audit.py @@ -457,8 +457,16 @@ def test_shared_time_budget_stops_commands_and_writes_partial_report( assert "time budget was exhausted" in report -def test_command_timeout_respects_remaining_shared_budget(npm_project, monkeypatch): +@pytest.mark.parametrize("gnu_timeout", [None, "/usr/bin/gnutimeout"]) +def test_command_timeout_respects_remaining_shared_budget( + npm_project, monkeypatch, gnu_timeout +): observed = [] + monkeypatch.setattr( + audit_module["shutil"], + "which", + lambda name: gnu_timeout if name == "gnutimeout" else None, + ) def invoke(command, **kwargs): observed.append(command[:4]) @@ -467,7 +475,7 @@ def invoke(command, **kwargs): monkeypatch.setattr(subprocess, "run", invoke) monkeypatch.setattr(audit_module["time"], "monotonic", lambda: 100) run(npm_project, "ci", deadline=130) - assert observed == [["timeout", "--signal=KILL", "30s", "npm"]] + assert observed == [[gnu_timeout or "timeout", "--signal=KILL", "30s", "npm"]] def test_timeout_terminates_child_before_it_can_modify_files(npm_project, monkeypatch): diff --git a/node/tests/test_node_security.py b/node/tests/test_node_security.py index 4fe71f1..dd95888 100644 --- a/node/tests/test_node_security.py +++ b/node/tests/test_node_security.py @@ -42,6 +42,53 @@ def test_a_new_security_release_changes_the_floor_without_a_workflow_update(): MODULE["verify_node_security"]("24.23.0", read=lambda: json.dumps(releases)) +@pytest.mark.parametrize("version", ["v27.0.0-alpha.0.0.0", "v24.24.0-rc.1"]) +def test_non_security_prereleases_do_not_break_or_change_the_stable_security_floor( + version, +): + releases = [{"version": version, "security": False}, *RELEASES] + + def read(): + return json.dumps(releases) + + assert MODULE["verify_node_security"]("24.22.1", read=read) == "24.22.1" + with pytest.raises(ValueError, match="older than the latest.*security release"): + MODULE["verify_node_security"]("24.22.0", read=read) + + +@pytest.mark.parametrize("security", [True, "false", 0, None]) +def test_prerelease_with_missing_or_non_false_security_flag_fails(security): + releases = [{"version": "v27.0.0-alpha.0.0.0", "security": security}, *RELEASES] + with pytest.raises(ValueError): + MODULE["verify_node_security"]("24.22.1", read=lambda: json.dumps(releases)) + + +@pytest.mark.parametrize( + "version", + [ + "broken", + "v27.0.0-", + "v27.0.0-alpha..0", + "v027.0.0-alpha.0", + "v27.00.0-rc.1", + "v27.0.00-rc.1", + "v27.0.0-01", + "v27.0.0-alpha.01", + ], +) +def test_malformed_non_security_release_still_fails(version): + releases = [{"version": version, "security": False}, *RELEASES] + with pytest.raises(ValueError): + MODULE["verify_node_security"]("24.22.1", read=lambda: json.dumps(releases)) + + +def test_installed_prerelease_is_not_accepted_as_a_stable_runtime(): + with pytest.raises(ValueError, match="exact stable release"): + MODULE["verify_node_security"]( + "24.24.0-rc.1", read=lambda: json.dumps(RELEASES) + ) + + @pytest.mark.parametrize( "data", [ diff --git a/node/tests/test_selected_repairs.py b/node/tests/test_selected_repairs.py index 29e019a..366aa29 100644 --- a/node/tests/test_selected_repairs.py +++ b/node/tests/test_selected_repairs.py @@ -815,6 +815,7 @@ def test_preparation_seals_only_three_members_without_validation( lambda: (project, {"minimumReleaseAge": 14400}, before), ) monkeypatch.setitem(module, "verified_targets", lambda *a, **kw: repair_audit()) + monkeypatch.setitem(module, "native_history", lambda *a: normal_history(before)) monkeypatch.setitem(module, "export_patch", lambda paths: b"sealed patch\n") status = [] @@ -917,6 +918,7 @@ def test_repair_workflow_keeps_preparation_separate_from_read_only_validation(): assert checkout["with"] == { "ref": "${{ inputs.expected-head }}", "persist-credentials": False, + "fetch-depth": 0, } assert "candidate-artifact-id" in prepare["outputs"] upload = next( @@ -929,7 +931,9 @@ def test_repair_workflow_keeps_preparation_separate_from_read_only_validation(): ] validation = workflow["jobs"]["validate"] assert validation["needs"] == "prepare" - assert validation["steps"][0]["with"] == checkout["with"] + assert validation["steps"][0]["with"] == { + k: v for k, v in checkout["with"].items() if k != "fetch-depth" + } assert ( validation["steps"][1]["with"]["candidate-artifact-id"] == "${{ needs.prepare.outputs.candidate-artifact-id }}" @@ -1217,3 +1221,669 @@ def audit(*args, **kwargs): assert not (evidence / "candidate.patch").exists() assert {p.name: p.read_bytes() for p in original.iterdir()} == original_bytes assert REPAIR["git"]("rev-parse", "HEAD").strip() == expected["head"] + + +def normal_history(snapshot): + audit = repair_audit() + return dict( + version=1, + repository=repair_request()["repository"], + source="c" * 40, + project=".", + manager="pnpm", + before_state=ENGINE["dependency_state"](snapshot), + candidate_state=ENGINE["dependency_state"](snapshot), + before=audit, + after=audit, + fixed_ids=[], + package_report=ENGINE["format_audit_summary"]( + audit, audit, "pnpm", fixed_ids=set(), locks=(snapshot[3], snapshot[3]) + ), + ) + + +@pytest.fixture +def cumulative_candidate(tmp_path, monkeypatch): + root = tmp_path / "source" + root.mkdir() + monkeypatch.chdir(root) + before = repair_files(root) + initial = normal_history(before) + after = repair_files(root, ITEM["to"]) + proof = repair_audit() + # The original advisory disappeared from the registry. Only fresh evidence + # for another advisory can establish this attempt's selected repair. + for detail in ( + proof["advisories"]["1"], + proof["vulnerabilities"][ITEM["name"]]["via"][0], + ): + detail["url"] = "https://github.com/advisories/GHSA-fresh-one1-test" + report = REPAIR["cumulative_report"]( + initial, + repair_request(), + root, + {}, + before, + after, + proof, + empty_audit(), + {"https://github.com/advisories/GHSA-fresh-one1-test"}, + ) + return root, before, after, initial, proof, report + + +def test_three_attempt_history_retains_original_inventory(cumulative_candidate): + root, before, after, initial, proof, report = cumulative_candidate + record = ENGINE["history_record"](report.encode()) + assert record["attempts"][0] == initial + original_url = repair_audit()["advisories"]["1"]["url"] + assert original_url in report + assert "Historical evidence (attempt 1)" in report + assert "Cumulative evidence: 2 advisory/package pairs across 2 attempts" in report + assert report.count("| Package |") == 1 + final = repair_files(root, "2.14.3") + fresh = repair_audit() + raw = fresh["advisories"]["1"] + raw["findings"][0]["version"] = ITEM["to"] + detail = fresh["vulnerabilities"][ITEM["name"]]["via"][0] + detail["nodes"] = [ITEM["name"] + "@" + ITEM["to"]] + detail["_verifiedVulnerableVersions"] = [ITEM["to"]] + detail["_verifiedRepairVersions"] = ["2.14.3"] + for item in (raw, detail): + item["url"] = "https://github.com/advisories/GHSA-fresh-two2-test" + request = dict( + repair_request(), + head="d" * 40, + selections=[dict(ITEM, **{"from": ITEM["to"], "to": "2.14.3"})], + ) + third = REPAIR["cumulative_report"]( + record, request, root, {}, after, final, fresh, empty_audit(), {detail["url"]} + ) + third_record = ENGINE["history_record"](third.encode()) + assert third_record["attempts"][:2] == record["attempts"] + assert len(third_record["attempts"]) == 3 + assert original_url in third and raw["url"] in third + assert "Cumulative evidence: 3 advisory/package pairs across 3 attempts" in third + assert third.count("| Package |") == 1 + assert third.count("| `launch-editor`") == 3 + assert "Historical evidence (attempt 2)" in third + assert "Current attempt" in third + # Original normalized and raw CVE evidence is carried, not regenerated. + assert third_record["attempts"][0]["before"] == initial["before"] + + +def test_history_absence_is_not_a_new_fix_claim(cumulative_candidate): + root, before, after, initial, proof, _ = cumulative_candidate + initial["before"] = initial["after"] = empty_audit() + # Preserve a historical upgraded package row even when both fresh audits + # omit that package entirely. + initial["package_report"] = ( + "| `earlier` | `1.0.0` → `1.0.1` | High | 1 fixed | ✅ Fix proposed. Resolved. | [CVE-2026-1111](https://example.com/advisory) |" + ) + report = REPAIR["cumulative_report"]( + initial, repair_request(), root, {}, before, after, proof, empty_audit(), set() + ) + assert ( + "Historical evidence (attempt 1). Not observed in the current audit." in report + ) + assert "withdrawn" not in report.lower() + # The payload keeps the full prior row independently of a fresh audit. + assert ENGINE["history_record"](report.encode())["attempts"][0] == initial + + +def test_current_unresolved_takes_precedence_over_historical_success( + cumulative_candidate, +): + root, before, after, initial, proof, _ = cumulative_candidate + initial["package_report"] = initial["package_report"].replace( + "🟡 Needs review.", "✅ Fix proposed. Resolved." + ) + report = REPAIR["cumulative_report"]( + initial, + repair_request(), + root, + {}, + before, + after, + repair_audit(), + repair_audit(), + set(), + ) + assert "Historical evidence (attempt 1). Current unresolved." in report + assert "Current attempt." in report + + +@pytest.mark.parametrize( + "problem", ["chain", "identity", "missing-rows", "bad-audit", "oversized"] +) +def test_cumulative_seal_rejects_incomplete_history(cumulative_candidate, problem): + root, before, after, initial, proof, report = cumulative_candidate + if problem == "chain": + initial["candidate_state"]["pnpm-lock.yaml"] = "0" * 64 + elif problem == "identity": + initial["repository"] = "different/repo" + elif problem == "missing-rows": + initial["package_report"] = "No findings" + elif problem == "bad-audit": + initial["before"] = "unavailable" + else: + initial["package_report"] += "\n" + "x" * 524288 + with pytest.raises(ValueError): + REPAIR["cumulative_report"]( + initial, + repair_request(), + root, + {}, + before, + after, + proof, + empty_audit(), + set(), + ) + + +@pytest.fixture +def repair_history_provider(cumulative_candidate, monkeypatch): + import hashlib + from io import BytesIO + from zipfile import ZipFile + + root, before, after, initial, proof, report = cumulative_candidate + record = ENGINE["history_record"](report.encode()) + payload = dict(envelope(), before=proof) + stream = BytesIO() + with ZipFile(stream, "w") as bundle: + for name, data in { + "candidate.patch": b"actual sealed patch", + "audit.json": json.dumps(payload).encode(), + "result.md": report.encode(), + }.items(): + bundle.writestr(name, data) + archive = stream.getvalue() + locator = dict( + run=10, + job=20, + check=30, + artifact=40, + archive_sha256=hashlib.sha256(archive).hexdigest(), + content_sha256=hashlib.sha256(report.encode()).hexdigest(), + ) + repo = repair_request()["repository"] + trigger = "e" * 40 + workflow = "f" * 40 + name = "repair / Node repair preparation" + run = dict( + repository={"full_name": repo}, + path=".github/workflows/fixture.yml", + head_sha=trigger, + event="workflow_dispatch", + run_attempt=1, + referenced_workflows=[ + dict( + path="hydra-ecosystem/github-actions/.github/workflows/node-repair.yml@experiment/node-maintenance-repair", + sha=workflow, + ) + ], + ) + job = dict( + id=20, + run_id=10, + run_attempt=1, + head_sha=trigger, + name=name, + check_run_url=f"https://api.github.com/repos/{repo}/check-runs/30", + status="completed", + conclusion="success", + html_url=f"https://github.com/{repo}/actions/runs/10/job/20", + steps=[ + {"name": s, "conclusion": "success"} + for s in ( + "Prepare the selected candidate using trusted workflow code", + "Seal uploaded repair evidence", + ) + ], + ) + check = dict( + id=30, + head_sha=trigger, + app={"id": 15368}, + details_url=job["html_url"], + output={"annotations_count": 1}, + ) + notice = dict( + title=REPAIR["REPAIR_HISTORY_TITLE"], + annotation_level="notice", + message=f"v1:40:{locator['archive_sha256']}:{locator['content_sha256']}", + ) + metadata = dict( + id=40, + expired=False, + size_in_bytes=len(archive), + digest="sha256:" + locator["archive_sha256"], + workflow_run={"id": 10, "head_sha": trigger}, + ) + data = { + "actions/runs/10": run, + "actions/jobs/20": job, + "actions/runs/10/attempts/1/jobs?per_page=100": { + "total_count": 1, + "jobs": [job], + }, + "check-runs/30": check, + "check-runs/30/annotations?per_page=100": [notice], + "actions/artifacts/40": metadata, + "actions/artifacts/40/zip": archive, + f"contents/.github/workflows/fixture.yml?ref={trigger}": encoded_workflow( + {"repair": {"uses": run["referenced_workflows"][0]["path"]}} + ), + f"contents/.github/workflows/node-repair.yml?ref={workflow}": encoded_workflow( + {"prepare": {"name": "Node repair preparation", "steps": []}} + ), + } + + def api(repository, suffix, **kwargs): + assert repository in (repo, "hydra-ecosystem/github-actions") + return data[suffix] + + monkeypatch.setitem( + REPAIR["verify_repair_history"].__globals__["ENGINE"], "history_api", api + ) + return ( + locator, + repo, + record["source"], + ".", + record["candidate_state"], + workflow, + data, + record, + ) + + +@pytest.mark.parametrize( + "problem", + [ + None, + "sibling", + "forged-check", + "duplicate-notice", + "notice-level", + "notice-hash", + "wrong-workflow", + "job-failed", + "step-order", + "job-attempt", + "job-head", + "check-app", + "check-url", + "expired", + "artifact-head", + "artifact-digest", + "archive", + "source", + "dependency-state", + ], +) +def test_distinct_run_repair_history_requires_exact_producer( + repair_history_provider, problem +): + locator, repo, source, project, state, workflow, data, record = ( + repair_history_provider + ) + if problem == "sibling": + data["actions/jobs/20"]["name"] = "sibling / copied bytes" + elif problem == "forged-check": + data["actions/jobs/20"]["check_run_url"] = ( + f"https://api.github.com/repos/{repo}/check-runs/99" + ) + elif problem == "duplicate-notice": + data["check-runs/30/annotations?per_page=100"] *= 2 + elif problem == "notice-level": + data["check-runs/30/annotations?per_page=100"][0]["annotation_level"] = ( + "warning" + ) + elif problem == "notice-hash": + data["check-runs/30/annotations?per_page=100"][0]["message"] += "x" + elif problem == "wrong-workflow": + data["actions/runs/10"]["referenced_workflows"][0]["sha"] = "0" * 40 + elif problem == "job-failed": + data["actions/jobs/20"]["conclusion"] = "failure" + elif problem == "step-order": + data["actions/jobs/20"]["steps"].reverse() + elif problem == "job-attempt": + data["actions/jobs/20"]["run_attempt"] = 2 + elif problem == "job-head": + data["actions/jobs/20"]["head_sha"] = source + elif problem == "check-app": + data["check-runs/30"]["app"]["id"] = 1 + elif problem == "check-url": + data["check-runs/30"]["details_url"] += "-forged" + elif problem == "expired": + data["actions/artifacts/40"]["expired"] = True + elif problem == "artifact-head": + data["actions/artifacts/40"]["workflow_run"]["head_sha"] = source + elif problem == "artifact-digest": + data["actions/artifacts/40"]["digest"] = "sha256:" + "0" * 64 + elif problem == "archive": + data["actions/artifacts/40/zip"] += b"tampered" + elif problem == "source": + source = "0" * 40 + elif problem == "dependency-state": + state = dict(state, **{"pnpm-lock.yaml": "0" * 64}) + if problem: + with pytest.raises(ValueError): + REPAIR["verify_repair_history"]( + locator, repo, source, project, state, workflow + ) + else: + assert ( + REPAIR["verify_repair_history"]( + locator, repo, source, project, state, workflow + ) + == record + ) + + +def test_native_history_uses_commit_trailers_and_rejects_missing_receipt( + sealed_checkout, monkeypatch +): + root, _, snapshot, expected = sealed_checkout + with pytest.raises(ValueError, match="No sealed historical evidence"): + REPAIR["native_history"](expected, root, snapshot) + locator = dict( + run=1, + job=2, + check=3, + artifact=4, + archive_sha256="a" * 64, + content_sha256="b" * 64, + ) + trailers = ( + "\n".join(f"Node-Audit-{k.replace('_', '-')}: {v}" for k, v in locator.items()) + + "\nNode-Audit-Workflow-SHA: " + + "f" * 40 + ) + subprocess.run( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "--allow-empty", + "-qm", + "Normal proposal\n\n" + trailers, + ], + check=True, + ) + expected = dict(expected, head=REPAIR["git"]("rev-parse", "HEAD").strip()) + module = REPAIR["native_history"].__globals__["ENGINE"] + monkeypatch.setitem( + module, + "history_api", + lambda *a, **kw: {"name": "audit / Node audit evidence (node-security)"}, + ) + monkeypatch.setitem( + REPAIR["native_history"].__globals__, + "producer_names", + lambda *args: ["audit / Node audit evidence (node-security)"], + ) + calls = [] + + def verify(actual, **kwargs): + calls.append((actual, kwargs)) + return normal_history(snapshot) + + monkeypatch.setitem(module, "verify_history", verify) + REPAIR["native_history"](expected, root, snapshot) + assert calls[0][0] == locator + assert calls[0][1]["candidate_state"] == ENGINE["dependency_state"](snapshot) + assert calls[0][1]["source"] == REPAIR["git"]("rev-parse", "HEAD^").strip() + + +def test_historical_html_rows_preserve_renderer_evidence_and_versions(): + report = '
(.*?)
Package
pkg1.0.0 → 1.0.1High1 fixed✅ Fix proposed. Resolved.GHSA-original
pin assessment
' + rows = REPAIR["report_rows"](report) + assert rows == [ + [ + "`pkg`", + "`1.0.0` → `1.0.1`", + "High", + "1 fixed", + "✅ Fix proposed. Resolved.", + "[GHSA-original](https://example.com/GHSA-original)", + ] + ] + assert REPAIR["report_rows"]( + "| `pkg` | one | High | 1 | action | evidence |\r\n" + ) == [["`pkg`", "one", "High", "1", "action", "evidence"]] + + +@pytest.mark.parametrize("html_origin", [False, True]) +def test_cumulative_table_keeps_range_pipes_inside_six_cells( + cumulative_candidate, html_origin +): + root, before, after, initial, proof, _ = cumulative_candidate + audit = initial["before"] + affected = ">=1.0.0 <2.0.0 || >=3.0.0 <4.0.0" + audit["advisories"]["1"]["vulnerable_versions"] = affected + audit["vulnerabilities"][ITEM["name"]]["via"][0]["range"] = affected + initial["after"] = empty_audit() + report = ENGINE["format_audit_summary"]( + audit, + initial["after"], + "pnpm", + fixed_ids=set(), + locks=(before[3], before[3]), + repair_options=[ITEM] if html_origin else None, + ) + assert ("" in report) == html_origin + initial["package_report"] = report + result = REPAIR["cumulative_report"]( + initial, repair_request(), root, {}, before, after, proof, empty_audit(), set() + ) + rows = [line for line in result.splitlines() if line.startswith("| `")] + assert rows and all(len(line[2:-2].split(" | ")) == 6 for line in rows) + historical = next(line for line in rows if "Historical evidence" in line) + assert "||" in historical + assert len(historical.split("|")) == 8 + cells = historical[2:-2].split(" | ") + assert "||" in cells[4] + assert audit["advisories"]["1"]["url"] in cells[5] + + +def test_documented_caller_covers_reusable_workflow_read_permissions(): + text = (ROOT / "repair-app/pilot/README.md").read_text() + caller = yaml.safe_load(text.split("```yaml\n", 1)[1].split("```", 1)[0]) + workflow = yaml.safe_load((ROOT / ".github/workflows/node-repair.yml").read_text()) + job = caller["jobs"]["candidate"] + for reusable in workflow["jobs"].values(): + for scope, level in reusable["permissions"].items(): + assert level == "read" and job["permissions"][scope] == level + assert set(job["with"]) == set(workflow["on"]["workflow_call"]["inputs"]) + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_selected_policy_edit_preserves_original_line_endings(newline): + original = ( + "packages:\n - .\noverrides:\n launch-editor: 2.14.1 # retained comment\n" + ).replace("\n", newline) + updated = REPAIR["update_policy"](original, [ITEM]) + assert updated == original.replace("2.14.1", '"2.14.2"') + + +def test_preparation_upload_notice_is_after_immutable_upload(): + workflow = yaml.safe_load((ROOT / ".github/workflows/node-repair.yml").read_text()) + action = yaml.safe_load((ROOT / "node/repair/action.yml").read_text()) + producer = workflow["jobs"]["prepare"] + assert producer["name"] == "Node repair preparation" + assert producer["permissions"] == { + "contents": "read", + "actions": "read", + "checks": "read", + } + seal = producer["steps"][-1] + assert seal["name"] == "Seal uploaded repair evidence" + assert "Node repair evidence v1" in seal["run"] + assert ( + seal["env"]["ARTIFACT_ID"] + == "${{ steps.prepare.outputs.candidate-artifact-id }}" + ) + assert ( + action["outputs"]["candidate-archive-sha256"]["value"] + == "${{ steps.candidate.outputs.artifact-digest }}" + ) + steps = action["runs"]["steps"] + assert next( + i for i, s in enumerate(steps) if s.get("id") == "report-digest" + ) < next(i for i, s in enumerate(steps) if s.get("id") == "candidate") + assert workflow["jobs"]["validate"]["permissions"] == {"contents": "read"} + + +def encoded_workflow(jobs): + import base64 + + return { + "type": "file", + "encoding": "base64", + "content": base64.b64encode(yaml.safe_dump({"jobs": jobs}).encode()).decode(), + } + + +@pytest.mark.parametrize( + "copied_name", + ["attacker / Node repair preparation", "repair / Node repair preparation"], +) +def test_sibling_cannot_copy_producer_names_and_steps( + repair_history_provider, copied_name +): + locator, repo, source, project, state, workflow, data, record = ( + repair_history_provider + ) + candidate = data["actions/jobs/20"] + genuine = dict(candidate, id=21) + candidate["name"] = copied_name + data["actions/runs/10/attempts/1/jobs?per_page=100"] = { + "total_count": 2, + "jobs": [candidate, genuine], + } + data["contents/.github/workflows/fixture.yml?ref=" + "e" * 40] = encoded_workflow( + { + "repair": { + "uses": data["actions/runs/10"]["referenced_workflows"][0]["path"] + }, + "attacker": {"name": copied_name, "steps": []}, + } + ) + with pytest.raises(ValueError): + REPAIR["verify_repair_history"](locator, repo, source, project, state, workflow) + + +def test_same_package_history_qualifies_each_advisory_identity(cumulative_candidate): + root, before, after, initial, proof, report = cumulative_candidate + old_id = repair_audit()["advisories"]["1"]["url"].rsplit("/", 1)[-1] + old_row = next( + line + for line in report.splitlines() + if "Historical evidence (attempt 1)" in line + ) + assert "Not observed in the current audit." in old_row + assert f"Advisories: `{old_id}`" in old_row + assert "Current unresolved." not in old_row + # Another current advisory for the same package is not old-advisory proof. + pending = REPAIR["cumulative_report"]( + initial, repair_request(), root, {}, before, after, proof, proof, set() + ) + old_row = next( + line + for line in pending.splitlines() + if "Historical evidence (attempt 1)" in line + ) + assert "Not observed in the current audit." in old_row + assert "Current unresolved." not in old_row + # Reintroduction of the exact old identity is current unresolved evidence. + old = repair_audit() + reintroduced = REPAIR["cumulative_report"]( + initial, repair_request(), root, {}, before, after, old, old, set() + ) + old_row = next( + line + for line in reintroduced.splitlines() + if "Historical evidence (attempt 1)" in line + ) + assert "Current unresolved." in old_row and "Not observed" not in old_row + + +@pytest.mark.parametrize("extra_commit", [False, True]) +def test_full_checkout_resolves_native_receipt_at_or_below_head( + sealed_checkout, monkeypatch, tmp_path, extra_commit +): + root, _, snapshot, expected = sealed_checkout + trailers = ( + "\n".join( + f"Node-Audit-{k.replace('_', '-')}: {v}" + for k, v in dict( + run=1, + job=2, + check=3, + artifact=4, + archive_sha256="a" * 64, + content_sha256="b" * 64, + ).items() + ) + + "\nNode-Audit-Workflow-SHA: " + + "f" * 40 + ) + + def commit(message): + subprocess.run( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "--allow-empty", + "-qm", + message, + ], + check=True, + ) + + commit("Receipt\n\n" + trailers) + receipt_parent = REPAIR["git"]("rev-parse", "HEAD^").strip() + if extra_commit: + commit("Unrelated maintainer commit") + clone = tmp_path / "shallow" + subprocess.run( + ["git", "clone", "-q", "--depth=1", root.as_uri(), str(clone)], check=True + ) + monkeypatch.chdir(clone) + assert REPAIR["git"]("rev-parse", "--is-shallow-repository").strip() == "true" + # actions/checkout fetch-depth:0 fetches the complete ancestry. + workflow = yaml.safe_load((ROOT / ".github/workflows/node-repair.yml").read_text()) + assert workflow["jobs"]["prepare"]["steps"][0]["with"]["fetch-depth"] == 0 + subprocess.run(["git", "fetch", "-q", "--unshallow", "origin"], check=True) + expected = dict(expected, head=REPAIR["git"]("rev-parse", "HEAD").strip()) + module = REPAIR["native_history"].__globals__ + monkeypatch.setitem( + module, + "producer_names", + lambda *args: ["audit / Node audit evidence (node-security)"], + ) + monkeypatch.setitem( + module["ENGINE"], + "history_api", + lambda *args, **kw: {"name": "audit / Node audit evidence (node-security)"}, + ) + calls = [] + + def verify(locator, **kwargs): + calls.append(kwargs) + return normal_history(snapshot) + + monkeypatch.setitem(module["ENGINE"], "verify_history", verify) + REPAIR["native_history"](expected, clone, snapshot) + assert calls[0]["source"] == receipt_parent diff --git a/node/tests/verify_node_project.py b/node/tests/verify_node_project.py index ca2285b..abc5b44 100644 --- a/node/tests/verify_node_project.py +++ b/node/tests/verify_node_project.py @@ -298,7 +298,35 @@ def check_selected_validation(version): os.chdir(checkout) with patch.dict(os.environ, {"RUNNER_TEMP": str(root)}): artifact = root / "prepared" - repair["prepare"](expected, artifact) + engine = repair["ENGINE"] + snapshot = engine["valid_snapshot"](checkout) + audit = engine["audit"](checkout) + history = dict( + version=1, + repository=expected["repository"], + source=expected["head"], + project=".", + manager="pnpm", + before_state=engine["dependency_state"](snapshot), + candidate_state=engine["dependency_state"](snapshot), + before=audit, + after=audit, + fixed_ids=[], + package_report=engine["format_audit_summary"]( + audit, + audit, + "pnpm", + fixed_ids=set(), + locks=(snapshot[3], snapshot[3]), + ), + ) + # This fixture proves real pnpm preparation and validation; + # producer API/Check provenance has separate tests and a hosted gate. + with patch.dict( + repair["prepare"].__globals__, + {"native_history": lambda *args: history}, + ): + repair["prepare"](expected, artifact) sealed = {p.name: p.read_bytes() for p in artifact.iterdir()} for mode in ("pass", "fail", "drift", "tamper"): subprocess.run(["git", "reset", "--hard", "-q", "HEAD"], check=True) diff --git a/repair-app/pilot/README.md b/repair-app/pilot/README.md index 8e6a3ab..64bce5e 100644 --- a/repair-app/pilot/README.md +++ b/repair-app/pilot/README.md @@ -47,6 +47,8 @@ jobs: permissions: contents: read pull-requests: read + actions: read + checks: read uses: hydra-ecosystem/github-actions/.github/workflows/node-repair.yml@REVIEWED_REF with: request-id: ${{ inputs.request-id }} @@ -106,3 +108,17 @@ Do not start the cumulative repair consumer from focused/local fixture tests. The required local full review, regular PR approval/CI and this hosted mapping and forgery gate must all pass first. This gate does not claim cumulative repair reporting, dependency publication or full repair acceptance is complete. + +For cumulative preparation acceptance, use the private demo repository. Retain +an initial normal-audit receipt and exercise two subsequent repair attempts. +Verify the original advisory row survives a later audit that omits it, then use +the second attempt's sealed `result.md` as the third attempt's seed. Keep current +unresolved findings distinct from historical outcomes. Record all three native +source/dependency bindings and exact run/job/Check/artifact IDs and digests. + +After the reviewed source passes CI, verify a real preparation upload precedes +separate validation. A distinct read-only run must accept the genuine producer +and reject an identical sibling-job copy. Grant only Actions/Checks read access +to the consumer. Preserve the existing demo PR and restore the complete fixture +file tree after the bounded test. No Hydra mutation, dependency publication or +source merge is included in this preparation slice.