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.