diff --git a/.github/workflows/node-audit.yml b/.github/workflows/node-audit.yml index 419c35c..6401160 100644 --- a/.github/workflows/node-audit.yml +++ b/.github/workflows/node-audit.yml @@ -61,3 +61,16 @@ jobs: uses: $/node/configuration-issue env: GH_TOKEN: ${{ github.token }} + - name: Read local patch status from this run + if: ${{ always() && !cancelled() }} + id: local-patches + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: node-audit-local-patches + path: ${{ runner.temp }}/node-audit-local-patches + - name: Maintain the local patch notification issue + if: ${{ always() && !cancelled() && steps.local-patches.outcome == 'success' }} + uses: $/node/local-patch-issue + env: + GH_TOKEN: ${{ github.token }} diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index 32a84a9..b14e682 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -249,6 +249,7 @@ installation, validation, and the audit result: | --- | --- | | Validated dependency changes | Opens or updates the single maintenance PR. | | Dependency changes with failed installation or checks | Retains a draft proposal when possible and fails the workflow. | +| Incomplete or unavailable security audit | Fails the run and skips publication, even if project checks pass; retains the diagnostic summary and report artifact. | | No dependency changes | Creates no PR and leaves any existing proposal untouched; read the current run summary or report artifact. | | Invalid configuration | Stops before dependency changes and updates one reusable configuration issue. Successful preflight closes it. | @@ -277,6 +278,7 @@ See the [tested versions](../node/runtime/toolchain.json). | Setting | Behavior | | --- | --- | | `validationScript` | Existing nonempty package script; default `"build"`. Names start with a letter or digit and contain only letters, digits, `:`, `_`, `.`, or `-`. | +| `localPatches` | Project-local declarations identifying advisories mitigated by local patches. These are maintainer assertions, never inherited or counted as independently confirmed security fixes. See [local patches](#local-security-patches). | | `minimumReleaseAgeDays` | Minimum release cooldown in days; nonnegative integer, default `10`. pnpm workspace policy must be at least this strict. | | `maintenance.mode` | `"security-only"` or `"security-and-maintenance"`. A schedule object defaults to security-only unless a mode is supplied locally or inherited. | | `maintenance.upgradeScope` | Maximum routine direct upgrade level: `"patch"`, `"minor"` including patches, or `"major"` including both (default). Security fixes are handled separately. | @@ -306,6 +308,44 @@ subject to the repository's other policies. The maintenance run already performs the frozen install and selected project checks; neither authentication mode waits for additional PR CI. See [GitHub's token behavior](https://docs.github.com/en/actions/concepts/security/github_token). +## Local security patches + +Record a maintainer assertion in `.github/node-maintenance.json` using an advisory +ID alone, or `[advisory, comment]` when you want to add context: + +```json +{ + "localPatches": [ + "GHSA-5p2g-fcmc-qvqq", + ["GHSA-w3rx-r6r6-pgpr", "Covered by regression tests."] + ] +} +``` + +Merge this field into the existing configuration. The optional comment is a +single-line string of at most 2048 characters. Up to 32 distinct advisories are +supported. Assertions stay local to this repository; they are not inherited. +Package names and affected versions come from audit and lockfile evidence. + +This records your assertion; the Action does not inspect, apply, or certify +arbitrary patches. Raw advisory findings stay visible and assertions never count +as confirmed security fixes. pnpm ignore rules stay unchanged while audits collect +the complete findings. An assertion whose advisory is absent from a complete +audit remains recorded as **not reported**, rather than being certified fixed. + +When a newer stable official fix is verified, one **Node local patches: official +fixes available** issue is created, updated, or reopened for the repository. +Availability does not prove compatibility or release-cooldown eligibility. Review +the upstream fix and everything your patch changes before upgrading, removing the +patch and obsolete assertion, and rerunning. **Patches are never removed automatically.** +A complete check with no remaining notifications closes the issue; unavailable +evidence does not close it. The reporter uses the existing `issues: write` +permission without checking out or executing project code. + +Use a workflow revision whose `node/contract.json` includes `local_patches` +support. Update both caller pins together; older version-6 revisions do not +accept `localPatches`. + ## Update or migrate an installation For an earlier Docusaurus installation, use the [migration checklist](node-migration.md). diff --git a/docs/shared-defaults.md b/docs/shared-defaults.md index 1898344..f6fda01 100644 --- a/docs/shared-defaults.md +++ b/docs/shared-defaults.md @@ -123,6 +123,8 @@ policy, if used, must satisfy those settings. Directory, validation script, dependency declarations, security exceptions, deployment, and credentials remain local. They are not inherited. Shared constraints are checked against declarations; they never rewrite them. +`localPatches` declarations are also project-local and never inherited; see +[local security patches](node-maintenance.md#local-security-patches). ## Use another project as the authoritative source diff --git a/node/audit/action.yml b/node/audit/action.yml index b4f2d1a..87e5149 100644 --- a/node/audit/action.yml +++ b/node/audit/action.yml @@ -65,11 +65,18 @@ runs: --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" - - cat "$RUNNER_TEMP/dependency-audit-pr.md" >> "$GITHUB_STEP_SUMMARY" + --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" + --local-patches-report "$RUNNER_TEMP/node-audit-local-patches.json" ' shell: bash + - name: Retain the audit summary even when the audit fails + if: ${{ always() && !cancelled() }} + run: | + if [ -f "$RUNNER_TEMP/dependency-audit-pr.md" ]; then + cat "$RUNNER_TEMP/dependency-audit-pr.md" >> "$GITHUB_STEP_SUMMARY" + fi + shell: bash - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: dependency-audit-dependencies @@ -84,7 +91,15 @@ runs: ' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: ${{ always() && !cancelled() }} with: name: dependency-audit-report - if-no-files-found: error + if-no-files-found: ignore path: ${{ runner.temp }}/dependency-audit-pr.md + - name: Retain local patch status for the issue reporter + if: ${{ always() && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: node-audit-local-patches + if-no-files-found: ignore + path: ${{ runner.temp }}/node-audit-local-patches.json diff --git a/node/contract.json b/node/contract.json index e40afbc..3dc8d0e 100644 --- a/node/contract.json +++ b/node/contract.json @@ -67,6 +67,13 @@ ], "project_scope": "one standalone package directory and lockfile", "validation_script_default": "build", + "local_patches": { + "config_key": "localPatches", + "inherited": false, + "assertion": "maintainer-declared, not independently certified", + "automatic_patch_removal": false, + "notification": "one issue per repository for published official fixes" + }, "breaking_changes": { "previous_component": "docusaurus", "old_configuration": ".github/docusaurus.json", diff --git a/node/local-patch-issue/action.yml b/node/local-patch-issue/action.yml new file mode 100644 index 0000000..873e54d --- /dev/null +++ b/node/local-patch-issue/action.yml @@ -0,0 +1,7 @@ +name: Report official fixes for local patches +description: Maintain one issue without modifying patches or executing project code. +runs: + using: composite + steps: + - shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/local_patch_issue.py" --report "$RUNNER_TEMP/node-audit-local-patches/node-audit-local-patches.json" diff --git a/node/maintenance.md b/node/maintenance.md index ba5d739..1cafcbb 100644 --- a/node/maintenance.md +++ b/node/maintenance.md @@ -32,6 +32,14 @@ type. Security repairs explicitly disable force mode, including inherited `force=true` settings. These overrides apply to audit and repair commands; project validation still uses the project's install settings. +For pnpm, audit commands override advisory-ignore lists without rewriting them. +This keeps registry evidence complete while preserving project policy. A +`localPatches` entry records a maintainer's assertion about an advisory, with +an optional comment. Affected packages and versions come from audit and lockfile +evidence. It does not prove patch application or security effectiveness, remove +raw findings, or count as a confirmed security fix. See +[declaring local patches](../docs/node-maintenance.md#local-security-patches). + Upgrade audits attempt security fixes first, then routine stable upgrades. `maintenance.upgradeScope` limits routine **direct** upgrades to `patch`, `minor` (including patches), or `major` (the default, including both). Restricted scopes @@ -69,7 +77,10 @@ the next PR. Nothing merges automatically. The title describes dependency updates and confirmed security fixes. For example, **Node: 9 routine dependency updates, no security fixes** identifies routine work. -If audit evidence is unavailable, the title says **security fixes unconfirmed**. +If initial or final audit evidence is unavailable or incomplete, the run fails +and skips publication. The report identifies the cause and retains the separate +frozen-install and project-check results. Its summary and report artifact remain +available after failure; passing project checks do not make the audit complete. The report leads with proposed versions and validation results. Routine-only headlines omit unrelated security counts. Existing blocked findings with unchanged audit evidence appear as neutral background, with severity and constraints in @@ -79,6 +90,13 @@ Reports retain unresolved findings, pin decisions, validation failures, changed locked version sets, and candidate dependency-file SHA-256 hashes. Grouped logs retain successful and failed command output, including warnings. +For declared local patches, reports also retain the advisory, inferred package/version, +optional maintainer comment, and verified official-fix availability in the +`node-audit-local-patches` artifact. A separate reporter updates one bot-owned +**Node local patches: official fixes available** issue when a newer stable fixed +release is verified. It never removes a patch or claims the release is compatible +with project constraints. Missing evidence cannot close that notification. + | Rerun result | Publisher behavior | | --- | --- | | Changed dependency proposal | Updates the existing PR and branch. | @@ -86,6 +104,7 @@ retain successful and failed command output, including warnings. | Same proposal with new report findings | Updates the body without another dependency commit. | | Same proposal with an outdated title | Corrects the title without rewriting the report or pushing a commit. | | Retained proposal with failed installation or checks | Publishes or updates a draft and fails the workflow. | +| Initial or final security audit unavailable or incomplete | Fails the audit and skips publication; retains the diagnostic summary and report artifact. | | Successful rerun of an existing draft | Leaves it as a draft for human review. | | No qualifying dependency changes | Creates no PR and leaves an existing branch and report unchanged. Read the new run summary or artifacts. | diff --git a/node/runtime/configuration.py b/node/runtime/configuration.py index 9416e8f..57439b7 100644 --- a/node/runtime/configuration.py +++ b/node/runtime/configuration.py @@ -9,11 +9,12 @@ from pathlib import Path SCHEDULING = runpy.run_path(str(Path(__file__).with_name("scheduling.py"))) +LOCAL_PATCHES = runpy.run_path(str(Path(__file__).with_name("local_patches.py"))) MAX_BYTES = 65536 MAX_SOURCES = 8 SHARED_KEYS = {"toolchain", "maintenance", "minimumReleaseAgeDays", "schedule"} -LOCAL_KEYS = {"directory", "validationScript"} +LOCAL_KEYS = {"directory", "validationScript", "localPatches"} def json_object(data, location, max_bytes=MAX_BYTES): @@ -74,6 +75,8 @@ def validate_settings(config, location): unknown = set(config) - SHARED_KEYS - LOCAL_KEYS - {"defaults"} if unknown: raise ValueError(f"{location}: unknown configuration fields: {sorted(unknown)}") + if "localPatches" in config: + LOCAL_PATCHES["validate_declarations"](config["localPatches"]) if "schedule" in config: SCHEDULING["validate_schedule"](config["schedule"]) if "defaults" in config: diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py index b0f55b7..5e4a5ce 100644 --- a/node/runtime/fleet.py +++ b/node/runtime/fleet.py @@ -452,6 +452,13 @@ def check_caller(root, config, selected, github): raise ValueError( "Pinned workflows must declare the supported Node integration version 6" ) + if ( + "localPatches" in config + and contract.get("local_patches", {}).get("config_key") != "localPatches" + ): + raise ValueError( + "Pinned workflows do not support localPatches; update both workflow pins" + ) toolchain = CONFIGURATION["json_object"]( source.read("node/runtime/toolchain.json"), "pinned toolchain support" ).get("supported", {}) diff --git a/node/runtime/local_patch_issue.py b/node/runtime/local_patch_issue.py new file mode 100644 index 0000000..600cb03 --- /dev/null +++ b/node/runtime/local_patch_issue.py @@ -0,0 +1,91 @@ +"""Notify about published fixes for declared local patches using data only.""" + +import argparse +import json +import os +import re +import runpy +import urllib.error +from pathlib import Path + +ISSUES = runpy.run_path(str(Path(__file__).with_name("configuration_issue.py"))) +PATCHES = runpy.run_path(str(Path(__file__).with_name("local_patches.py"))) +MARKER = "" +TITLE = "Node local patches: official fixes available" + + +def update_issue(repository, report, run_url, request=ISSUES["github_request"]): + PATCHES["validate_report"](report) + available = any(item["status"] == "fix-available" for item in report["patches"]) + if not available and ( + not report["complete"] + or any(item["status"] == "unverified" for item in report["patches"]) + ): + return {"operation": "skipped", "reason": "official fix evidence is incomplete"} + preview = PATCHES["render"](report) + if len(preview.encode()) > 48_000: + preview = preview.encode()[:48_000].decode(errors="ignore").rsplit("\n", 1)[0] + preview += ( + "\n\nTable preview truncated. The workflow run's " + "`node-audit-local-patches` artifact contains all entries and comments.\n\n" + ) + body = ( + MARKER + + "\n\n" + + ( + "Published official fixes are available for maintainer-declared local patches.\n\n" + if available + else "No declared local patch currently has a verified newer official fix requiring this notification.\n\n" + ) + + preview + + ( + "The full security audit did not complete. This notification records verified release availability only.\n\n" + if not report["complete"] + else "" + ) + + "Review the upstream fix, project constraints, and release cooldown. Upgrade manually and remove a patch only after checking everything it changes. Remove obsolete localPatches declarations and any corresponding audit-ignore entries, then rerun maintenance.\n\n" + + "Availability does not prove compatibility or certify the local patch. This reporter does not change patch files or project policy.\n\n" + + f"[Workflow run]({run_url})\n\n" + + "This repository reuses one issue for local-patch notifications. Unavailable audit or registry evidence does not close it." + ) + return ISSUES["maintain_issue"]( + repository, + marker=MARKER, + title=TITLE, + body=body, + failing=available, + request=request, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + try: + repository = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if ( + not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) + or any(part in {".", ".."} for part in repository.split("/")) + or not run_id.isdecimal() + ): + raise ValueError( + "Local patch reporter requires the current repository and run ID" + ) + with args.report.open("rb") as stream: + raw = stream.read(PATCHES["MAX_REPORT_BYTES"] + 1) + if len(raw) > PATCHES["MAX_REPORT_BYTES"]: + raise ValueError("Local patch report exceeds its size limit") + result = update_issue( + repository, + json.loads(raw), + f"https://github.com/{repository}/actions/runs/{run_id}", + ) + print(json.dumps(result)) + except (OSError, ValueError, urllib.error.URLError) as error: + parser.exit(1, f"Local patch issue: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/node/runtime/local_patches.py b/node/runtime/local_patches.py new file mode 100644 index 0000000..cf55ea5 --- /dev/null +++ b/node/runtime/local_patches.py @@ -0,0 +1,308 @@ +"""Track advisory assertions and optional comments without certifying local patches.""" + +import html +import json +import re + +MAX_PATCHES = 32 +MAX_ROWS = 256 +MAX_REPORT_BYTES = 8 * 1024 * 1024 +VERSION = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" +PACKAGE = r"(?:@[a-z0-9_.~-]+/)?[a-z0-9_.~-]+" + + +def validate_declarations(values): + """Normalize an advisory ID or [advisory ID, comment] into internal records.""" + if not isinstance(values, list) or len(values) > MAX_PATCHES: + raise ValueError("localPatches must be a list of at most 32 assertions") + result, seen = [], set() + for value in values: + if isinstance(value, str): + advisory, comment = value, "" + elif isinstance(value, list) and len(value) == 2: + advisory, comment = value + else: + raise ValueError( + "localPatches entries must be an advisory ID or [advisory ID, comment]" + ) + if not isinstance(advisory, str) or not re.fullmatch( + r"GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}", advisory + ): + raise ValueError("localPatches requires a GHSA identifier") + if ( + not isinstance(comment, str) + or len(comment) > 2048 + or any(ord(c) < 32 for c in comment) + or any(0xD800 <= ord(c) <= 0xDFFF for c in comment) + ): + raise ValueError( + "localPatches comments must be valid Unicode single-line strings of at most 2048 characters" + ) + if advisory in seen: + raise ValueError("Duplicate localPatches advisory assertion") + seen.add(advisory) + result.append({"advisory": advisory, "comment": comment}) + return result + + +def fixed_versions(data, item, published): + """Use advisory and registry evidence; availability is not compatibility.""" + source = data.get("package_manager", "npm") + vulnerability = data.get("vulnerabilities", {}).get(item["package"], {}) + details = [ + v + for v in vulnerability.get("via", []) + if isinstance(v, dict) + and v.get("url") == "https://github.com/advisories/" + item["advisory"] + and v.get("name", item["package"]) == item["package"] + ] + if source == "pnpm": + details = [ + detail + for detail in details + if f"{item['package']}@{item['version']}" in detail.get("nodes", []) + ] + if not details: + return "not-reported", [] + if source != "pnpm": + vulnerable = set() + for detail in details: + affected = published(f"{item['package']}@{detail['range']}") + if affected is None: + return "unverified", [] + vulnerable.update(affected) + if item["version"] not in vulnerable: + return "unverified", [] + versions = published(item["package"], all_versions=True) + if versions is None: + return "unverified", [] + ordered = sorted( + ( + v + for v in versions - vulnerable + if tuple(map(int, v.split("."))) + > tuple(map(int, item["version"].split("."))) + ), + key=lambda value: tuple(map(int, value.split("."))), + ) + return ("fix-available" if ordered else "no-fix"), ordered[:10] + candidates = set() + for detail in details: + expression = detail.get("patchedRange") + if expression in (None, "", "<0.0.0"): + continue + versions = published(f"{item['package']}@{expression}") + if versions is None: + return "unverified", [] + candidates.update( + v + for v in versions + if tuple(map(int, v.split("."))) + > tuple(map(int, item["version"].split("."))) + ) + ordered = sorted(candidates, key=lambda value: tuple(map(int, value.split(".")))) + return ("fix-available" if ordered else "no-fix"), ordered[:10] + + +def assess(declared, before, after, published, lock): + rows = [] + evidence = before if isinstance(before, dict) else after + for assertion in declared: + found = [] + if isinstance(evidence, dict): + for name, vulnerability in evidence.get("vulnerabilities", {}).items(): + details = [ + detail + for detail in vulnerability.get("via", []) + if isinstance(detail, dict) + and detail.get("url") + == "https://github.com/advisories/" + assertion["advisory"] + and detail.get("name", name) == name + ] + if not details: + continue + if evidence.get("package_manager") == "pnpm": + versions = { + node[len(name) + 1 :].split("(", 1)[0] + for detail in details + for node in detail.get("nodes", []) + if isinstance(node, str) and node.startswith(name + "@") + } + else: + versions = { + package.get("version") + for node in vulnerability.get("nodes", []) + if isinstance( + package := lock.get("packages", {}).get(node), dict + ) + and (package.get("name") or node.rsplit("node_modules/", 1)[-1]) + == name + } + versions = sorted( + version + for version in versions + if isinstance(version, str) and re.fullmatch(VERSION, version) + ) + if not versions: + found.append( + { + **assertion, + "package": name, + "version": "", + "status": "unverified", + "fixedVersions": [], + } + ) + for version in versions: + item = {**assertion, "package": name, "version": version} + status, fixes = fixed_versions(evidence, item, published) + if status != "not-reported": + found.append({**item, "status": status, "fixedVersions": fixes}) + if not found: + found.append( + { + **assertion, + "package": "", + "version": "", + "status": "not-reported" + if isinstance(evidence, dict) + else "unverified", + "fixedVersions": [], + } + ) + rows.extend(found) + if len(rows) > MAX_ROWS: + raise ValueError( + "Local patch audit report exceeds 256 affected package versions" + ) + return { + "schema_version": 1, + "complete": isinstance(before, dict) and isinstance(after, dict), + "patches": rows, + } + + +def render(report): + if not report["patches"]: + return "" + labels = { + "fix-available": "Official fix available; review patch removal", + "no-fix": "No newer stable official fix verified", + "not-reported": "Advisory not reported; assertion retained", + "unverified": "Official fix availability unverified", + } + + def cell(value): + return re.sub( + r"[\\`*_\[\]~!|]", lambda match: f"&#{ord(match[0])};", html.escape(value) + ) + + lines = [ + "## Maintainer-declared local patches", + "", + "These are maintainer assertions, not independently verified security fixes. Raw registry findings remain visible. No patch is removed automatically.", + "", + "| Advisory | Affected package | Comment | Official fix |", + "| --- | --- | --- | --- |", + ] + for item in report["patches"]: + status = labels[item["status"]] + if item["fixedVersions"]: + status += ": " + item["fixedVersions"][0] + package = item["package"] or "Not identified" + if item["version"]: + package += "@" + item["version"] + lines.append( + "| " + + " | ".join( + map(cell, (item["advisory"], package, item["comment"] or "—", status)) + ) + + " |" + ) + return "\n".join(lines) + "\n\n" + + +def validate_report(report): + if ( + not isinstance(report, dict) + or set(report) != {"schema_version", "complete", "patches"} + or type(report["schema_version"]) is not int + or report["schema_version"] != 1 + or type(report["complete"]) is not bool + ): + raise ValueError("Invalid local patch report") + rows = report["patches"] + if not isinstance(rows, list) or len(rows) > MAX_ROWS: + raise ValueError("Invalid local patch rows") + assertions, seen = {}, set() + for item in rows: + if not isinstance(item, dict) or set(item) != { + "advisory", + "comment", + "package", + "version", + "status", + "fixedVersions", + }: + raise ValueError("Invalid local patch report entry") + if not isinstance(item["advisory"], str) or not isinstance( + item["comment"], str + ): + raise ValueError("Invalid local patch assertion") + if ( + item["advisory"] in assertions + and assertions[item["advisory"]] != item["comment"] + ): + raise ValueError("Inconsistent local patch comments") + assertions[item["advisory"]] = item["comment"] + if ( + not isinstance(item["package"], str) + or (item["package"] and not re.fullmatch(PACKAGE, item["package"])) + or not isinstance(item["version"], str) + or ( + item["version"] + and (not item["package"] or not re.fullmatch(VERSION, item["version"])) + ) + ): + raise ValueError("Invalid inferred local patch package/version") + identity = (item["advisory"], item["package"], item["version"]) + if identity in seen: + raise ValueError("Duplicate local patch report entry") + seen.add(identity) + if ( + not isinstance(item["status"], str) + or item["status"] + not in {"fix-available", "no-fix", "not-reported", "unverified"} + or ( + not item["version"] + and item["status"] not in {"not-reported", "unverified"} + ) + ): + raise ValueError("Invalid local patch status") + versions = item["fixedVersions"] + if ( + not isinstance(versions, list) + or len(versions) > 10 + or any( + not isinstance(v, str) or not re.fullmatch(VERSION, v) for v in versions + ) + or bool(versions) != (item["status"] == "fix-available") + ): + raise ValueError("Invalid local patch official fix versions") + if any( + tuple(map(int, v.split("."))) <= tuple(map(int, item["version"].split("."))) + for v in versions + ): + raise ValueError( + "Official fix versions must be newer than the affected version" + ) + validate_declarations([[key, value] for key, value in assertions.items()]) + + +def serialize_report(report): + """Serialize the full report within the reporter's bounded transport.""" + validate_report(report) + data = (json.dumps(report, indent=2) + "\n").encode() + if len(data) > MAX_REPORT_BYTES: + raise ValueError("Local patch report exceeds its size limit") + return data diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index 5166da9..8397a49 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -5,6 +5,7 @@ import hashlib import json import re +import runpy import subprocess import time from datetime import datetime, timedelta, timezone @@ -13,6 +14,8 @@ import yaml +LOCAL_PATCHES = runpy.run_path(str(Path(__file__).parents[1] / "local_patches.py")) + SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4} DIRECTORY_PATTERN = re.compile(r"[A-Za-z0-9_./ -]+") NPM_AUDIT_INCLUDES = ( @@ -573,15 +576,14 @@ def counts_match_details( def audit(directory: Path, *, deadline: float | None = None) -> dict | str: package_manager = manager(directory) if package_manager == "pnpm": - policy = yaml.safe_load((directory / "pnpm-workspace.yaml").read_bytes()) - if policy.get("audit", {}).get("ignore") or policy.get("auditConfig", {}).get( - "ignoreGhsas" - ): - return ( - "Audit unavailable: pnpm advisory-ignore rules would hide dependency " - "findings; manual review required." - ) - flags = ["--audit-level", "info"] + # Audit all advisories without rewriting the project's ignore decisions. + # Override whole objects: dotted CLI keys do not clear YAML ignore lists. + flags = [ + "--audit-level", + "info", + '--config.audit={"ignore":[]}', + '--config.auditConfig={"ignoreGhsas":[]}', + ] else: # CLI includes override project, user and environment omission settings. flags = ["--audit", "--package-lock-only", *NPM_AUDIT_INCLUDES] @@ -2623,6 +2625,7 @@ def prepare( minimum_release_age_days=10, maintenance_mode="security-and-maintenance", upgrade_scope="major", + local_patches=None, ) -> str: # Leave time in the 60-minute job to write and upload a partial report. deadline = time.monotonic() + 45 * 60 @@ -2647,6 +2650,7 @@ def prepare( raise ValueError( "Valid manifest, one deployment lockfile and package-manager policy are required" ) + declared_patches = LOCAL_PATCHES["validate_declarations"](local_patches or []) scripts = initial_snapshot[2].get("scripts", {}) if ( not isinstance(validation_script, str) @@ -2991,8 +2995,91 @@ def prepare( "Discarded dependency changes because final security evidence was unavailable or no longer confirmed a fix." ) after_lock = after_snapshot[3] + audit_errors = [ + f"{phase}: {evidence}" + for phase, evidence in (("Initial audit", before), ("Final audit", after)) + if isinstance(evidence, str) + ] + + def published_patch_versions(specifier, *, all_versions=False): + key = ("local-patch", specifier, all_versions) + if key not in cache: + snapshot = valid_snapshot(directory) + result = run( + directory, + "view", + specifier, + "versions" if all_versions else "version", + "--json", + deadline=deadline, + ) + if snapshot is None or restore_invalid_state( + directory, snapshot, notes, "Local patch metadata lookup", frozen=True + ): + cache[key] = None + else: + try: + data = json.loads(result.stdout) + except ValueError: + data = None + error = data.get("error") if isinstance(data, dict) else None + if ( + result.returncode == 1 + and isinstance(error, dict) + and error.get("code") + in { + "ERR_PNPM_NO_MATCHING_VERSION", + } + ): + cache[key] = set() + elif ( + result.returncode == 1 + and isinstance(error, dict) + and error.get("code") == "E404" + ): + summary = " ".join( + str(error.get(field, "")) + for field in ("summary", "detail", "message") + ) + cache[key] = ( + set() + if not all_versions + and re.search(r"\bNo match found for version\b", summary) + else None + ) + elif ( + isinstance(error, dict) + and error.get("code") == "ERR_PNPM_PACKAGE_NOT_FOUND" + ): + cache[key] = None + else: + values = [data] if isinstance(data, str) else data + cache[key] = ( + { + v + for v in values + if re.fullmatch( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)", v + ) + } + if result.returncode == 0 + and isinstance(values, list) + and all(isinstance(v, str) for v in values) + else None + ) + return cache[key] + + patch_report = LOCAL_PATCHES["assess"]( + declared_patches, + before, + after, + published_patch_versions, + initial_snapshot[3] if isinstance(before, dict) else after_snapshot[3], + ) if validation is not None: validation["passed"] = check_status == "Passed." + validation["audit_errors"] = audit_errors + validation["local_patches"] = patch_report fingerprints = "\n".join( f"- `{name}`: `{hashlib.sha256(content).hexdigest()}`" for name, content in { @@ -3163,11 +3250,22 @@ def prepare( ) report = ( f"**{proposal_summary} · Validation {'passed' if check_status == 'Passed.' else 'failed or unavailable'}**\n\n" - f"## Proposed dependency changes\n\n" - f"{proposed_changes}\n\n" - f"## Validation\n\n" - f"- Frozen install: {install_status[0].lower() + install_status[1:]}\n" - f"- Project checks: {check_status[0].lower() + check_status[1:]}\n\n" + + ( + "## Security audit incomplete\n\n" + "This run fails because complete before/after security evidence is unavailable. " + "Project validation results are separate.\n\n" + + "\n".join(f"- {error}" for error in audit_errors) + + "\n\n" + if audit_errors + else "" + ) + + ( + f"## Proposed dependency changes\n\n" + f"{proposed_changes}\n\n" + f"## Validation\n\n" + f"- Frozen install: {install_status[0].lower() + install_status[1:]}\n" + f"- Project checks: {check_status[0].lower() + check_status[1:]}\n\n" + ) + ( f"## Existing security findings\n\n{remaining_count} {remaining_unit} {'is' if remaining_count == 1 else 'are'} blocked, unchanged by this PR.\n\n" if blocked_findings @@ -3176,6 +3274,7 @@ def prepare( ) if isinstance(after, str): report += f"{after}\n\n" + report += LOCAL_PATCHES["render"](patch_report) if initial_ids is not None and remaining_ids is not None: new_count = len(new_ids) if new_count: @@ -3263,6 +3362,8 @@ def main() -> None: type=Path, help="Write machine-readable validation status for the publisher", ) + parser.add_argument("--resolved-config", type=Path) + parser.add_argument("--local-patches-report", type=Path) args = parser.parse_args() directory = Path(args.directory) if ( @@ -3277,6 +3378,18 @@ def main() -> None: ) validation: dict = {} try: + patches = [] + if args.resolved_config is not None: + if not args.resolved_config.is_file(): + raise ValueError("Resolved configuration snapshot is missing") + project = runpy.run_path(str(Path(__file__).parents[1] / "project.py")) + selected, _ = project["resolve_for_run"]( + Path.cwd().resolve(), args.resolved_config, [] + ) + if selected != directory.resolve(): + raise ValueError("Selected directory differs from the resolved project") + resolved = json.loads(args.resolved_config.read_bytes()) + patches = resolved["configuration"].get("localPatches", []) report = prepare( directory, validation=validation, @@ -3284,14 +3397,38 @@ def main() -> None: minimum_release_age_days=args.minimum_release_age_days, maintenance_mode=args.maintenance_mode, upgrade_scope=args.upgrade_scope, + local_patches=patches, ) - except ValueError as error: + except (OSError, ValueError) as error: parser.error(str(error)) + if args.local_patches_report is not None: + try: + local_patches_report = LOCAL_PATCHES["serialize_report"]( + validation["local_patches"] + ) + except ValueError as error: + parser.error(str(error)) + else: + local_patches_report = None args.report.write_text(report) + if local_patches_report is not None: + args.local_patches_report.write_bytes(local_patches_report) 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") print(report) + if validation["audit_errors"]: + print( + "::error title=Node security audit incomplete::Complete before/after " + "security evidence is unavailable. See the retained report for the cause; " + "project validation results are separate." + ) + parser.exit( + 1, + "Security audit incomplete: " + + "; ".join(validation["audit_errors"]) + + "\n", + ) if __name__ == "__main__": diff --git a/node/tests/test_dependency_audit_feedback.py b/node/tests/test_dependency_audit_feedback.py index 4c52d9e..063694a 100644 --- a/node/tests/test_dependency_audit_feedback.py +++ b/node/tests/test_dependency_audit_feedback.py @@ -1,7 +1,9 @@ import hashlib import json +import os import subprocess import sys +from pathlib import Path import pytest import yaml @@ -67,6 +69,128 @@ def test_cli_exports_validation_status_without_preventing_report_publication( ) +@pytest.mark.parametrize("missing", ["initial", "final", "both"]) +def test_cli_fails_incomplete_audit_but_retains_successful_project_checks( + npm_project, monkeypatch, capsys, missing +): + fake_npm(monkeypatch) + original_audit = audit_module["audit"] + calls = 0 + + def evidence(*args, **kwargs): + nonlocal calls + calls += 1 + phase = "initial" if calls == 1 else "final" + if missing in (phase, "both"): + return f"Audit unavailable: {phase} registry response is incomplete." + return original_audit(*args, **kwargs) + + monkeypatch.setitem(audit_module["prepare"].__globals__, "audit", evidence) + monkeypatch.chdir(npm_project.parent) + report = npm_project / "report.md" + output = npm_project / "github-output" + monkeypatch.setattr( + sys, + "argv", + [ + "dependency_audit.py", + "--directory", + npm_project.name, + "--maintenance-mode", + "security-only", + "--report", + str(report), + "--github-output", + str(output), + ], + ) + with pytest.raises(SystemExit) as error: + audit_module["main"]() + assert error.value.code == 1 + assert calls == 2 + assert "- Project checks: passed." in report.read_text() + assert "Security audit incomplete" in report.read_text() + assert output.read_text() == "validation_passed=true\n" + assert "registry response is incomplete" in capsys.readouterr().err + + +def test_cli_fails_unavailable_pnpm_evidence_without_rewriting_project_policy( + pnpm_project, monkeypatch +): + workspace = pnpm_project / "pnpm-workspace.yaml" + policy = yaml.safe_load(workspace.read_bytes()) + policy["auditConfig"] = {"ignoreGhsas": ["GHSA-example"]} + workspace.write_text(yaml.safe_dump(policy)) + original = workspace.read_bytes() + module = audit_module["prepare"].__globals__ + monkeypatch.setitem( + module, + "fix_security", + lambda *a, **kw: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setitem( + module, "run", lambda *a, **kw: subprocess.CompletedProcess([], 0, "", "") + ) + monkeypatch.chdir(pnpm_project.parent) + report = pnpm_project / "report.md" + monkeypatch.setattr( + sys, + "argv", + [ + "dependency_audit.py", + "--directory", + pnpm_project.name, + "--maintenance-mode", + "security-only", + "--report", + str(report), + ], + ) + with pytest.raises(SystemExit) as error: + audit_module["main"]() + assert error.value.code == 1 + assert workspace.read_bytes() == original + assert "Audit unavailable" in report.read_text() + assert "- Project checks: passed." in report.read_text() + + +@pytest.mark.parametrize("report_exists", [False, True]) +def test_action_retains_incomplete_audit_summary_and_artifact(tmp_path, report_exists): + action = yaml.safe_load( + (Path(__file__).parents[1] / "audit/action.yml").read_text() + ) + steps = action["runs"]["steps"] + summary = next( + step + for step in steps + if step.get("name", "").startswith("Retain the audit summary") + ) + artifact = next( + step + for step in steps + if step.get("with", {}).get("name") == "dependency-audit-report" + ) + for step in (summary, artifact): + assert step["if"] == "${{ always() && !cancelled() }}" + assert artifact["with"]["if-no-files-found"] == "ignore" + text = "Security audit incomplete.\nProject checks passed.\n$(never-execute)\n" + if report_exists: + (tmp_path / "dependency-audit-pr.md").write_text(text) + destination = tmp_path / "summary.md" + subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", summary["run"]], + env={ + **os.environ, + "RUNNER_TEMP": str(tmp_path), + "GITHUB_STEP_SUMMARY": str(destination), + }, + check=True, + ) + assert ( + destination.read_text() == text if report_exists else not destination.exists() + ) + + @pytest.mark.parametrize("pnpm", [False, True]) def test_all_locked_version_changes_include_transitives_additions_and_removals(pnpm): old = { diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py index edacdd7..b12fafd 100644 --- a/node/tests/test_fleet_setup.py +++ b/node/tests/test_fleet_setup.py @@ -174,6 +174,30 @@ def forbidden(*args, **kwargs): assert all(call.startswith("repos/") for call in github.calls) +@pytest.mark.parametrize("failure", [None, "old-pin"]) +def test_fleet_verifies_compact_patch_assertions_and_pinned_support( + monkeypatch, failure +): + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: pytest.fail("Fleet checks execute no project commands"), + ) + github = GitHub() + files = github.repos["owner/project"] + config = json.loads(files[".github/node-maintenance.json"]) + config["localPatches"] = ["GHSA-2345-6789-cfgh"] + files[".github/node-maintenance.json"] = data(config) + if failure == "old-pin": + contract = json.loads(github.repos["owner/actions"]["node/contract.json"]) + contract.pop("local_patches") + github.repos["owner/actions"]["node/contract.json"] = data(contract) + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == ("ok" if failure is None else "failed") + if failure == "old-pin": + assert "do not support localPatches" in result["diagnostic"] + + def test_fleet_checks_five_at_a_time_and_retains_order_and_failures(monkeypatch): github = GitHub() names = [f"owner/project-{number}" for number in range(9)] diff --git a/node/tests/test_integration_contract.py b/node/tests/test_integration_contract.py index 44c9bcc..e0c49b0 100644 --- a/node/tests/test_integration_contract.py +++ b/node/tests/test_integration_contract.py @@ -40,6 +40,7 @@ def test_reusable_workflows_bind_sibling_actions_to_their_own_commit(): "$/node/project", "$/node/publish", "$/node/configuration-issue", + "$/node/local-patch-issue", } diff --git a/node/tests/test_local_patches.py b/node/tests/test_local_patches.py new file mode 100644 index 0000000..7440595 --- /dev/null +++ b/node/tests/test_local_patches.py @@ -0,0 +1,551 @@ +"""Compact assertions identify advisories without certifying arbitrary patch code.""" + +import copy +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from test_configuration_issue import GitHub +from test_node_standard import audit_module +from test_node_standard import npm_project as npm_project +from test_node_standard import pnpm_project as pnpm_project + +ROOT = Path(__file__).parents[2] +PATCHES = runpy.run_path(str(ROOT / "node/runtime/local_patches.py")) +ISSUE = runpy.run_path(str(ROOT / "node/runtime/local_patch_issue.py")) +PROJECT = runpy.run_path(str(ROOT / "node/runtime/project.py")) +ID = "GHSA-2345-6789-cfgh" +ASSERTION = {"advisory": ID, "comment": "Covered by regression tests."} + + +def evidence(source="pnpm", patched_range=">=1.0.1"): + nodes = ["renderer@1.0.0"] if source == "pnpm" else ["node_modules/renderer"] + detail = { + "title": "Example advisory", + "url": "https://github.com/advisories/" + ID, + "severity": "high", + "range": "<1.0.1", + "patchedRange": patched_range, + "nodes": nodes, + } + return { + "package_manager": source, + "metadata": {"vulnerabilities": {"high": 1, "total": 1}}, + "vulnerabilities": { + "renderer": { + "severity": "high", + "range": "<1.0.1", + "nodes": nodes, + "fixAvailable": True, + "via": [detail], + } + }, + } + + +def report(status="fix-available", complete=True): + return { + "schema_version": 1, + "complete": complete, + "patches": [ + { + **ASSERTION, + "package": "renderer", + "version": "1.0.0", + "status": status, + "fixedVersions": ["1.0.1"] if status == "fix-available" else [], + } + ], + } + + +def maximum_report(): + value = report() + value["patches"] = [ + { + **value["patches"][0], + "package": f"renderer-{version}", + "version": f"1.0.{version}", + "comment": "🧪" * 2048, + "fixedVersions": ["2.0.0"], + } + for version in range(256) + ] + return value + + +@pytest.mark.parametrize( + "value,comment", + [ + (ID, ""), + ([ID, "Covered by regression tests."], "Covered by regression tests."), + ([ID, ""], ""), + ], +) +def test_advisory_ids_and_optional_comments(value, comment): + assert PATCHES["validate_declarations"]([value]) == [ + {"advisory": ID, "comment": comment} + ] + + +@pytest.mark.parametrize( + "value", + [ + None, + {}, + [ID], + [ID, "comment", "extra"], + [ID, None], + [ID, "two\nlines"], + "invalid", + {"advisory": ID, "comment": "Old object form"}, + [ID, "x" * 2049], + ], +) +def test_invalid_or_superseded_assertion_forms_fail(value): + with pytest.raises(ValueError): + PATCHES["validate_declarations"]([value]) + + +def test_duplicate_ids_rejected_across_shorthand_and_comment_forms(): + with pytest.raises(ValueError, match="Duplicate"): + PATCHES["validate_declarations"]([ID, [ID, "comment"]]) + + +@pytest.mark.parametrize("comment", ["\ud800", "\udfff", "text\ud800text"]) +def test_unpaired_surrogates_fail_before_rendering_or_issue_mutation(comment): + declarations = json.loads(json.dumps([[ID, comment]])) + with pytest.raises(ValueError, match="valid Unicode"): + PATCHES["validate_declarations"](declarations) + value = report() + value["patches"][0]["comment"] = comment + github = GitHub() + with pytest.raises(ValueError, match="valid Unicode"): + ISSUE["update_issue"]("owner/site", value, "run", github.request) + assert not github.calls + + +@pytest.mark.parametrize("source", ["npm", "pnpm"]) +def test_package_versions_are_inferred_from_audit_and_lock_evidence(source): + lock = {"packages": {"node_modules/renderer": {"version": "1.0.0"}}} + + def published(specifier, *, all_versions=False): + return ( + {"1.0.0", "1.0.1"} + if all_versions + else {"1.0.0"} + if "<" in specifier + else {"1.0.1"} + ) + + value = PATCHES["assess"]( + [ASSERTION], evidence(source), evidence(source), published, lock + ) + PATCHES["validate_report"](value) + assert value == report() + + +def test_each_affected_version_gets_a_notification_row(): + data = evidence() + data["vulnerabilities"]["renderer"]["via"][0]["nodes"].append("renderer@1.0.2") + value = PATCHES["assess"]([ASSERTION], data, data, lambda *a, **kw: {"1.0.3"}, {}) + PATCHES["validate_report"](value) + assert [row["version"] for row in value["patches"]] == ["1.0.0", "1.0.2"] + + +@pytest.mark.parametrize("complete", [False, True]) +def test_missing_evidence_does_not_invent_package_metadata_or_a_fix(complete): + data = {"vulnerabilities": {}} if complete else "Audit unavailable" + value = PATCHES["assess"]( + [ASSERTION], data, data, lambda *a, **kw: pytest.fail("No finding to query"), {} + ) + PATCHES["validate_report"](value) + row = value["patches"][0] + assert not row["package"] and not row["version"] and not row["fixedVersions"] + assert row["status"] == ("not-reported" if complete else "unverified") + + +def test_unresolved_affected_versions_remain_unverified(): + value = PATCHES["assess"]( + [ASSERTION], + evidence("npm"), + evidence("npm"), + lambda *a, **kw: pytest.fail("Version unknown"), + {}, + ) + PATCHES["validate_report"](value) + assert value["patches"][0]["status"] == "unverified" + + +def test_npm_official_fix_excludes_every_vulnerable_range(): + data = evidence("npm") + details = data["vulnerabilities"]["renderer"]["via"] + details.append({**details[0], "range": ">=1.0.2 <1.0.3"}) + + def published(specifier, *, all_versions=False): + if all_versions: + return {"1.0.0", "1.0.1", "1.0.2", "1.0.3"} + return {"1.0.2"} if ">=" in specifier else {"1.0.0"} + + assert PATCHES["fixed_versions"]( + data, {**ASSERTION, "package": "renderer", "version": "1.0.0"}, published + ) == ("fix-available", ["1.0.1", "1.0.3"]) + + +def test_npm_release_notification_is_independent_of_automatic_remediation(): + data = evidence("npm") + data["vulnerabilities"]["renderer"]["fixAvailable"] = False + lock = {"packages": {"node_modules/renderer": {"version": "1.0.0"}}} + + def published(specifier, *, all_versions=False): + return {"1.0.0", "1.0.1"} if all_versions else {"1.0.0"} + + value = PATCHES["assess"]([ASSERTION], data, data, published, lock) + assert value == report() + github = GitHub() + assert ( + ISSUE["update_issue"]("owner/site", value, "run", github.request)["operation"] + == "created" + ) + + +def test_unpublished_audit_reported_version_keeps_notification_open(): + data = evidence("npm") + lock = {"packages": {"node_modules/renderer": {"version": "1.0.0"}}} + value = PATCHES["assess"]([ASSERTION], data, data, lambda *a, **kw: set(), lock) + assert value == report("unverified") + github = GitHub() + ISSUE["update_issue"]("owner/site", report(), "run", github.request) + assert ( + ISSUE["update_issue"]("owner/site", value, "run", github.request)["operation"] + == "skipped" + ) + assert github.issues[0]["state"] == "open" + + +def test_one_issue_lifecycle_preserves_unknown_evidence_and_escapes_comments(): + github = GitHub() + + def update(value, run=1): + return ISSUE["update_issue"]( + "owner/site", + value, + f"https://github.com/owner/site/actions/runs/{run}", + github.request, + ) + + value = report() + value["patches"][0]["comment"] = "Review |