Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- For advisory-driven dependency changes: follow docs/security-dependency-maintenance.md.
Mark the title with security or a CVE/GHSA ID, or use the security label.
Replace every value below with evidence; omit this block for unrelated work. -->

- Advisory: TODO
- Package and versions: TODO
- Validation: TODO
- Rollback: TODO
23 changes: 22 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}

Expand Down Expand Up @@ -43,6 +43,27 @@ 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
});
// 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) ||
(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\b|TBD\b|N\/A\b|<.*>|\.\.\.)/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)');
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,7 @@ When running the nightly first-principles audit (status &gt; 400 or latency &gt;
```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.
29 changes: 29 additions & 0 deletions docs/security-dependency-maintenance.md
Original file line number Diff line number Diff line change
@@ -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 <package>`, 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 <package>@<version>` 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.
59 changes: 59 additions & 0 deletions tests/unit/test_security_dependency_pr_evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""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, 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,
"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'}};
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_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))

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()
Loading