From b62701e7dc3ce24a283d10060ac07aba4d2b3d3b Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Wed, 28 Oct 2026 20:12:05 +0800 Subject: [PATCH] [node-maintenance-repair/8.1.1] Prepare and seal initial normal-audit evidence on a script-free producer with complete policy enrollment and exact preserved-code reuse Prepare normal Node audit evidence on a fresh runner and seal it before project scripts run. Validate the exact prepared dependency and Node declaration files on a separate runner, with the producer's concrete Node and package-manager versions. Keep original advisory evidence in the existing report artifact. Bind it to the trusted producer's native job Check notice and immutable artifact ID/digests, verify those bindings before publication, and retain exact locators in commit metadata. Preserve the existing renderer and reject missing, ambiguous, expired or mismatched evidence. Cumulative repair consumption, repaired-candidate publication and installation rollout remain later slices. The isolated demo must prove distinct-run readback and sibling-job forgery rejection before dependent work starts. Validation: 1,505 Python tests, Ruff lint/format and Pyrefly pass. Complete authored production growth is 594 lines against the 600-line boundary. Hosted provider acceptance is recorded separately. --- .github/workflows/node-audit.yml | 61 +- .github/workflows/node-maintenance.yml | 4 + .github/workflows/node-publish.yml | 6 + docs/node-maintenance-operator.md | 42 + docs/node-maintenance.md | 2 + node/audit/action.yml | 65 +- node/caller.yml | 2 + node/coordinated-caller.yml | 2 + node/publish/action.yml | 3 + node/publish/publish.sh | 11 +- node/runtime/fleet.py | 4 + node/runtime/templates/dependency_audit.py | 468 +++++++- node/tests/test_dependency_audit_publisher.py | 1029 +++++++++++++++++ node/tests/test_fleet_setup.py | 26 + node/tests/test_integration_contract.py | 7 +- node/tests/test_selected_repairs.py | 69 +- repair-app/pilot/README.md | 27 + 17 files changed, 1799 insertions(+), 29 deletions(-) diff --git a/.github/workflows/node-audit.yml b/.github/workflows/node-audit.yml index 2ad5275..9644b73 100644 --- a/.github/workflows/node-audit.yml +++ b/.github/workflows/node-audit.yml @@ -24,7 +24,7 @@ name: Node dependency audit outputs: validation_passed: description: Whether candidate validation passed. - value: ${{ jobs.audit.outputs.validation_passed }} + value: ${{ jobs.validation.outputs.validation_passed }} proposal_complete: description: Whether proposal resolution and audit evidence completed. value: ${{ jobs.audit.outputs.proposal_complete }} @@ -32,13 +32,25 @@ permissions: contents: read jobs: audit: + name: Node audit evidence (${{ inputs.artifact-prefix || 'dependency-audit' }}) + env: + npm_config_ignore_scripts: 'true' + pnpm_config_ignore_scripts: 'true' + npm_config_ignore_pnpmfile: 'true' + pnpm_config_ignore_pnpmfile: 'true' + npm_config_lockfile_only: 'true' + pnpm_config_lockfile_only: 'true' + npm_config_package_lock_only: 'true' runs-on: ubuntu-latest permissions: contents: read timeout-minutes: 60 outputs: - validation_passed: ${{ steps.proposal.outputs.validation_passed }} + prepared_node: ${{ steps.seal.outputs.prepared_node }} proposal_complete: ${{ steps.proposal.outputs.proposal_complete }} + report_artifact_id: ${{ steps.proposal.outputs.report_artifact_id }} + dependencies_artifact_id: ${{ steps.proposal.outputs.dependencies_artifact_id }} + node_update_artifact_id: ${{ steps.proposal.outputs.node_update_artifact_id }} steps: - name: Validate manual dispatch ref env: @@ -54,7 +66,7 @@ jobs: with: ref: ${{ inputs.audit-ref || github.sha }} persist-credentials: false - - name: Audit, propose upgrades, and validate + - name: Prepare and seal audit evidence id: proposal uses: $/node/audit with: @@ -63,6 +75,49 @@ jobs: refresh: ${{ inputs.refresh }} operation: ${{ inputs.operation }} proposal-kind: ${{ inputs.proposal-kind }} + - name: Seal uploaded audit evidence + id: seal + env: + ARTIFACT_ID: ${{ steps.proposal.outputs.report_artifact_id }} + ARCHIVE_SHA256: ${{ steps.proposal.outputs.report_archive_sha256 }} + CONTENT_SHA256: ${{ steps.proposal.outputs.report_content_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 audit evidence v1::v1:%s:%s:%s\n' "$ARTIFACT_ID" "$ARCHIVE_SHA256" "$CONTENT_SHA256" + printf 'prepared_node=%s\n' "$NODE_AUDIT_NODE_VERSION" >> "$GITHUB_OUTPUT" + validation: + needs: audit + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 60 + outputs: + validation_passed: ${{ steps.validation.outputs.validation_passed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.audit-ref || github.sha }} + persist-credentials: false + - uses: $/node/project + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.audit.outputs.dependencies_artifact_id }} + merge-multiple: true + path: ${{ env.NPM_PROJECT }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.audit.outputs.node_update_artifact_id }},${{ needs.audit.outputs.report_artifact_id }} + merge-multiple: true + path: ${{ runner.temp }} + - name: Check the frozen candidate on a separate runner + id: validation + continue-on-error: ${{ inputs.operation != 'validate' }} + uses: $/node/audit + with: + operation: check-prepared + prepared-node: ${{ needs.audit.outputs.prepared_node }} + artifact-prefix: ${{ inputs.artifact-prefix || 'dependency-audit' }}-validation configuration-issue: needs: audit if: ${{ always() && !cancelled() }} diff --git a/.github/workflows/node-maintenance.yml b/.github/workflows/node-maintenance.yml index 69a1450..baa95d5 100644 --- a/.github/workflows/node-maintenance.yml +++ b/.github/workflows/node-maintenance.yml @@ -77,6 +77,8 @@ jobs: permissions: contents: write pull-requests: write + actions: read + checks: read uses: ./.github/workflows/node-publish.yml with: validation-passed: ${{ needs.security.outputs.validation_passed }} @@ -110,6 +112,8 @@ jobs: permissions: contents: write pull-requests: write + actions: read + checks: read uses: ./.github/workflows/node-publish.yml with: validation-passed: ${{ needs.upgrades.outputs.validation_passed }} diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml index 6ee254e..8959733 100644 --- a/.github/workflows/node-publish.yml +++ b/.github/workflows/node-publish.yml @@ -41,6 +41,8 @@ jobs: permissions: contents: write pull-requests: write + actions: read + checks: read steps: - name: Create repository-scoped publisher token if: ${{ inputs.app-client-id != '' }} @@ -53,6 +55,8 @@ jobs: repositories: ${{ github.event.repository.name }} permission-contents: write permission-pull-requests: write + permission-actions: read + permission-checks: read - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: token: ${{ steps.app-token.outputs.token || github.token }} @@ -113,6 +117,8 @@ jobs: PR_FOOTER: | [Workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) PROPOSAL_KIND: ${{ inputs.proposal-kind }} + TRUSTED_WORKFLOW_SHA: ${{ job.workflow_sha }} + AUDIT_ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} REFRESH_ONLY: ${{ inputs.refresh-only }} EXPECTED_PR: ${{ inputs.expected-pr }} PROPOSAL_COMPLETE: ${{ inputs.proposal-complete }} diff --git a/docs/node-maintenance-operator.md b/docs/node-maintenance-operator.md index 6fedb1a..b2b338f 100644 --- a/docs/node-maintenance-operator.md +++ b/docs/node-maintenance-operator.md @@ -248,3 +248,45 @@ it does not promise permanent deduplication after a record is deleted. Keep App credentials in operator storage and Worker secrets. Keep verification receipts outside the repository, without credentials or webhook payloads. A local test or dry-run bundle does not replace live delivery evidence. + +## Sealed normal-audit evidence + +The normal audit producer prepares dependency changes on a fresh read-only +runner. It disables lifecycle scripts, local/global pnpmfile hooks and dependency +materialization. Its parser installer runs in Python isolated mode. Project +validation runs later on another read-only runner with the exact prepared files. +That runner can retain check diagnostics; it cannot replace the producer artifact. +A failed proposal check still permits a PR report with failed validation. A +committed-state validation run fails when its project checks fail. + +The existing `dependency-audit-pr.md` report includes one bounded canonical +version1 record with original audit advisories, severities, links, package rows, +before/candidate dependency hashes and deduplicated counts. After the report +upload succeeds, the producer emits one `Node audit evidence v1` notice on its +existing Actions job Check. The notice binds the numeric artifact ID and archive +and content SHA256 hashes. It requires no Checks write permission. + +The normal publisher resolves the expected producer job in the exact current +run, then verifies its server-derived Check URL, immutable workflow revision, +completed seal steps, unique matching annotation and immutable artifact digest. +The provider run, job, Check and artifact heads must agree on the triggering +revision. The sealed checkout revision is checked separately against the planned +audit revision, which can advance before the coordinated planning checkout. +It rejects sibling-job notices, duplicate/ambiguous producer jobs, expired or +altered artifacts and mismatched project/dependency state. Native commit trailers +retain exact run/job/Check/artifact IDs and hashes for the later read-only repair +consumer. Editable PR prose is not historical audit authority. +The immutable artifact retains the canonical record while the human PR body omits +that opaque record. + +Only the publisher reads Actions metadata/artifacts and Check annotations; grant +`actions: read` and `checks: read` to its calling job and any publisher App token. +The producer and project-validation jobs keep Contents read only. The isolated +pilot must provide these read grants. Both supported caller templates include +them, and fleet setup checks require them. Updating callers in existing +repositories and the installation rollout remain later delivery work. + +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. diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index 4d1fd31..8ca5045 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -235,6 +235,8 @@ jobs: permissions: contents: write pull-requests: write + actions: read + checks: read uses: hydra-ecosystem/github-actions/.github/workflows/node-publish.yml@v1 with: validation-passed: ${{ needs.audit.outputs.validation_passed }} diff --git a/node/audit/action.yml b/node/audit/action.yml index 7f3066b..a84eceb 100644 --- a/node/audit/action.yml +++ b/node/audit/action.yml @@ -11,18 +11,36 @@ inputs: description: Select an existing proposal channel on a default-branch push. default: 'false' operation: - description: propose or validate (read-only dependency validation). + description: propose, validate committed state, or check-prepared on a separate runner. default: propose proposal-kind: description: Verify resolved policy agrees with the selected channel. default: legacy + prepared-node: + description: The producer's selected Node runtime for isolated validation. + default: '' outputs: validation_passed: description: Whether frozen installation and project checks passed. - value: ${{ steps.proposal.outputs.validation_passed }} + value: ${{ steps.prepared-validation.outputs.validation_passed || steps.proposal.outputs.validation_passed }} proposal_complete: description: Whether proposal resolution and audit evidence completed. value: ${{ steps.proposal.outputs.proposal_complete }} + report_artifact_id: + description: Exact immutable report artifact ID. + value: ${{ steps.report.outputs.artifact-id }} + dependencies_artifact_id: + description: Exact immutable prepared dependency artifact ID. + value: ${{ steps.dependencies.outputs.artifact-id }} + node_update_artifact_id: + description: Exact immutable prepared Node update artifact ID. + value: ${{ steps.node-update.outputs.artifact-id }} + report_archive_sha256: + description: Uploaded report archive digest. + value: ${{ steps.report.outputs.artifact-digest }} + report_content_sha256: + description: Sealed report content digest. + value: ${{ steps.proposal.outputs.report_sha256 }} runs: using: composite steps: @@ -30,9 +48,10 @@ runs: with: python-version: '3.13' - name: Install configuration and audit report parser - run: python -m pip install PyYAML==6.0.3 + run: python -I -m pip install PyYAML==6.0.3 shell: bash - name: Read project configuration + if: ${{ inputs.operation != 'check-prepared' }} env: AUDIT_KIND: ${{ inputs.audit-kind }} REFRESH: ${{ inputs.refresh }} @@ -48,14 +67,19 @@ runs: esac python "$GITHUB_ACTION_PATH/../runtime/project.py" --github-env "$GITHUB_ENV" --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" --audit-kind "$AUDIT_KIND" --node-update "$RUNNER_TEMP/node-audit-node-update.json" "${node_args[@]}" "${refresh_args[@]}" shell: bash + - name: Read the sealed prepared toolchain + if: ${{ inputs.operation == 'check-prepared' }} + shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --check-prepared --node-update "$RUNNER_TEMP/node-audit-node-update.json" --github-env "$GITHUB_ENV" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: ${{ env.NODE_AUDIT_NODE_VERSION }} + node-version: ${{ inputs.prepared-node || env.NODE_AUDIT_NODE_VERSION }} check-latest: true - name: Enable the project's pinned package manager env: PACKAGE_MANAGER: ${{ env.NODE_AUDIT_MANAGER }} PACKAGE_MANAGER_VERSION: ${{ env.NODE_AUDIT_MANAGER_VERSION }} + npm_config_package_lock_only: 'false' run: | set -euo pipefail if [ "$PACKAGE_MANAGER" = pnpm ]; then @@ -65,6 +89,7 @@ runs: fi shell: bash - name: Verify the declared toolchain before dependency changes + if: ${{ inputs.operation != 'check-prepared' }} env: AUDIT_KIND: ${{ inputs.audit-kind }} REFRESH: ${{ inputs.refresh }} @@ -73,15 +98,18 @@ runs: refresh_args=() if [ "${REFRESH:-false}" = true ]; then refresh_args=(--refresh); fi python "$GITHUB_ACTION_PATH/../runtime/project.py" --verify-runtime --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" --audit-kind "$AUDIT_KIND" "${refresh_args[@]}" + installed_node=$(node --version) + printf 'NODE_AUDIT_NODE_VERSION=%s\n' "${installed_node#v}" >> "$GITHUB_ENV" shell: bash - name: Retain configuration status even when preflight fails - if: ${{ always() && !cancelled() }} + if: ${{ always() && !cancelled() && inputs.operation != 'check-prepared' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ inputs.artifact-prefix || 'node-audit' }}-configuration if-no-files-found: ignore path: ${{ runner.temp }}/node-audit-configuration.json - name: Verify the selected proposal channel + if: ${{ inputs.operation != 'check-prepared' }} env: PROPOSAL_KIND: ${{ inputs.proposal-kind }} MODE: ${{ env.NODE_AUDIT_MAINTENANCE_MODE }} @@ -93,19 +121,25 @@ runs: esac shell: bash - name: Audit, propose upgrades, and validate + if: ${{ inputs.operation != 'check-prepared' }} id: proposal run: | set -euo pipefail - operation_args=() + operation_args=(--prepare-only) case "${OPERATION:-propose}" in propose) ;; - validate) operation_args=(--validate-only) ;; + validate) operation_args+=(--validate-only) ;; *) echo "::error::Unknown maintenance operation"; exit 1 ;; esac python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --github-output "$GITHUB_OUTPUT" --validation-script "$NODE_AUDIT_VALIDATION_SCRIPT" --maintenance-mode "$NODE_AUDIT_MAINTENANCE_MODE" --upgrade-scope "$NODE_AUDIT_UPGRADE_SCOPE" --minimum-release-age-days "$NODE_AUDIT_MINIMUM_RELEASE_AGE_DAYS" --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --node-update "$RUNNER_TEMP/node-audit-node-update.json" --local-patches-report "$RUNNER_TEMP/node-audit-local-patches.json" "${operation_args[@]}" shell: bash env: OPERATION: ${{ inputs.operation }} + - name: Validate the exact prepared dependencies + id: prepared-validation + if: ${{ inputs.operation == 'check-prepared' }} + shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --check-prepared --node-update "$RUNNER_TEMP/node-audit-node-update.json" --github-output "$GITHUB_OUTPUT" - name: Retain the audit summary even when the audit fails if: ${{ always() && !cancelled() }} run: | @@ -113,12 +147,16 @@ runs: cat "$RUNNER_TEMP/dependency-audit-pr.md" >> "$GITHUB_STEP_SUMMARY" fi shell: bash - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - if: ${{ inputs.operation != 'check-prepared' }} + id: node-update + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-node-update if-no-files-found: error path: ${{ runner.temp }}/node-audit-node-update.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - if: ${{ inputs.operation != 'check-prepared' }} + id: dependencies + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-dependencies if-no-files-found: error @@ -127,21 +165,22 @@ runs: ${{ env.NPM_PROJECT }}/package-lock.json ${{ env.NPM_PROJECT }}/pnpm-lock.yaml ${{ env.NPM_PROJECT }}/pnpm-workspace.yaml - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: ${{ always() && !cancelled() }} + - if: ${{ always() && !cancelled() }} + id: report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-report if-no-files-found: ignore path: ${{ runner.temp }}/dependency-audit-pr.md - name: Retain local patch status for the issue reporter - if: ${{ always() && !cancelled() }} + if: ${{ always() && !cancelled() && inputs.operation != 'check-prepared' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ inputs.artifact-prefix || 'node-audit' }}-local-patches if-no-files-found: ignore path: ${{ runner.temp }}/node-audit-local-patches.json - name: Fail committed-state validation when project checks fail - if: ${{ inputs.operation == 'validate' && steps.proposal.outputs.validation_passed != 'true' }} + if: ${{ inputs.operation == 'check-prepared' && steps.prepared-validation.outputs.validation_passed != 'true' }} run: | echo "::error::Default-branch validation failed; see the retained report." exit 1 diff --git a/node/caller.yml b/node/caller.yml index c188573..8a31a79 100644 --- a/node/caller.yml +++ b/node/caller.yml @@ -33,6 +33,8 @@ jobs: permissions: contents: write pull-requests: write + actions: read + checks: read uses: __CENTRAL_REPOSITORY__/.github/workflows/node-publish.yml@__CENTRAL_REVISION__ with: validation-passed: ${{ needs.audit.outputs.validation_passed }} diff --git a/node/coordinated-caller.yml b/node/coordinated-caller.yml index 00ce5f0..37baffa 100644 --- a/node/coordinated-caller.yml +++ b/node/coordinated-caller.yml @@ -35,6 +35,8 @@ jobs: contents: write pull-requests: write issues: write + actions: read + checks: read uses: __CENTRAL_REPOSITORY__/.github/workflows/node-maintenance.yml@__CENTRAL_REVISION__ with: audit-kind: ${{ inputs.audit-kind || 'auto' }} diff --git a/node/publish/action.yml b/node/publish/action.yml index e86fa28..ff76ca0 100644 --- a/node/publish/action.yml +++ b/node/publish/action.yml @@ -6,5 +6,8 @@ runs: - name: Verify and stage the Node security update shell: bash run: python "$GITHUB_ACTION_PATH/../runtime/node_proposal.py" --plan "$RUNNER_TEMP/node-audit-node-update.json" --github-env "$GITHUB_ENV" + - name: Verify immutable normal-audit history and retain exact native locators + shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --locate-history --trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" --artifact-prefix "$AUDIT_ARTIFACT_PREFIX" --github-env "$GITHUB_ENV" - shell: bash run: bash "$GITHUB_ACTION_PATH/publish.sh" diff --git a/node/publish/publish.sh b/node/publish/publish.sh index fcc6ad5..18f88d4 100644 --- a/node/publish/publish.sh +++ b/node/publish/publish.sh @@ -104,6 +104,13 @@ if [ "$PROPOSAL_KIND" != legacy ]; then pr_title="${pr_title/Node:/Node $PROPOSAL_KIND:}" fi printf '\n%s\n' "$PR_FOOTER" >> "$body_file" +python3 -I - "$body_file" <<'PY' +from pathlib import Path +import sys +length = len(Path(sys.argv[1]).read_text()) +if length > 65536: + raise SystemExit(f"::error::PR body exceeds 65536 characters ({length})") +PY pr_number=$(gh pr list --state open --head "$PR_BRANCH" --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') if [ "$REFRESH_ONLY" = true ] && { [ -z "${EXPECTED_PR:-}" ] || [ "$pr_number" != "$EXPECTED_PR" ]; }; then echo 'The original proposal is no longer open; refresh will not create or replace a PR.' @@ -163,7 +170,9 @@ else fi fi fi - git commit -m 'Node dependency audit and upgrades' + commit_args=(-m 'Node dependency audit and upgrades') + if [ -n "${NODE_AUDIT_HISTORY_TRAILERS:-}" ]; then commit_args+=(-m "$NODE_AUDIT_HISTORY_TRAILERS"); fi + git commit "${commit_args[@]}" current_base if [ "$PROPOSAL_KIND" != legacy ] && [ -n "$pr_number" ] && ! still_open; then echo 'The proposal was closed or merged during the audit; no branch update published.' diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py index 892b0e7..d45462e 100644 --- a/node/runtime/fleet.py +++ b/node/runtime/fleet.py @@ -412,6 +412,8 @@ def check_caller(root, config, selected, github, default_branch="main"): "contents": "write", "pull-requests": "write", "issues": "write", + "actions": "read", + "checks": "read", }: raise ValueError( "Coordinated caller permissions differ from the supported contract" @@ -476,6 +478,8 @@ def check_caller(root, config, selected, github, default_branch="main"): } or publisher.get("permissions") != { "contents": "write", "pull-requests": "write", + "actions": "read", + "checks": "read", }: raise ValueError( "Caller audit/publisher permissions differ from the supported caller contract" diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index 19bd2f3..85811ca 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -1,14 +1,18 @@ """Prepare a best-effort npm or pnpm upgrade report for one standalone Node project.""" import argparse +import base64 import copy import hashlib import html import json +import os import re import runpy import subprocess import time +import zipfile +from io import BytesIO from datetime import datetime, timedelta, timezone from pathlib import Path from tempfile import TemporaryDirectory @@ -292,10 +296,15 @@ def restore_invalid_state( return False policy = yaml.safe_load(current[1]["pnpm-workspace.yaml"]) reference = yaml.safe_load(snapshot[1]["pnpm-workspace.yaml"]) - if direct_dependency_regressed(snapshot, current): + pins = reference.get("overrides", {}) + bypassed_pin = isinstance(pins, dict) and any( + stable_version(pins.get(name)) not in (None, version) + for (_, name), version in direct_versions(current).items() + ) + if direct_dependency_regressed(snapshot, current) or bypassed_pin: restore_snapshot(directory, snapshot) notes.append( - f"{operation} changed protected pnpm policy or lowered a known direct " + f"{operation} changed protected pnpm policy, bypassed an exact pin or lowered a known direct " "pnpm dependency manifest floor or locked version; restored the last " "valid snapshot and did not claim its changes." ) @@ -3184,6 +3193,7 @@ def prepare( upgrade_scope="major", local_patches=None, validate_only=False, + prepare_only=False, platform=None, ) -> str: # Leave time in the 60-minute job to write and upload a partial report. @@ -3494,12 +3504,18 @@ def prepare( if package_manager == "pnpm" else ["ci", "--no-audit"] ) - install = run(directory, *install_args, deadline=deadline) + install = ( + subprocess.CompletedProcess([], 1, "", "") + if prepare_only + else run(directory, *install_args, deadline=deadline) + ) invalid_install_state = restore_invalid_state( directory, validation_snapshot, notes, "Frozen install", frozen=True ) last_valid_snapshot = valid_snapshot(directory) or validation_snapshot - if install.returncode or invalid_install_state: + if prepare_only: + check_status = "Pending: separate prepared-candidate validation." + elif install.returncode or invalid_install_state: check_status = ( f"Not run: {package_manager} frozen install failed " f"(exit {install.returncode}); validation failed." @@ -3806,7 +3822,11 @@ def published_patch_versions(specifier, *, all_versions=False): if not proposed_changes: proposed_changes = "No dependency version changes are proposed." install_status = ( - "Passed." if not install.returncode and not invalid_install_state else "Failed." + "Not run: separate prepared-candidate validation." + if prepare_only + else "Passed." + if not install.returncode and not invalid_install_state + else "Failed." ) if check_status.startswith("Proposal discarded:"): install_status = ( @@ -3945,14 +3965,332 @@ def published_patch_versions(specifier, *, all_versions=False): + f"### Candidate dependency files (SHA-256)\n\n{fingerprints}\n\n\n" ) if validate_only: + status = ( + "Pending" + if prepare_only + else "Passed" + if check_status == "Passed." + else "Failed" + ) report = ( - f"**Committed dependency validation · {'Passed' if check_status == 'Passed.' else 'Failed'}**\n\n" + f"**Committed dependency validation · {status}**\n\n" "Checked committed dependencies without attempting repairs or routine upgrades.\n\n" + report.partition("\n")[2] ) + if prepare_only and not audit_errors: + record = { + "version": 1, + "repository": os.environ["GITHUB_REPOSITORY"], + "source": subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip(), + "project": directory.as_posix(), + "manager": package_manager, + "node": os.environ["NODE_AUDIT_NODE_VERSION"], + "manager_version": os.environ["NODE_AUDIT_MANAGER_VERSION"], + "validation_script": validation_script, + "before_state": dependency_state(initial_snapshot), + "candidate_state": dependency_state(after_snapshot), + "before": before, + "after": after, + "package_report": package_report, + "fixed_ids": sorted(fixed_ids), + "initial_ids": sorted(initial_ids or ()), + "remaining_ids": sorted(remaining_ids or ()), + } + encoded = base64.b64encode(canonical_history(record)).decode("ascii") + if len(encoded) > 524288: + raise ValueError("Historical audit evidence exceeds its bound") + report += f"\n\n" + heading, separator, tail = report.partition("\n") + report = ( + heading.replace("Validation failed or unavailable", "Validation pending") + + separator + + tail.replace( + "⚪ Upgrade validation failed or unavailable: **Node.js**", + "⚪ Upgrade validation pending: **Node.js**", + 1, + ) + ) + if len(report.encode()) > 1048576: + raise ValueError("Audit report exceeds its bound") return report +HISTORY_TITLE = "Node audit evidence v1" +HISTORY_PATTERN = re.compile( + r"^$", re.M +) + + +def canonical_history(value): + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii") + + +def dependency_state(snapshot): + return { + name: hashlib.sha256(data).hexdigest() + for name, data in sorted({"package.json": snapshot[0], **snapshot[1]}.items()) + } + + +def history_record(report): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate history field") + result[key] = value + return result + + if len(report) > 1048576: + raise ValueError("Audit report exceeds its bound") + text = report.decode("utf-8") + matches = HISTORY_PATTERN.findall(text) + if ( + len(matches) != 1 + or text.count("node-audit-history:") != 1 + or len(matches[0]) > 524288 + ): + raise ValueError("Missing or ambiguous sealed audit history") + raw = base64.b64decode(matches[0], validate=True) + record = json.loads(raw, object_pairs_hook=unique) + if ( + canonical_history(record) != raw + or not isinstance(record, dict) + or type(record.get("version")) is not int + or record["version"] != 1 + ): + raise ValueError("Noncanonical or unsupported audit history") + return record + + +def check_prepared_history(report, directory): + record = history_record(report) + if ( + record["repository"] != os.environ["GITHUB_REPOSITORY"] + or record["source"] + != subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + or record["project"] != directory.as_posix() + or record["candidate_state"] != dependency_state(valid_snapshot(directory)) + ): + raise ValueError( + "Prepared dependencies or project identity differ from sealed evidence" + ) + return record + + +def history_api(repository, suffix, *, binary=False): + result = subprocess.run( + ["gh", "api", f"repos/{repository}/{suffix}"], + capture_output=True, + timeout=60, + check=True, + ) + if len(result.stdout) > (8388608 if binary else 1048576): + raise ValueError("Historical evidence API response exceeds its bound") + return result.stdout if binary else json.loads(result.stdout) + + +def verify_history( + locator, + *, + repository, + source, + project, + candidate_state, + trusted_workflow_sha, + expected_job_name, +): + required = {"run", "job", "check", "artifact", "archive_sha256", "content_sha256"} + if ( + set(locator) != required + or any( + type(locator[k]) is not int or locator[k] <= 0 + for k in ("run", "job", "check", "artifact") + ) + or any( + not isinstance(locator[k], str) + or not re.fullmatch(r"[a-f0-9]{64}", locator[k]) + for k in ("archive_sha256", "content_sha256") + ) + ): + raise ValueError("Invalid exact historical evidence locator") + run = history_api(repository, f"actions/runs/{locator['run']}") + trigger_head = run.get("head_sha") + job = history_api(repository, f"actions/jobs/{locator['job']}") + check_url = ( + f"https://api.github.com/repos/{repository}/check-runs/{locator['check']}" + ) + jobs = history_api( + repository, + f"actions/runs/{locator['run']}/attempts/{run['run_attempt']}/jobs?per_page=100", + ) + workflows = [ + w + for w in run.get("referenced_workflows", []) + if w.get("path", "").split("@")[0] + == "hydra-ecosystem/github-actions/.github/workflows/node-audit.yml" + ] + steps = [ + (step.get("name"), step.get("conclusion")) for step in job.get("steps", []) + ] + expected_steps = [ + ("Prepare and seal audit evidence", "success"), + ("Seal uploaded audit evidence", "success"), + ] + relevant = [ + step for step in steps if step[0] in {name for name, _ in expected_steps} + ] + if ( + run.get("repository", {}).get("full_name") != repository + or not isinstance(trigger_head, str) + or re.fullmatch(r"[a-f0-9]{40}", trigger_head) is None + or run.get("event") not in {"push", "schedule", "workflow_dispatch"} + or len(workflows) != 1 + or workflows[0].get("sha") != trusted_workflow_sha + or job.get("id") != locator["job"] + or job.get("run_id") != locator["run"] + or job.get("head_sha") != trigger_head + or job.get("run_attempt") != run["run_attempt"] + or job.get("name") != expected_job_name + or job.get("check_run_url") != check_url + 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") == expected_job_name + ] + != [locator["job"]] + or relevant != expected_steps + ): + raise ValueError( + "Historical evidence does not identify the trusted producer job" + ) + check = history_api(repository, f"check-runs/{locator['check']}") + annotations = history_api( + repository, f"check-runs/{locator['check']}/annotations?per_page=100" + ) + notices = [a for a in annotations if a.get("title") == HISTORY_TITLE] + message = f"v1:{locator['artifact']}:{locator['archive_sha256']}:{locator['content_sha256']}" + if ( + check.get("id") != locator["check"] + or check.get("head_sha") != trigger_head + 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 producer seal annotation") + artifact = history_api(repository, 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") != trigger_head + ): + raise ValueError("Historical artifact metadata differs from its producer seal") + archive = history_api( + repository, 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("Historical archive digest differs") + with zipfile.ZipFile(BytesIO(archive)) as bundle: + entries = bundle.infolist() + if ( + len(entries) != 1 + or entries[0].filename != "dependency-audit-pr.md" + or entries[0].file_size > 1048576 + ): + raise ValueError("Unexpected historical artifact members or size") + report = bundle.read(entries[0]) + if hashlib.sha256(report).hexdigest() != locator["content_sha256"]: + raise ValueError("Historical report digest differs") + record = history_record(report) + if ( + record["repository"], + record["source"], + record["project"], + record["candidate_state"], + ) != (repository, source, project, candidate_state): + raise ValueError( + "Historical record belongs to different dependencies or project" + ) + return record + + +def locate_current_history(report, directory, *, trusted_workflow_sha, prefix): + repository = os.environ["GITHUB_REPOSITORY"] + record = check_prepared_history(report, directory) + run_id = int(os.environ["GITHUB_RUN_ID"]) + jobs = history_api(repository, f"actions/runs/{run_id}/jobs?per_page=100") + producer_name = f"Node audit evidence ({prefix or 'dependency-audit'})" + matches = [ + job + for job in jobs.get("jobs", []) + if job.get("name") == producer_name + or job.get("name", "").endswith(" / " + producer_name) + ] + if jobs.get("total_count", 100) >= 100 or len(matches) != 1: + raise ValueError("Missing or ambiguous expected producer job") + job = matches[0] + check_id = int(job["check_run_url"].rsplit("/", 1)[-1]) + if ( + job["check_run_url"] + != f"https://api.github.com/repos/{repository}/check-runs/{check_id}" + ): + raise ValueError("Producer Check locator is not server-derived") + annotations = history_api( + repository, f"check-runs/{check_id}/annotations?per_page=100" + ) + notices = [item for item in annotations if item.get("title") == HISTORY_TITLE] + match = ( + re.fullmatch( + r"v1:([1-9][0-9]*):([a-f0-9]{64}):([a-f0-9]{64})", + notices[0].get("message", ""), + ) + if len(notices) == 1 + else None + ) + if match is None: + raise ValueError("Producer has no unique artifact seal") + locator = { + "run": run_id, + "job": job["id"], + "check": check_id, + "artifact": int(match[1]), + "archive_sha256": match[2], + "content_sha256": match[3], + } + verified = verify_history( + locator, + repository=repository, + source=record["source"], + project=record["project"], + candidate_state=record["candidate_state"], + trusted_workflow_sha=trusted_workflow_sha, + expected_job_name=job["name"], + ) + if ( + verified != record + or hashlib.sha256(report).hexdigest() != locator["content_sha256"] + ): + raise ValueError("Publisher report differs from immutable producer evidence") + return locator + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--directory", default=".") @@ -3978,6 +4316,12 @@ def main() -> None: parser.add_argument("--node-update", type=Path) parser.add_argument("--local-patches-report", type=Path) parser.add_argument("--validate-only", action="store_true") + parser.add_argument("--prepare-only", action="store_true") + parser.add_argument("--check-prepared", action="store_true") + parser.add_argument("--github-env", type=Path) + parser.add_argument("--locate-history", action="store_true") + parser.add_argument("--trusted-workflow-sha") + parser.add_argument("--artifact-prefix", default="") args = parser.parse_args() directory = Path(args.directory) if ( @@ -3990,7 +4334,114 @@ def main() -> None: parser.error( "directory must contain valid package files for one npm or pnpm deployment" ) + if args.locate_history: + if not args.trusted_workflow_sha or not re.fullmatch( + r"[a-f0-9]{40}", args.trusted_workflow_sha + ): + parser.error("An immutable trusted producer source is required") + locator = locate_current_history( + args.report.read_bytes(), + directory, + trusted_workflow_sha=args.trusted_workflow_sha, + prefix=args.artifact_prefix, + ) + trailers = "\n".join( + f"Node-Audit-{key.replace('_', '-')}: {value}" + for key, value in locator.items() + ) + trailers += f"\nNode-Audit-Workflow-SHA: {args.trusted_workflow_sha}" + with args.github_env.open("a") as output: + output.write( + f"NODE_AUDIT_HISTORY_TRAILERS< None: upgrade_scope=args.upgrade_scope, local_patches=patches, validate_only=args.validate_only, + prepare_only=args.prepare_only, platform=NODE_RELEASES["collect"](node_plan), ) if args.node_update is not None: @@ -4043,6 +4495,10 @@ def main() -> None: if args.github_output is not None: with args.github_output.open("a") as output: output.write(f"validation_passed={str(validation['passed']).lower()}\n") + if args.prepare_only: + output.write( + f"report_sha256={hashlib.sha256(report.encode()).hexdigest()}\n" + ) output.write( f"proposal_complete={str(validation['proposal_complete']).lower()}\n" ) diff --git a/node/tests/test_dependency_audit_publisher.py b/node/tests/test_dependency_audit_publisher.py index e058193..324b382 100644 --- a/node/tests/test_dependency_audit_publisher.py +++ b/node/tests/test_dependency_audit_publisher.py @@ -36,6 +36,8 @@ def test_app_credentials_are_scoped_to_trusted_repository_publishing(): assert permissions == { "permission-contents": "write", "permission-pull-requests": "write", + "permission-actions": "read", + "permission-checks": "read", } assert app["with"].get("skip-token-revoke", False) is False assert checkout["with"]["token"] == PUBLISH["env"]["GH_TOKEN"] @@ -280,3 +282,1030 @@ def test_publication_preserves_reports_and_signals_failed_validation( assert len(pushes) == int(changes and not blocked) if pushes and draft_commands: assert commands.index(draft_commands[0]) < commands.index(pushes[0]) + + +def test_publication_rejects_oversized_final_body_before_push(tmp_path): + cli = tmp_path / "mock-cli" + cli.write_text( + f"#!{sys.executable}\n" + "import os, sys\n" + "from pathlib import Path\n" + "tool, args = Path(sys.argv[0]).name, sys.argv[1:]\n" + "with open(os.environ['COMMAND_LOG'], 'a') as log: log.write(tool + ' ' + ' '.join(args) + '\\n')\n" + "if tool == 'git' and args[0] == 'rev-parse': sys.exit(1)\n" + "if tool == 'git' and args[0] == 'diff': sys.exit(int(os.environ['HAS_CHANGES']))\n" + "if tool == 'gh' and args[:2] == ['pr', 'list']: print('')\n" + "if tool == 'gh' and args[:2] == ['pr', 'create']:\n" + " body = Path(args[args.index('--body-file') + 1]).read_text()\n" + " sys.exit(42 if len(body) > 65536 else 0)\n" + ) + cli.chmod(0o755) + for tool in ("git", "gh"): + (tmp_path / tool).symlink_to(cli) + project = tmp_path / "website" + project.mkdir() + (project / "package.json").write_text("{}\n") + (project / "package-lock.json").write_text("{}\n") + report = tmp_path / "dependency-audit-pr.md" + report.write_text( + "**9 dependency updates · 1 security fix · Validation passed**\n\n" + + "x" * 65520 + ) + log = tmp_path / "commands" + result = subprocess.run( + ["bash", "-c", PUBLISH["run"]], + cwd=tmp_path, + env={ + **os.environ, + **PUBLISH["env"], + "PROPOSAL_KIND": "legacy", + "NPM_PROJECT": "website", + "BASE_BRANCH": "main", + "PR_BRANCH": "maintenance/dependency-audit", + "VALIDATION_PASSED": "true", + "RUNNER_TEMP": str(tmp_path), + "COMMAND_LOG": str(log), + "HAS_CHANGES": "1", + "PR_FOOTER": "footer", + "PATH": f"{tmp_path}:{os.environ['PATH']}", + }, + capture_output=True, + text=True, + ) + assert result.returncode != 0, result.stdout + commands = log.read_text().splitlines() + assert not any(command.startswith("git push ") for command in commands) + assert not any(command.startswith("gh pr create ") for command in commands) + assert "65536" in result.stderr + + +def test_publisher_size_block_isolated_from_checkout_python_modules( + tmp_path, monkeypatch +): + import shlex + + marker = tmp_path / "shadowed" + (tmp_path / "pathlib.py").write_text( + "import os\n" + "Path = type('Path', (), {'__init__': lambda self, _: None})\n" + "open(os.environ['SHADOW_MARKER'], 'w').write(os.environ['GH_TOKEN'])\n" + "raise SystemExit(73)\n" + ) + body = tmp_path / "body.md" + body.write_text("small body\n") + marker_text = "python3 -I - \"$body_file\" <<'PY'\n" + assert marker_text in PUBLISH["run"] + python_body = PUBLISH["run"].split(marker_text, 1)[1].split("\nPY\n", 1)[0] + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + monkeypatch.setenv("GH_TOKEN", "dummy-publisher-token") + monkeypatch.setenv("SHADOW_MARKER", str(marker)) + environment = os.environ.copy() + + def invoke(flag): + return subprocess.run( + [ + "bash", + "-c", + "body_file=" + + shlex.quote(str(body)) + + "\npython3 " + + flag + + " - \"$body_file\" <<'PY'\n" + + python_body + + "\nPY\n", + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + + control = invoke("") + assert control.returncode == 73 + assert marker.read_text() == "dummy-publisher-token" + marker.unlink() + isolated = invoke("-I") + assert isolated.returncode == 0, isolated.stderr + assert not marker.exists() + + +@pytest.fixture +def sealed_history(tmp_path, monkeypatch): + import base64 + import hashlib + import io + import runpy + import zipfile + from pathlib import Path + + engine = runpy.run_path( + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py") + ) + record = dict( + version=1, + repository="owner/repo", + source="a" * 40, + project="app", + candidate_state={"package.json": "b" * 64}, + before_state={"package.json": "c" * 64}, + manager="pnpm", + node="24.22.1", + manager_version="11.21.0", + validation_script="build", + before={"original": "CVE-2026-12345"}, + after={}, + package_report="Original row", + fixed_ids=["GHSA-aaaa-bbbb-cccc"], + initial_ids=["GHSA-aaaa-bbbb-cccc"], + remaining_ids=[], + ) + report = ( + b"Report\n\n" + ) + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as bundle: + bundle.writestr("dependency-audit-pr.md", report) + archive = stream.getvalue() + locator = dict( + run=10, + job=20, + check=30, + artifact=40, + archive_sha256=hashlib.sha256(archive).hexdigest(), + content_sha256=hashlib.sha256(report).hexdigest(), + ) + job_name = "security / Node audit evidence (node-security)" + check_url = "https://api.github.com/repos/owner/repo/check-runs/30" + job = dict( + id=20, + run_id=10, + head_sha="a" * 40, + run_attempt=1, + name=job_name, + check_run_url=check_url, + status="completed", + conclusion="success", + html_url="job-url", + steps=[ + dict(name=name, conclusion="success") + for name in [ + "Prepare and seal audit evidence", + "Seal uploaded audit evidence", + ] + ], + ) + data = { + "actions/runs/10": dict( + repository={"full_name": "owner/repo"}, + head_sha="a" * 40, + event="workflow_dispatch", + run_attempt=1, + referenced_workflows=[ + dict( + path="hydra-ecosystem/github-actions/.github/workflows/node-audit.yml@tag", + sha="d" * 40, + ) + ], + ), + "actions/jobs/20": job, + "actions/runs/10/attempts/1/jobs?per_page=100": dict(total_count=1, jobs=[job]), + "check-runs/30": dict( + id=30, + head_sha="a" * 40, + app={"id": 15368}, + details_url="job-url", + output={"annotations_count": 1}, + ), + "check-runs/30/annotations?per_page=100": [ + dict( + title=engine["HISTORY_TITLE"], + annotation_level="notice", + message=f"v1:40:{locator['archive_sha256']}:{locator['content_sha256']}", + ) + ], + "actions/artifacts/40": dict( + id=40, + expired=False, + size_in_bytes=len(archive), + digest="sha256:" + locator["archive_sha256"], + workflow_run={"id": 10, "head_sha": "a" * 40}, + ), + "actions/artifacts/40/zip": archive, + } + + def api(repository, suffix, **_): + assert repository == "owner/repo" + return data[suffix] + + monkeypatch.setitem(engine["verify_history"].__globals__, "history_api", api) + arguments = dict( + repository="owner/repo", + source="a" * 40, + project="app", + candidate_state=record["candidate_state"], + trusted_workflow_sha="d" * 40, + expected_job_name=job_name, + ) + return engine, record, report, locator, data, arguments + + +def test_sealed_history_retains_original_cve_rows_and_exact_artifact(sealed_history): + engine, record, report, locator, _, arguments = sealed_history + assert engine["history_record"](report) == record + assert engine["verify_history"](locator, **arguments) == record + + +def test_history_binds_trigger_and_planned_checkout_separately(sealed_history): + engine, record, _, locator, data, arguments = sealed_history + data["actions/runs/10"]["head_sha"] = "b" * 40 + data["actions/jobs/20"]["head_sha"] = "b" * 40 + data["check-runs/30"]["head_sha"] = "b" * 40 + data["actions/artifacts/40"]["workflow_run"]["head_sha"] = "b" * 40 + assert engine["verify_history"](locator, **arguments) == record + + +@pytest.mark.parametrize("mismatch", ["job", "check", "artifact", "checkout", "run"]) +def test_history_rejects_mismatched_trigger_or_checkout(sealed_history, mismatch): + engine, _, _, locator, data, arguments = sealed_history + data["actions/runs/10"]["head_sha"] = "b" * 40 + data["actions/jobs/20"]["head_sha"] = "b" * 40 + data["check-runs/30"]["head_sha"] = "b" * 40 + data["actions/artifacts/40"]["workflow_run"]["head_sha"] = "b" * 40 + if mismatch == "job": + data["actions/jobs/20"]["head_sha"] = arguments["source"] + elif mismatch == "check": + data["check-runs/30"]["head_sha"] = arguments["source"] + elif mismatch == "artifact": + data["actions/artifacts/40"]["workflow_run"]["head_sha"] = arguments["source"] + elif mismatch == "checkout": + arguments["source"] = "c" * 40 + else: + data["actions/runs/10"]["head_sha"] = "not-a-revision" + with pytest.raises(ValueError): + engine["verify_history"](locator, **arguments) + + +def test_locate_history_omits_opaque_record_from_human_report( + sealed_history, tmp_path, monkeypatch +): + import hashlib + from pathlib import Path + + engine, record, sealed, locator, _, _ = sealed_history + project = tmp_path / "app" + project.mkdir() + (project / "package.json").write_text('{"name":"app"}\n') + (project / "package-lock.json").write_text('{"lockfileVersion":3}\n') + canonical = sealed[sealed.index(b"" + with pytest.raises(ValueError): + engine["history_record"](report) + + +@pytest.mark.parametrize("validate_only", [False, True]) +def test_producer_never_installs_or_runs_project_checks( + tmp_path, monkeypatch, validate_only +): + import runpy + from pathlib import Path + from test_dependency_audit import fake_npm, npm_project + + project = tmp_path / "app" + project.mkdir() + npm_project.__wrapped__(project) + commands = fake_npm(monkeypatch) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + monkeypatch.setenv("NODE_AUDIT_NODE_VERSION", "24.22.1") + monkeypatch.setenv("NODE_AUDIT_MANAGER_VERSION", "11.9.0") + monkeypatch.setattr(subprocess, "check_output", lambda *a, **kw: "a" * 40 + "\n") + engine = runpy.run_path( + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py") + ) + report = engine["prepare"]( + Path("app"), + prepare_only=True, + maintenance_mode="security-only", + validate_only=validate_only, + ) + assert not any(command[0] in {"ci", "run"} for command in commands) + assert all( + "--lockfile-only" in command or "--package-lock-only" in command + for command in commands + if command[0] == "install" + ) + record = engine["history_record"](report.encode()) + assert record["repository"] == "owner/repo" + assert record["source"] == "a" * 40 + assert record["before"]["vulnerabilities"]["transitive"]["severity"] == "high" + assert ( + "Committed dependency validation · Pending" + if validate_only + else "Validation pending" + ) in report.splitlines()[0] + + +def test_current_publisher_resolves_only_expected_producer(sealed_history, monkeypatch): + from pathlib import Path + + engine, record, report, locator, data, _ = sealed_history + original = data["actions/jobs/20"] + sibling = dict( + original, + id=21, + name="Project validation", + check_run_url="https://api.github.com/repos/owner/repo/check-runs/31", + ) + data["actions/runs/10/jobs?per_page=100"] = dict( + total_count=2, jobs=[original, sibling] + ) + data["check-runs/31/annotations?per_page=100"] = data[ + "check-runs/30/annotations?per_page=100" + ] + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + monkeypatch.setenv("GITHUB_RUN_ID", "10") + monkeypatch.setitem( + engine["locate_current_history"].__globals__, + "check_prepared_history", + lambda actual, _: record if actual == report else None, + ) + assert ( + engine["locate_current_history"]( + report, Path("app"), trusted_workflow_sha="d" * 40, prefix="node-security" + ) + == locator + ) + data["check-runs/30/annotations?per_page=100"] = [] + with pytest.raises(ValueError, match="unique artifact seal"): + engine["locate_current_history"]( + report, Path("app"), trusted_workflow_sha="d" * 40, prefix="node-security" + ) + sibling["name"] = original["name"] + with pytest.raises(ValueError, match="ambiguous expected producer"): + engine["locate_current_history"]( + report, Path("app"), trusted_workflow_sha="d" * 40, prefix="node-security" + ) + + +def test_producer_and_validation_keep_separate_permissions_and_artifacts(): + import yaml + from pathlib import Path + + value = yaml.safe_load( + (Path(__file__).parents[2] / ".github/workflows/node-audit.yml").read_text() + ) + producer, validation = value["jobs"]["audit"], value["jobs"]["validation"] + assert producer["permissions"] == validation["permissions"] == {"contents": "read"} + for namespace in ["npm", "pnpm"]: + for setting in ["ignore_scripts", "ignore_pnpmfile", "lockfile_only"]: + assert producer["env"][namespace + "_config_" + setting] == "true" + assert "env" not in validation + assert validation["needs"] == "audit" + checks = next( + step for step in validation["steps"] if step.get("id") == "validation" + ) + assert checks["continue-on-error"] == "${{ inputs.operation != 'validate' }}" + downloads = [ + step["with"] + for step in validation["steps"] + if step.get("uses", "").startswith("actions/download-artifact@") + ] + assert [step["artifact-ids"] for step in downloads] == [ + "${{ needs.audit.outputs.dependencies_artifact_id }}", + "${{ needs.audit.outputs.node_update_artifact_id }},${{ needs.audit.outputs.report_artifact_id }}", + ] + assert "node_update_artifact_id" in producer["outputs"] + assert "history-seal" not in value["on"]["workflow_call"]["inputs"] + + +@pytest.fixture(params=["npm", "pnpm"]) +def prepared_validation(request, sealed_history, tmp_path, monkeypatch): + import base64 + from pathlib import Path + + engine, original, *_ = sealed_history + project = tmp_path / "app" + project.mkdir() + selected = request.param + version = "11.21.0" if selected == "pnpm" else "11.9.0" + (project / "package.json").write_text( + json.dumps( + {"packageManager": f"{selected}@{version}", "scripts": {"build": "true"}} + ) + ) + if selected == "pnpm": + (project / "pnpm-lock.yaml").write_text( + "lockfileVersion: '9.0'\nimporters:\n .: {}\npackages: {}\n" + ) + (project / "pnpm-workspace.yaml").write_text("packages: ['.']\n") + else: + (project / "package-lock.json").write_text('{"lockfileVersion": 3}\n') + monkeypatch.chdir(tmp_path) + for args in [ + ["init", "-q"], + ["add", "app"], + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "-qm", + "base", + ], + ]: + subprocess.run(["git", *args], check=True, cwd=tmp_path) + record = dict( + original, + manager=selected, + manager_version=version, + candidate_state=engine["dependency_state"](engine["valid_snapshot"](project)), + source=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + ) + report = tmp_path / "dependency-audit-pr.md" + + def seal(): + report.write_bytes( + b"Report\n\n" + ) + + seal() + binary = tmp_path / "bin" + binary.mkdir() + log = tmp_path / "commands.jsonl" + audit_state = tmp_path / "audit-count" + stub = ( + f"#!{sys.executable}\n" + "import json, os, sys\nfrom pathlib import Path\n" + "name = Path(sys.argv[0]).name\nargs = sys.argv[1:]\n" + "with open(os.environ['COMMAND_LOG'], 'a') as f:\n" + " f.write(json.dumps([name, *args]) + '\\n')\n" + "if name == 'npm' and args[:2] == ['install', '--global'] and os.environ.get('npm_config_package_lock_only') == 'true':\n" + " sys.exit(17)\n" + "if args[-1:] == ['--version']:\n" + " print(os.environ['TEST_NODE'] if name == 'node' else os.environ['TEST_MANAGER'])\n" + " if name != 'node': sys.exit(int(os.environ.get('TEST_MANAGER_EXIT', '0')))\n" + "elif 'audit' in args:\n" + " count = int(Path(os.environ['AUDIT_STATE']).read_text()) if Path(os.environ['AUDIT_STATE']).exists() else 0\n" + " Path(os.environ['AUDIT_STATE']).write_text(str(count + 1))\n" + " mode = os.environ.get('TEST_AUDIT_MODE', '')\n" + " phase = 'initial' if count == 0 else 'final'\n" + " if mode == phase + '-unavailable': print('registry unavailable'); sys.exit(1)\n" + " elif mode == phase + '-malformed':\n" + " print(json.dumps({'advisories': []} if name == 'corepack' else {'vulnerabilities': {'broken': {}}, 'metadata': {'vulnerabilities': {'total': 1}}}))\n" + " else: print(json.dumps({'advisories': {}, 'vulnerabilities': {}, 'metadata': {'vulnerabilities': {'total': 0}}}))\n" + ) + for name in ["node", "npm", "corepack"]: + executable = binary / name + executable.write_text(stub) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(binary) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + monkeypatch.setenv("COMMAND_LOG", str(log)) + monkeypatch.setenv("AUDIT_STATE", str(audit_state)) + monkeypatch.setenv("TEST_NODE", "v24.22.1") + monkeypatch.setenv("TEST_MANAGER", version) + output = tmp_path / "outputs" + envfile = tmp_path / "environment" + command = [ + sys.executable, + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py"), + "--directory", + "app", + "--report", + str(report), + "--check-prepared", + ] + return command, record, seal, report, envfile, output, log, selected, version + + +def test_prepared_toolchain_export_and_validation_run_real_cli(prepared_validation): + command, record, _, report, envfile, output, log, selected, version = ( + prepared_validation + ) + before = report.read_bytes() + result = subprocess.run( + command + ["--github-env", str(envfile)], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + values = dict(line.split("=", 1) for line in envfile.read_text().splitlines()) + assert values == { + "NODE_AUDIT_NODE_VERSION": record["node"], + "NODE_AUDIT_MANAGER": selected, + "NODE_AUDIT_MANAGER_VERSION": version, + } + assert report.read_bytes() == before + assert not log.exists(), ( + "Configuration export must not run project or manager commands" + ) + import yaml + from pathlib import Path + + steps = yaml.safe_load( + (Path(__file__).parents[1] / "audit/action.yml").read_text() + )["runs"]["steps"] + export = next( + step + for step in steps + if "--check-prepared" in step.get("run", "") + and "--github-env" in step.get("run", "") + ) + setup_index = next( + i + for i, step in enumerate(steps) + if step.get("uses", "").startswith("actions/setup-node@") + ) + assert steps.index(export) < setup_index + setup = next( + step + for step in steps + if step.get("name") == "Enable the project's pinned package manager" + ) + assert setup["env"]["npm_config_package_lock_only"] == "false" + setup_env = { + **os.environ, + **setup["env"], + "PACKAGE_MANAGER": selected, + "PACKAGE_MANAGER_VERSION": version, + "npm_config_package_lock_only": "false", + } + subprocess.run( + ["bash", "-c", setup["run"]], + check=True, + env=setup_env, + ) + result = subprocess.run( + command + ["--github-output", str(output)], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + assert output.read_text() == "validation_passed=true\n" + commands = [json.loads(line) for line in log.read_text().splitlines()] + assert commands[0] == ( + ["corepack", "enable"] + if selected == "pnpm" + else [ + "npm", + "install", + "--global", + f"npm@{version}", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ] + ) + assert ["node", "--version"] in commands + assert ( + ["corepack", "pnpm", "--version"] + if selected == "pnpm" + else ["npm", "--version"] + ) in commands + + +@pytest.mark.parametrize( + ("audit_mode", "diagnostic"), + [ + ("initial-unavailable", "Initial audit: Audit unavailable (exit 1)."), + ( + "initial-malformed", + "Initial audit: Audit unavailable: malformed", + ), + ("final-unavailable", "Final audit: Audit unavailable (exit 1)."), + ("final-malformed", "Final audit: Audit unavailable: malformed"), + ], +) +def test_prepared_validation_fails_closed_on_audit_errors( + prepared_validation, monkeypatch, audit_mode, diagnostic +): + command, _, _, report, _, output, log, selected, _ = prepared_validation + original = report.read_bytes() + producer_artifact = report.parent / "sealed-producer-report.md" + producer_artifact.write_bytes(original) + monkeypatch.setenv("TEST_AUDIT_MODE", audit_mode) + + result = subprocess.run( + command + ["--github-output", str(output)], + capture_output=True, + text=True, + ) + + assert result.returncode != 0, result.stdout + assert output.read_text() == "validation_passed=false\n" + local_report = report.read_text() + assert local_report.startswith("**Committed dependency validation · Failed**") + assert "validation_passed=true" not in local_report + assert "## Security audit incomplete" in local_report + assert diagnostic in local_report + assert "- Frozen install: passed." in local_report + assert "- Project checks: passed." in local_report + assert producer_artifact.read_bytes() == original + commands = [json.loads(line) for line in log.read_text().splitlines()] + assert ( + ["corepack", "pnpm", "install", "--frozen-lockfile"] + if selected == "pnpm" + else ["npm", "ci", "--no-audit"] + ) in commands + assert ( + ["corepack", "pnpm", "run", "build"] + if selected == "pnpm" + else ["npm", "run", "build"] + ) in commands + + +@pytest.mark.parametrize( + "mutation", + [ + "node", + "manager", + "manager-failure", + "files", + "source", + "unsafe-version", + "wrong-manager", + ], +) +def test_prepared_validation_rejects_drift_before_project_commands( + prepared_validation, monkeypatch, mutation +): + command, record, seal, report, envfile, output, log, selected, _ = ( + prepared_validation + ) + if mutation == "node": + monkeypatch.setenv("TEST_NODE", "v24.23.0") + elif mutation == "manager": + monkeypatch.setenv("TEST_MANAGER", "99.0.0") + elif mutation == "manager-failure": + monkeypatch.setenv("TEST_MANAGER_EXIT", "1") + elif mutation == "files": + from pathlib import Path + + with Path("app/package.json").open("a") as file: + file.write("\n") + elif mutation == "source": + record["source"] = "b" * 40 + elif mutation == "unsafe-version": + record["manager_version"] = "11.9.0\nINJECTED=value" + else: + record["manager"] = "npm" if selected == "pnpm" else "pnpm" + seal() + before = report.read_bytes() + export_only = mutation in {"files", "source", "unsafe-version", "wrong-manager"} + args = ( + ["--github-env", str(envfile)] + if export_only + else ["--github-output", str(output)] + ) + result = subprocess.run(command + args, capture_output=True, text=True) + assert result.returncode != 0, result.stdout + assert report.read_bytes() == before + assert not envfile.exists() and not output.exists() + commands = ( + [json.loads(line) for line in log.read_text().splitlines()] + if log.exists() + else [] + ) + assert all(command[-1:] == ["--version"] for command in commands) + + +def test_verified_producer_seals_concrete_node_version(tmp_path): + import yaml + from pathlib import Path + + steps = yaml.safe_load( + (Path(__file__).parents[1] / "audit/action.yml").read_text() + )["runs"]["steps"] + verify = next( + step + for step in steps + if step.get("name") == "Verify the declared toolchain before dependency changes" + ) + binary = tmp_path / "bin" + binary.mkdir() + for name, content in {"python": "exit 0", "node": "echo v24.22.1"}.items(): + executable = binary / name + executable.write_text("#!/bin/bash\n" + content + "\n") + executable.chmod(0o755) + envfile = tmp_path / "environment" + subprocess.run( + ["bash", "-c", verify["run"]], + check=True, + env={ + **os.environ, + "PATH": str(binary) + os.pathsep + os.environ["PATH"], + "NODE_AUDIT_NODE_VERSION": "24", + "GITHUB_ACTION_PATH": str(tmp_path), + "RUNNER_TEMP": str(tmp_path), + "AUDIT_KIND": "security", + "GITHUB_ENV": str(envfile), + }, + ) + assert envfile.read_text() == "NODE_AUDIT_NODE_VERSION=24.22.1\n" + + +def test_prepared_validation_reconstructs_node_candidate_before_project_check( + tmp_path, monkeypatch +): + import base64 + import runpy + from pathlib import Path + + engine = runpy.run_path( + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py") + ) + project = tmp_path / "app" + project.mkdir() + (tmp_path / ".github").mkdir() + (tmp_path / ".github/node-maintenance.json").write_text( + json.dumps({"directory": "app", "toolchain": {"node": "project"}}) + ) + (project / "package.json").write_text( + json.dumps({"packageManager": "pnpm@11.21.0", "scripts": {"build": "true"}}) + ) + (project / "pnpm-lock.yaml").write_text( + "lockfileVersion: '9.0'\nimporters:\n .: {}\npackages: {}\n" + ) + (project / "pnpm-workspace.yaml").write_text("packages: ['.']\n") + (project / ".nvmrc").write_text("24.13.0\n") + monkeypatch.chdir(tmp_path) + for args in [ + ["init", "-q"], + ["add", "app"], + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "-qm", + "base", + ], + ]: + subprocess.run(["git", *args], check=True, cwd=tmp_path) + record = { + "version": 1, + "repository": "owner/repo", + "source": subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip(), + "project": "app", + "candidate_state": engine["dependency_state"]( + engine["valid_snapshot"](project) + ), + "before_state": {"package.json": "c" * 64}, + "manager": "pnpm", + "node": "24.22.1", + "manager_version": "11.21.0", + "validation_script": "build", + "before": {"original": "CVE-2026-12345"}, + "after": {}, + "package_report": "Original row", + "fixed_ids": ["GHSA-aaaa-bbbb-cccc"], + "initial_ids": ["GHSA-aaaa-bbbb-cccc"], + "remaining_ids": [], + } + report = tmp_path / "dependency-audit-pr.md" + report.write_bytes( + b"Report\n\n" + ) + plan = tmp_path / "node-audit-node-update.json" + plan.write_text( + json.dumps( + { + "schema_version": 1, + "directory": "app", + "version": "24.14.0", + "changes": [ + { + "path": "app/.nvmrc", + "before": "24.13.0\n", + "after": "24.14.0\n", + } + ], + "message": "", + } + ) + ) + binary = tmp_path / "bin" + binary.mkdir() + (binary / "node").write_text("#!/bin/sh\nprintf 'v24.22.1\\n'\n") + (binary / "corepack").write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys\n" + "args = sys.argv[1:]\n" + "if args and args[0] == 'pnpm': args = args[1:]\n" + "if args[-1:] == ['--version']: print('11.21.0')\n" + "elif 'audit' in args: print(json.dumps({'advisories': {}, 'vulnerabilities': {}, 'metadata': {'vulnerabilities': {'total': 0}}}))\n" + "elif args[:2] == ['run', 'build']:\n" + " if os.environ.get('MUTATE_NODE'): pathlib.Path('.nvmrc').write_text('24.13.0\\n')\n" + " raise SystemExit(0 if pathlib.Path('.nvmrc').read_text() == '24.14.0\\n' else 1)\n" + ) + for name in ("node", "corepack"): + (binary / name).chmod(0o755) + monkeypatch.setenv("PATH", str(binary) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + command = [ + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py"), + "--directory", + "app", + "--report", + str(report), + "--check-prepared", + "--node-update", + str(plan), + ] + namespace = engine["main"].__globals__ + original_run_path = namespace["runpy"].run_path + + def load_node_proposal(path, *args, **kwargs): + loaded = original_run_path(path, *args, **kwargs) + if str(path).endswith("node_proposal.py"): + loaded["SECURITY"]["latest_security_release"] = lambda _: "24.14.0" + return loaded + + monkeypatch.setattr(namespace["runpy"], "run_path", load_node_proposal) + + def invoke(*args): + monkeypatch.setattr(sys, "argv", [*command, *args]) + namespace["main"]() + + envfile = tmp_path / "environment" + invoke("--github-env", str(envfile)) + assert (project / ".nvmrc").read_text() == "24.14.0\n" + sealed_report = report.read_bytes() + output = tmp_path / "outputs" + invoke("--github-output", str(output)) + assert output.read_text() == "validation_passed=true\n" + report.write_bytes(sealed_report) + output.unlink() + monkeypatch.setenv("MUTATE_NODE", "1") + with pytest.raises( + ValueError, match="Project validation changed a proposed Node declaration" + ): + invoke("--github-output", str(output)) + assert not output.exists() + + +@pytest.mark.parametrize("declaration", [".nvmrc", ".node-version", "local-config"]) +def test_node_candidate_reconstruction_covers_declaration_forms(tmp_path, declaration): + import runpy + from pathlib import Path + + proposal = runpy.run_path( + str(Path(__file__).parents[1] / "runtime/node_proposal.py") + ) + project = tmp_path / "app" + project.mkdir() + (tmp_path / ".github").mkdir() + config = {"directory": "app", "toolchain": {"node": "project"}} + if declaration == "local-config": + config["toolchain"]["node"] = "24.13.0" + else: + (project / declaration).write_text("24.13.0\n") + (tmp_path / ".github/node-maintenance.json").write_text(json.dumps(config)) + changes = proposal["declaration_changes"](tmp_path, project, "24.14.0") + assert changes and changes[0]["path"] == ( + ".github/node-maintenance.json" + if declaration == "local-config" + else f"app/{declaration}" + ) + plan = {"changes": changes} + proposal["write_changes"](tmp_path, plan) + proposal["verify_candidate"](tmp_path, plan) diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py index c0cd0cf..e61767c 100644 --- a/node/tests/test_fleet_setup.py +++ b/node/tests/test_fleet_setup.py @@ -192,6 +192,32 @@ def test_fleet_accepts_coordinated_installation_and_checks_trigger_contract(prob assert result["status"] == ("ok" if problem is None else "failed"), result +@pytest.mark.parametrize("coordinated", [False, True]) +@pytest.mark.parametrize("permission", ["actions", "checks"]) +def test_fleet_rejects_missing_publisher_evidence_permissions(coordinated, permission): + github = GitHub() + files = github.repos["owner/project"] + if coordinated: + caller = yaml.safe_load( + (ROOT / "node/coordinated-caller.yml") + .read_text() + .replace("__CENTRAL_REPOSITORY__", "owner/actions") + .replace("__CENTRAL_REVISION__", SHA) + .replace("__DEFAULT_BRANCH__", "main") + ) + caller["on"]["schedule"] = [ + {"cron": cron} for cron in ["0 2 * * 1", "0 2 1 */3 *"] + ] + else: + caller = yaml.safe_load(files[".github/workflows/dependency-audit.yml"]) + publisher = caller["jobs"]["maintenance" if coordinated else "pull-request"] + publisher["permissions"].pop(permission, None) + files[".github/workflows/dependency-audit.yml"] = yaml.safe_dump(caller).encode() + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == "failed", result + assert "permissions" in result["diagnostic"] + + @pytest.mark.parametrize("manager", ["npm", "pnpm"]) def test_healthy_fleet_without_running_project_commands(monkeypatch, manager): def forbidden(*args, **kwargs): diff --git a/node/tests/test_integration_contract.py b/node/tests/test_integration_contract.py index 6193dd2..fc8620b 100644 --- a/node/tests/test_integration_contract.py +++ b/node/tests/test_integration_contract.py @@ -20,7 +20,12 @@ def test_caller_separates_audit_and_publisher_authority(): "contents": "read", } assert "secrets" not in audit - assert publisher["permissions"] == {"contents": "write", "pull-requests": "write"} + assert publisher["permissions"] == { + "contents": "write", + "pull-requests": "write", + "actions": "read", + "checks": "read", + } assert set(publisher["secrets"]) == {"app-private-key"} assert publisher["needs"] == "audit" assert audit["uses"].endswith("@__CENTRAL_REVISION__") diff --git a/node/tests/test_selected_repairs.py b/node/tests/test_selected_repairs.py index c5a18d1..29e019a 100644 --- a/node/tests/test_selected_repairs.py +++ b/node/tests/test_selected_repairs.py @@ -22,6 +22,58 @@ REVISION = {"head": "a" * 40, "base": "b" * 40} +@pytest.mark.parametrize("override", ["2.14.1", "2.14.2", "^2.14.1", None]) +def test_security_candidate_cannot_bypass_retained_exact_direct_pin(tmp_path, override): + manifest = { + "packageManager": "pnpm@11.21.0", + "dependencies": {"launch-editor": "^2.14.1"}, + } + policy = { + "packages": ["."], + "overrides": {"launch-editor": override} if override else {}, + } + lock = { + "lockfileVersion": "9.0", + "importers": { + ".": { + "dependencies": { + "launch-editor": {"specifier": "2.14.1", "version": "2.14.1"} + } + } + }, + "packages": {"launch-editor@2.14.1": {}}, + } + (tmp_path / "package.json").write_text(json.dumps(manifest)) + (tmp_path / "pnpm-workspace.yaml").write_text(yaml.safe_dump(policy)) + (tmp_path / "pnpm-lock.yaml").write_text(yaml.safe_dump(lock)) + original = ENGINE["valid_snapshot"](tmp_path) + # Actual hosted pnpm audit --fix=update output: the preserved pin and + # lockfile override remain 2.14.1, but the direct candidate becomes 2.14.2. + manifest["dependencies"]["launch-editor"] = "2.14.2" + lock["importers"]["."]["dependencies"]["launch-editor"] = { + "specifier": "2.14.2", + "version": "2.14.2", + } + lock["packages"] = {"launch-editor@2.14.2": {}} + (tmp_path / "package.json").write_text(json.dumps(manifest)) + (tmp_path / "pnpm-lock.yaml").write_text(yaml.safe_dump(lock)) + notes = [] + rejected = ENGINE["restore_invalid_state"]( + tmp_path, original, notes, "Security fix", security_fix=True + ) + assert rejected == (override == "2.14.1") + if rejected: + assert ENGINE["valid_snapshot"](tmp_path) == original + assert "pin" in notes[0] + else: + assert ( + json.loads((tmp_path / "package.json").read_text())["dependencies"][ + "launch-editor" + ] + == "2.14.2" + ) + + @pytest.mark.parametrize("case", FIXTURES["cases"], ids=lambda case: case["name"]) def test_shared_app_fixtures(case): if case.get("invalid"): @@ -904,12 +956,19 @@ def test_repair_workflow_keeps_preparation_separate_from_read_only_validation(): assert "publish" not in workflow["jobs"] -def test_parser_install_isolated_from_checkout_module_shadowing(tmp_path): - action = yaml.safe_load((ROOT / "node/repair/action.yml").read_text()) +@pytest.mark.parametrize( + "action_path,step_name", + [ + ("node/repair/action.yml", "Install configuration and audit parser"), + ("node/audit/action.yml", "Install configuration and audit report parser"), + ], +) +def test_parser_install_isolated_from_checkout_module_shadowing( + tmp_path, action_path, step_name +): + action = yaml.safe_load((ROOT / action_path).read_text()) command = next( - step["run"] - for step in action["runs"]["steps"] - if step.get("name") == "Install configuration and audit parser" + step["run"] for step in action["runs"]["steps"] if step.get("name") == step_name ) assert command == "python -I -m pip install PyYAML==6.0.3" legacy_command = command.replace("python -I -m pip", "python -m pip", 1) diff --git a/repair-app/pilot/README.md b/repair-app/pilot/README.md index df8a933..8e6a3ab 100644 --- a/repair-app/pilot/README.md +++ b/repair-app/pilot/README.md @@ -79,3 +79,30 @@ Keep both artifact IDs and their separate contents in the acceptance record. Publication is not enabled yet. These gates do not pass G-REPAIR or upgrade the PR. Publication and retention are tested in their owning slices before the final Hydra rollout. + +## Initial normal-audit evidence acceptance + +Use the isolated demo repository for the reviewed producer slice. Keep the +five repair dispatch inputs and the three-member C4 artifact unchanged. The +normal-audit probe is a separate workflow; it is not a new repair dispatch route. + +Run the reviewed `node-audit.yml` workflow with a fixed artifact prefix and +retain its exact source ref, audited head, report artifact ID, API/archive and +content SHA256 hashes, producer job ID and server-derived Check URL. Verify that +the producer uploaded the report before its seal notice and before the separate +project-validation job. Poison lifecycle, local/global pnpmfile and project-check +sentinels must not run on the producer. Validation must consume the exact prepared +dependency hashes, and must not replace the original report artifact. + +Use a later read-only run with Actions and Checks read permission to verify the +exact original artifact and producer Check annotation. In the original run, +include a sibling job that uploads a forged report and emits the same notice +title. A forged locator for that sibling, even with the same run and referenced +workflow, must reject. Retain both actual outcomes and the immutable artifact +metadata. The normal publisher also needs those read grants to retain native +commit locators; it grants no extra permissions to either untrusted runner. + +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.