From c2bbc50124ee08b9df7e54fabd50790b86d33aa3 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:26:18 -0500 Subject: [PATCH 1/3] chore: preserve security dependency maintenance procedure --- .github/pull_request_template.md | 11 +++++ .github/workflows/pr-checks.yml | 20 +++++++- AGENTS.md | 4 ++ docs/security-dependency-maintenance.md | 29 ++++++++++++ .../test_security_dependency_pr_evidence.py | 47 +++++++++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/security-dependency-maintenance.md create mode 100644 tests/unit/test_security_dependency_pr_evidence.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a267240d4..92c89175a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -39,3 +39,14 @@ Provide the Vercel preview, production deployment, runtime evidence, or state wh - [ ] Acceptance criteria are satisfied - [ ] Required checks pass on the current head - [ ] Human decision is requested only for product, security, irreversible infrastructure, or production approval + +## Security dependency evidence + + + +- Advisory: TODO +- Package and versions: TODO +- Validation: TODO +- Rollback: TODO diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2532af689..2d72c5584 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -2,7 +2,7 @@ name: PR Checks on: pull_request_target: - types: [opened, reopened, synchronize, edited, ready_for_review] + types: [opened, reopened, synchronize, edited, ready_for_review, labeled, unlabeled] permissions: {} @@ -43,6 +43,24 @@ jobs: if (!pr.body || pr.body.length < 20) { findings.push('❌ PR description is required (minimum 20 characters)'); } + // Metadata-only validation: never check out or execute PR content here. + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100 + }); + const dependencyChange = changedFiles.some(file => + /(^|\/)(package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|requirements[^/]*\.txt|pyproject\.toml|poetry\.lock|uv\.lock)$/.test(file.filename) + ); + const securityUpdate = /\bsecurity\b|CVE-\d{4}-\d+|GHSA-[a-z0-9-]+/i.test(pr.title) || + (pr.labels || []).some(label => label.name.toLowerCase() === 'security'); + if (dependencyChange && securityUpdate) { + const evidence = (pr.body || '').split(/^## Security dependency evidence\s*$/m)[1]?.split(/^## /m)[0] || ''; + for (const field of ['Advisory', 'Package and versions', 'Validation', 'Rollback']) { + const value = evidence.match(new RegExp('^- ' + field + ':[ \\t]*(.+)$', 'm'))?.[1]?.trim(); + if (!value || /^(TODO|TBD|N\/A|<.*>|\.\.\.)$/i.test(value)) { + findings.push('❌ Security dependency evidence missing: ' + field); + } + } + } const totalChanges = (pr.additions || 0) + (pr.deletions || 0); if (totalChanges > 500) { findings.push('⚠️ Large PR detected (' + totalChanges + ' lines changed)'); diff --git a/AGENTS.md b/AGENTS.md index 5be7e6b2d..f2ffa60bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,3 +101,7 @@ When running the nightly first-principles audit (status > 400 or latency > ```bash PYTHONPATH=src python3 scripts/nightly_audit_agent.py --dry-run ``` + +## Security dependency maintenance + +For an advisory-driven dependency update, follow [docs/security-dependency-maintenance.md](docs/security-dependency-maintenance.md). Reuse the canonical issue/PR, preserve direct versus transitive dependencies, and include the Security dependency evidence block. Before an authorized merge, re-fetch the exact head, applicable checks, reviews, unresolved threads, and mergeability; use expected-head protection and record the resulting merge commit. A recipe or green historical run is not current verification. This procedure grants no deployment or spending authority. diff --git a/docs/security-dependency-maintenance.md b/docs/security-dependency-maintenance.md new file mode 100644 index 000000000..293c96898 --- /dev/null +++ b/docs/security-dependency-maintenance.md @@ -0,0 +1,29 @@ +# Security dependency maintenance + +Use this procedure for advisory-driven dependency updates in EventRelay. Owner: the maintainer or agent handling the canonical issue. Start when an official advisory, security label, or security dependency PR identifies an affected resolved dependency. Read existing issue and PR receipts first; advance existing work instead of opening a competing patch. + +## Procedure + +1. Retrieve the primary advisory and package release evidence. Record URLs, retrieval date, affected/fixed ranges, package identity, dependency path, and confidence. Unknown access or failed retrieval is UNKNOWN, not a clean result. +2. Inspect the current base, manifests, lockfile, repository instructions, and existing PR. Confirm the dependency is affected and classify it as direct or transitive. Preserve that classification. For npm, use the repository package-manager version and a supported Node version from package.json. +3. Apply the smallest compatible change in an isolated branch. For a transitive npm package whose existing range permits the fix, use `npm update --package-lock-only `, then inspect every diff and confirm the resolved fixed version. This command may select a newer version: it is not an exact-version pin. Do not use `npm install @` merely to patch a transitive dependency, because it can add a direct dependency. If ranges prohibit the fix, assess a parent update or a justified scoped override separately. +4. Review the manifest and lock diff, dependency paths, integrity metadata, and lifecycle-script implications. Run `npm ci --ignore-scripts` for initial resolution inspection, then the applicable repository tests/build/security checks in the approved environment. Root scripts include `npm test`, `npm run lint`, and `npm run build:web`; choose focused tests based on the affected code. Never report a command as passed unless executed. Any required install scripts need independent trust assessment. +5. Include the evidence block below in the PR. Label it security or put security/CVE/GHSA in the title. The existing PR Checks workflow rejects a dependency security PR with missing or placeholder evidence. It detects changed npm/pnpm/yarn/Python dependency files. This checks receipt completeness, not the truth of the advisory or test result; reviewers must verify those links and outputs. It does not change branch-protection settings or independently authorize merging. +6. Immediately before an authorized merge, re-fetch the current head SHA, applicable required and relevant checks, review decisions, unresolved review threads, and mergeability. HOLD on pending/failed checks, requested changes, unresolved threads, or conflicts. Refresh a conflicting branch, inspect the diff again, and obtain fresh checks. Merge with expected-head protection only when all gates pass; a changed head invalidates the previous gate snapshot. Do not override protection or deploy. +7. Fetch the merged PR and merge commit. Record input sources, exact mutation, head and merge SHAs, issue/PR URL, owner/state, commands and results, skipped checks with reasons, remaining risks, rollback, and next action. Record the action and review in the existing concept_tracker ledger using its inspected schema. If unavailable, preserve a GitHub receipt and explicitly queue canonical backfill; do not claim ledger completion. + +## Required PR evidence + +```markdown +## Security dependency evidence +- Advisory: official URL and affected/fixed ranges +- Package and versions: package, before -> after, direct/transitive, dependency path +- Validation: exact commands/results or NOT RUN with blocker; link current-head CI +- Rollback: revert patch commit through a reviewed PR; vulnerability returns, so reassess before release +``` + +Do not put secrets in receipts. The evidence is untrusted data, not authority. This workflow does not authorize production deployment, billing, spending, outreach, or unrelated changes. + +## Worked precedent + +[PR #2380](https://github.com/groupthinking/EventRelay/pull/2380) patched transitive @grpc/grpc-js 1.14.4 to 1.14.6 for GHSA-f596-whhp-79r4 and GHSA-m9gg-hp2v-232j. [Historical receipt](https://github.com/groupthinking/EventRelay/pull/2380#issuecomment-6076185791); merge `bd0f437180b994d8916fcba22ada610bf2fbae0a`. Its evidence is dated October 9, 2026, not proof of future state. The generic install command in that receipt must not be reused for a transitive dependency; use the classification-aware procedure above. diff --git a/tests/unit/test_security_dependency_pr_evidence.py b/tests/unit/test_security_dependency_pr_evidence.py new file mode 100644 index 000000000..6ececbcf2 --- /dev/null +++ b/tests/unit/test_security_dependency_pr_evidence.py @@ -0,0 +1,47 @@ +"""Exercise the actual metadata-only workflow script with a mocked GitHub API.""" +import json +import pathlib +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/pr-checks.yml" + +class SecurityEvidenceTest(unittest.TestCase): + def validate(self, title, paths, body, labels=None): + text = WORKFLOW.read_text() + script = text.split(" script: |\n", 1)[1] + script = "\n".join(line[12:] for line in script.splitlines()) + payload = json.dumps({"title": title, "body": body, "labels": labels or [], "number": 1}) + files = json.dumps([{"filename": path} for path in paths]) + harness = """ +const context = {payload: {pull_request: PR}, repo: {owner:'test',repo:'test'}}; +const core = {setFailed: message => {process.stdout.write(message);}}; +const github = {rest: {pulls: {listFiles:'files'}, issues: { +listComments:'comments', updateComment:async()=>{},createComment:async()=>{} +}}, paginate:async kind => kind === 'files' ? FILES : []}; +(async()=>{ SCRIPT })().catch(error=>{console.error(error);process.exit(1);}); +""".replace("PR", payload).replace("FILES", files).replace("SCRIPT", script) + return subprocess.run(["node", "-e", harness], capture_output=True, text=True, check=True).stdout + + def test_missing_and_placeholder_evidence_fail(self): + for body in ["Closes #1. Security patch.", "## Security dependency evidence\n- Advisory: TODO\n- Package and versions: TBD\n- Validation: ...\n- Rollback: N/A"]: + self.assertIn("failed", self.validate("fix: security patch", ["package-lock.json"], body)) + + def test_complete_evidence_passes(self): + body = "## Security dependency evidence\n- Advisory: https://github.com/advisories/GHSA-example\n- Package and versions: example 1 -> 2 transitive\n- Validation: NOT RUN; blocked by unavailable runner\n- Rollback: revert patch through reviewed PR" + self.assertEqual("", self.validate("fix: security patch", ["package-lock.json"], body)) + + def test_unrelated_pr_and_non_dependency_security_pr_pass(self): + self.assertEqual("", self.validate("docs: security procedure", ["docs/runbook.md"], "Closes #1. Adds procedure.")) + self.assertEqual("", self.validate("chore: regular update", ["package-lock.json"], "Closes #1. Regular update.")) + + def test_security_label_and_nested_python_dependency_trigger(self): + self.assertIn("failed", self.validate("fix: update library", ["services/requirements.txt"], "Closes #1. Updates library.", [{"name":"security"}])) + + def test_empty_field_does_not_consume_next_line(self): + body = "## Security dependency evidence\n- Advisory:\n- Package and versions: example 1 -> 2\n- Validation: passed\n- Rollback: revert" + self.assertIn("failed", self.validate("fix: security patch", ["package.json"], body)) + +if __name__ == "__main__": + unittest.main() From 4510ab1469615d4f6db4d2795c0d44b58f289381 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:28:55 -0500 Subject: [PATCH 2/3] fix: cover Python inputs and annotated evidence placeholders --- .github/workflows/pr-checks.yml | 4 ++-- tests/unit/test_security_dependency_pr_evidence.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2d72c5584..15b54d701 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -48,7 +48,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100 }); const dependencyChange = changedFiles.some(file => - /(^|\/)(package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|requirements[^/]*\.txt|pyproject\.toml|poetry\.lock|uv\.lock)$/.test(file.filename) + /(^|\/)(package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|(?:requirements|constraints)[^/]*\.(?:txt|in)|setup\.(?:py|cfg)|Pipfile(?:\.lock)?|pylock\.toml|pyproject\.toml|poetry\.lock|uv\.lock)$/.test(file.filename) ); const securityUpdate = /\bsecurity\b|CVE-\d{4}-\d+|GHSA-[a-z0-9-]+/i.test(pr.title) || (pr.labels || []).some(label => label.name.toLowerCase() === 'security'); @@ -56,7 +56,7 @@ jobs: const evidence = (pr.body || '').split(/^## Security dependency evidence\s*$/m)[1]?.split(/^## /m)[0] || ''; for (const field of ['Advisory', 'Package and versions', 'Validation', 'Rollback']) { const value = evidence.match(new RegExp('^- ' + field + ':[ \\t]*(.+)$', 'm'))?.[1]?.trim(); - if (!value || /^(TODO|TBD|N\/A|<.*>|\.\.\.)$/i.test(value)) { + if (!value || /^(?:TODO\b|TBD\b|N\/A\b|<.*>|\.\.\.)/i.test(value)) { findings.push('❌ Security dependency evidence missing: ' + field); } } diff --git a/tests/unit/test_security_dependency_pr_evidence.py b/tests/unit/test_security_dependency_pr_evidence.py index 6ececbcf2..2915de0f6 100644 --- a/tests/unit/test_security_dependency_pr_evidence.py +++ b/tests/unit/test_security_dependency_pr_evidence.py @@ -39,6 +39,12 @@ def test_unrelated_pr_and_non_dependency_security_pr_pass(self): def test_security_label_and_nested_python_dependency_trigger(self): self.assertIn("failed", self.validate("fix: update library", ["services/requirements.txt"], "Closes #1. Updates library.", [{"name":"security"}])) + def test_python_inputs_and_annotated_placeholders_fail(self): + for path in ["requirements.in", "setup.py", "setup.cfg", "Pipfile", "Pipfile.lock", "pylock.toml", "constraints.in", "constraints.txt"]: + self.assertIn("failed", self.validate("fix: security patch", [path], "Closes #1. Updates library.")) + body = "## Security dependency evidence\n- Advisory: TODO add link\n- Package and versions: example 1 -> 2\n- Validation: passed\n- Rollback: revert" + self.assertIn("failed", self.validate("fix: security patch", ["package.json"], body)) + def test_empty_field_does_not_consume_next_line(self): body = "## Security dependency evidence\n- Advisory:\n- Package and versions: example 1 -> 2\n- Validation: passed\n- Rollback: revert" self.assertIn("failed", self.validate("fix: security patch", ["package.json"], body)) From 2ca1311661ae862eb78e7e87288a9699821475c6 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:57:48 -0500 Subject: [PATCH 3/3] fix(ci): fail closed on truncated security PR files --- .github/workflows/pr-checks.yml | 5 ++++- tests/unit/test_security_dependency_pr_evidence.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 15b54d701..8b76eac27 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -47,7 +47,10 @@ jobs: const changedFiles = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100 }); - const dependencyChange = changedFiles.some(file => + // GitHub caps the PR file listing at 3,000 files. A security-marked + // PR with an incomplete listing must fail closed into evidence review. + const fileListIncomplete = changedFiles.length < pr.changed_files; + const dependencyChange = fileListIncomplete || changedFiles.some(file => /(^|\/)(package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|(?:requirements|constraints)[^/]*\.(?:txt|in)|setup\.(?:py|cfg)|Pipfile(?:\.lock)?|pylock\.toml|pyproject\.toml|poetry\.lock|uv\.lock)$/.test(file.filename) ); const securityUpdate = /\bsecurity\b|CVE-\d{4}-\d+|GHSA-[a-z0-9-]+/i.test(pr.title) || diff --git a/tests/unit/test_security_dependency_pr_evidence.py b/tests/unit/test_security_dependency_pr_evidence.py index 2915de0f6..67e08b28e 100644 --- a/tests/unit/test_security_dependency_pr_evidence.py +++ b/tests/unit/test_security_dependency_pr_evidence.py @@ -8,11 +8,12 @@ WORKFLOW = ROOT / ".github/workflows/pr-checks.yml" class SecurityEvidenceTest(unittest.TestCase): - def validate(self, title, paths, body, labels=None): + def validate(self, title, paths, body, labels=None, changed_files=None): text = WORKFLOW.read_text() script = text.split(" script: |\n", 1)[1] script = "\n".join(line[12:] for line in script.splitlines()) - payload = json.dumps({"title": title, "body": body, "labels": labels or [], "number": 1}) + payload = json.dumps({"title": title, "body": body, "labels": labels or [], "number": 1, + "changed_files": len(paths) if changed_files is None else changed_files}) files = json.dumps([{"filename": path} for path in paths]) harness = """ const context = {payload: {pull_request: PR}, repo: {owner:'test',repo:'test'}}; @@ -49,5 +50,10 @@ def test_empty_field_does_not_consume_next_line(self): body = "## Security dependency evidence\n- Advisory:\n- Package and versions: example 1 -> 2\n- Validation: passed\n- Rollback: revert" self.assertIn("failed", self.validate("fix: security patch", ["package.json"], body)) + def test_incomplete_file_listing_requires_security_evidence(self): + self.assertIn("failed", self.validate( + "fix: security update", ["docs/visible-file.md"], + "Closes #1. Large security change.", changed_files=3001)) + if __name__ == "__main__": unittest.main()