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 |
|---|
pkg | 1.0.0 → 1.0.1 | High | 1 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.