From 58ba4306085af41025eab8d2f63e729c01b537d0 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Mon, 19 Oct 2026 21:15:41 +0800 Subject: [PATCH] Track local advisory patches and fail incomplete Node audits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects can now record advisories covered by local patches as an advisory ID or `[advisory, comment]` in `localPatches`. The audit infers affected packages and versions, preserves raw findings and patch files, and maintains one issue when a newer stable upstream fix is available. Unknown evidence leaves an existing notification open; the issue shows a bounded table with the complete evidence in its artifact. Incomplete initial or final security audits now fail visibly and skip publication even when project checks pass. pnpm audits collect ignored advisories without changing the project’s ignore rules, and failed runs retain their summary and report. Validation: 1,140 tests pass; Ruff lint/format and runtime type checks pass. Read-only cli.dev evidence correctly identifies image-size@2.0.2 and upstream 2.0.4 without changing project files. Regression coverage includes incomplete audits, compact configuration, inferred versions, issue lifecycle, large Unicode reports through the CLI, unknown registry evidence, and the data-only credential boundary. Consumers must update both workflow pins before using localPatches. No installed repositories are changed by this PR. --- .github/workflows/node-audit.yml | 13 + docs/node-maintenance.md | 40 ++ docs/shared-defaults.md | 2 + node/audit/action.yml | 21 +- node/contract.json | 7 + node/local-patch-issue/action.yml | 7 + node/maintenance.md | 21 +- node/runtime/configuration.py | 5 +- node/runtime/fleet.py | 7 + node/runtime/local_patch_issue.py | 91 +++ node/runtime/local_patches.py | 308 +++++++++++ node/runtime/templates/dependency_audit.py | 167 +++++- node/tests/test_dependency_audit_feedback.py | 124 +++++ node/tests/test_fleet_setup.py | 24 + node/tests/test_integration_contract.py | 1 + node/tests/test_local_patches.py | 551 +++++++++++++++++++ node/tests/test_node_standard.py | 9 +- node/tests/test_project.py | 7 +- node/tests/test_transferred_invariants.py | 9 +- node/tests/test_transferred_pnpm.py | 23 +- node/tests/verify_node_project.py | 1 + 21 files changed, 1412 insertions(+), 26 deletions(-) create mode 100644 node/local-patch-issue/action.yml create mode 100644 node/runtime/local_patch_issue.py create mode 100644 node/runtime/local_patches.py create mode 100644 node/tests/test_local_patches.py 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 |