Skip to content

Commit 6c50c96

Browse files
committed
feat(pr-bot): sticky PR comment workflow + stdlib-only renderer
The bot side of the equation: every PR that touches a lockfile gets one visible, in-diff signal that gets edited in place on subsequent pushes (no comment spam). * tools/pr_comment.py - Stdlib-only (zero deps beyond Python). Reads the JSON produced by 'pwned-deps check --format json' and emits a Markdown body. - Body always starts with a magic marker so the calling workflow can find and edit (rather than re-post) the prior comment. - Three render modes: green (clean), yellow (HIGH/CRITICAL CVEs only, exit 2), red (compromised packages, exit 1). Findings table includes severity, ecosystem:pkg@ver, advisory id with the first reference link, and the campaign name when present. - Exit code mirrors pwned-deps so the calling workflow can fail the build with one line. * tests/test_pr_comment.py - 3 new tests (clean / compromised / HIGH-only) covering the marker, table rendering, and exit codes. * examples/workflows/pr-comment.yml - drop-in workflow showing the full pattern: checkout, pip install pwned-deps, fetch the renderer, scan, post-or-edit comment via gh pr comment with the edit-last flag (with fallback for first runs), and an optional fail-the-build step guarded by the scan output for report-only mode. * README - new "Sticky PR comment (the bot workflow)" subsection under CI integration with sample comment markup and a link to the example workflow. Tests 122 -> 125. Lint clean for src+tests. smoke-local 7/7.
1 parent 08ea3ec commit 6c50c96

4 files changed

Lines changed: 354 additions & 0 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,32 @@ the build on exit `1` (compromised package) by default. See
413413
Exit `1` fails the build. Exit `2` is HIGH/CRITICAL CVEs (no
414414
malicious hits) — you decide whether that fails or warns.
415415

416+
### Sticky PR comment (the bot workflow)
417+
418+
For pull requests, you usually want a *visible* signal next to the
419+
diff — not just a red check. Drop
420+
[`examples/workflows/pr-comment.yml`](examples/workflows/pr-comment.yml)
421+
into `.github/workflows/` and every PR that touches a lockfile gets a
422+
single sticky comment that gets *edited in place* on subsequent
423+
pushes (no comment spam):
424+
425+
```text
426+
## pwned-deps scan
427+
428+
🚨 **1 compromised package(s)** detected
429+
430+
| Severity | Package | Advisory | Campaign |
431+
|------------|-------------------------------|-------------------|---------------------------------------|
432+
| MALICIOUS | npm:event-stream@3.3.6 | EXTRA-2018-0001 ↗ | event-stream / flatmap-stream |
433+
```
434+
435+
Mechanism: the workflow runs `pwned-deps check . --format json`,
436+
pipes the JSON through [`tools/pr_comment.py`](tools/pr_comment.py)
437+
(stdlib-only, no extra deps), and uses `gh pr comment --edit-last`
438+
to find and update the prior comment by a magic marker. Comment-only
439+
mode (don't fail the build) is a one-line tweak documented in the
440+
example.
441+
416442
### pre-commit
417443

418444
```yaml

examples/workflows/pr-comment.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Example: post a sticky pwned-deps PR comment on every pull request that
2+
# touches a lockfile. Drop this file into `.github/workflows/` of any
3+
# repo that uses pwned-deps. Three things to customise:
4+
#
5+
# 1. `paths:` — the lockfiles you care about.
6+
# 2. The `pwned-deps check .` invocation if you want a different scope
7+
# (e.g. `--offline`, a specific subdirectory).
8+
# 3. Optional: replace `${{ secrets.GITHUB_TOKEN }}` with a fine-grained
9+
# PAT if you want comments to attribute to a bot account.
10+
#
11+
# The job:
12+
# * Runs `pwned-deps check . --format json` on the PR head.
13+
# * Pipes the JSON through `tools/pr_comment.py` (curled from this
14+
# repo at the pinned ref) to render a Markdown body with a magic
15+
# marker.
16+
# * `gh pr comment --edit-last` finds the previous comment by marker
17+
# and edits it in place — no spam on every push.
18+
#
19+
# Exit policy: this job posts the comment and *fails* if compromised
20+
# packages are present (exit 1). For report-only mode replace the final
21+
# `exit $rc` line with `exit 0`.
22+
23+
name: pwned-deps PR comment
24+
25+
on:
26+
pull_request:
27+
paths:
28+
- "**/package-lock.json"
29+
- "**/yarn.lock"
30+
- "**/pnpm-lock.yaml"
31+
- "**/requirements.txt"
32+
- "**/poetry.lock"
33+
- "**/Pipfile.lock"
34+
- "**/go.sum"
35+
- "**/Cargo.lock"
36+
- "**/Gemfile.lock"
37+
- "**/pom.xml"
38+
39+
permissions:
40+
contents: read
41+
pull-requests: write # required for `gh pr comment`
42+
43+
jobs:
44+
scan-and-comment:
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- uses: actions/setup-python@v5
50+
with:
51+
python-version: "3.12"
52+
53+
- name: Install pwned-deps
54+
run: pip install --quiet pwned-deps
55+
56+
- name: Fetch comment renderer
57+
run: |
58+
# Pin to a tag (e.g. v0.1.1) once the helper has a release.
59+
curl -fsSL \
60+
https://raw.githubusercontent.com/mkbhardwas12/pwned-deps/main/tools/pr_comment.py \
61+
-o pr_comment.py
62+
63+
- name: Scan + render comment
64+
id: scan
65+
run: |
66+
set +e
67+
pwned-deps check . --format json > scan.json
68+
rc=$?
69+
python pr_comment.py scan.json > comment.md
70+
echo "rc=$rc" >> "$GITHUB_OUTPUT"
71+
72+
- name: Post (or update) sticky PR comment
73+
env:
74+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75+
PR: ${{ github.event.pull_request.number }}
76+
run: |
77+
# Try editing the previous comment; fall back to a fresh post.
78+
gh pr comment "$PR" --body-file comment.md --edit-last \
79+
|| gh pr comment "$PR" --body-file comment.md
80+
81+
- name: Fail step if compromised packages were found
82+
if: steps.scan.outputs.rc != '0'
83+
run: |
84+
echo "::error::pwned-deps reported exit ${{ steps.scan.outputs.rc }}"
85+
exit ${{ steps.scan.outputs.rc }}

tests/test_pr_comment.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for the PR-comment renderer (tools/pr_comment.py)."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
from pathlib import Path
7+
8+
_SPEC = importlib.util.spec_from_file_location(
9+
"pwned_deps_pr_comment_under_test",
10+
Path(__file__).resolve().parent.parent / "tools" / "pr_comment.py",
11+
)
12+
assert _SPEC is not None and _SPEC.loader is not None
13+
_MOD = importlib.util.module_from_spec(_SPEC)
14+
_SPEC.loader.exec_module(_MOD)
15+
render = _MOD.render
16+
MARKER = _MOD.MARKER
17+
18+
19+
def test_clean_scan_renders_green_comment_with_marker_and_zero_exit() -> None:
20+
body, code = render(
21+
{
22+
"schema_version": "1.0",
23+
"tool": {"name": "pwned-deps", "version": "0.1.0"},
24+
"lockfiles": [
25+
{"path": "package-lock.json", "ecosystem": "npm", "findings": []}
26+
],
27+
"summary": {
28+
"total_packages": 42,
29+
"compromised": 0,
30+
"high_critical": 0,
31+
"other": 0,
32+
},
33+
}
34+
)
35+
assert code == 0
36+
assert body.startswith(MARKER)
37+
assert "Clean" in body
38+
assert "42 pinned" in body
39+
# Clean comments don't include the findings table.
40+
assert "| Severity |" not in body
41+
42+
43+
def test_compromised_scan_renders_red_comment_and_exit_one() -> None:
44+
body, code = render(
45+
{
46+
"schema_version": "1.0",
47+
"tool": {"name": "pwned-deps", "version": "0.1.0"},
48+
"lockfiles": [
49+
{
50+
"path": "package-lock.json",
51+
"ecosystem": "npm",
52+
"findings": [
53+
{
54+
"id": "EXTRA-2018-0001",
55+
"package": "event-stream",
56+
"version": "3.3.6",
57+
"ecosystem": "npm",
58+
"severity": "CRITICAL",
59+
"summary": "credential stealer",
60+
"references": ["https://example.test/disclosure"],
61+
"is_malicious": True,
62+
"campaign_name": "event-stream / flatmap-stream",
63+
}
64+
],
65+
}
66+
],
67+
"summary": {
68+
"total_packages": 100,
69+
"compromised": 1,
70+
"high_critical": 0,
71+
"other": 0,
72+
},
73+
}
74+
)
75+
assert code == 1
76+
assert body.startswith(MARKER)
77+
assert "compromised" in body.lower()
78+
assert "MALICIOUS" in body
79+
assert "event-stream" in body
80+
assert "EXTRA-2018-0001" in body
81+
assert "event-stream / flatmap-stream" in body
82+
# Reference link rendered.
83+
assert "https://example.test/disclosure" in body
84+
85+
86+
def test_high_only_scan_exits_two() -> None:
87+
body, code = render(
88+
{
89+
"schema_version": "1.0",
90+
"tool": {"name": "pwned-deps", "version": "0.1.0"},
91+
"lockfiles": [
92+
{
93+
"path": "requirements.txt",
94+
"ecosystem": "PyPI",
95+
"findings": [
96+
{
97+
"id": "GHSA-xxxx",
98+
"package": "requests",
99+
"version": "2.10.0",
100+
"ecosystem": "PyPI",
101+
"severity": "HIGH",
102+
"summary": "x",
103+
"references": [],
104+
"is_malicious": False,
105+
"campaign_name": None,
106+
}
107+
],
108+
}
109+
],
110+
"summary": {
111+
"total_packages": 5,
112+
"compromised": 0,
113+
"high_critical": 1,
114+
"other": 0,
115+
},
116+
}
117+
)
118+
assert code == 2
119+
assert "HIGH/CRITICAL" in body
120+
assert "GHSA-xxxx" in body

tools/pr_comment.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Render a sticky PR comment from `pwned-deps check --format json` output.
2+
3+
Usage:
4+
5+
pwned-deps check . --format json > scan.json
6+
python tools/pr_comment.py scan.json > comment.md
7+
gh pr comment "$PR" --body-file comment.md --edit-last \
8+
|| gh pr comment "$PR" --body-file comment.md
9+
10+
The comment body always starts with a magic marker line so a follow-up
11+
run can find and edit (rather than spam) the existing comment with
12+
`gh pr comment --edit-last` or the GitHub REST API.
13+
14+
Exit codes:
15+
0 — comment written, no compromised packages
16+
1 — comment written, at least one MAL-* / EXTRA-* finding
17+
2 — comment written, only HIGH/CRITICAL CVE findings
18+
3 — input JSON could not be parsed
19+
20+
The script is dependency-free (stdlib only) so consumers can curl it
21+
into a workflow without provisioning a Python environment beyond the
22+
one already running pwned-deps.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import json
28+
import sys
29+
from collections.abc import Iterable
30+
from pathlib import Path
31+
32+
MARKER = "<!-- pwned-deps:pr-comment:v1 -->"
33+
34+
35+
def render(payload: dict) -> tuple[str, int]:
36+
"""Return ``(markdown_body, exit_code)``.
37+
38+
``payload`` is the parsed object produced by ``pwned-deps check
39+
--format json``. Output starts with ``MARKER`` so subsequent runs
40+
can locate and edit the existing comment.
41+
"""
42+
43+
summary = payload.get("summary", {}) or {}
44+
lockfiles = payload.get("lockfiles", []) or []
45+
tool = payload.get("tool", {}) or {}
46+
tool_version = tool.get("version", "?")
47+
48+
compromised = int(summary.get("compromised", 0) or 0)
49+
high_critical = int(summary.get("high_critical", 0) or 0)
50+
total_packages = int(summary.get("total_packages", 0) or 0)
51+
52+
if compromised:
53+
exit_code = 1
54+
headline = f"🚨 **{compromised} compromised package(s)** detected"
55+
elif high_critical:
56+
exit_code = 2
57+
headline = f"⚠️ **{high_critical} HIGH/CRITICAL CVE(s)** detected"
58+
else:
59+
exit_code = 0
60+
headline = f"✅ Clean — no compromised packages in {total_packages} pinned dependencies."
61+
62+
lines: list[str] = [
63+
MARKER,
64+
"## pwned-deps scan",
65+
"",
66+
headline,
67+
"",
68+
f"_Scanned {total_packages} pinned packages across "
69+
f"{len(lockfiles)} lockfile(s) with pwned-deps `{tool_version}`._",
70+
"",
71+
]
72+
73+
if compromised or high_critical:
74+
lines.extend(_render_findings_table(lockfiles))
75+
76+
return "\n".join(lines).rstrip() + "\n", exit_code
77+
78+
79+
def _render_findings_table(lockfiles: Iterable[dict]) -> list[str]:
80+
rows: list[str] = []
81+
for lf in lockfiles:
82+
for finding in lf.get("findings", []) or []:
83+
tag = "MALICIOUS" if finding.get("is_malicious") else finding.get(
84+
"severity", "?"
85+
)
86+
campaign = finding.get("campaign_name") or ""
87+
adv_id = finding.get("id", "?")
88+
refs = finding.get("references") or []
89+
ref_link = f" [↗]({refs[0]})" if refs else ""
90+
rows.append(
91+
f"| `{tag}` | `{finding.get('ecosystem', '?')}:"
92+
f"{finding.get('package', '?')}@{finding.get('version', '?')}` "
93+
f"| `{adv_id}`{ref_link} | {campaign} |"
94+
)
95+
if not rows:
96+
return []
97+
return [
98+
"| Severity | Package | Advisory | Campaign |",
99+
"|---|---|---|---|",
100+
*rows,
101+
"",
102+
"_Re-run `pwned-deps check` locally to reproduce. "
103+
"See [pwned-deps](https://github.com/mkbhardwas12/pwned-deps) for triage guidance._",
104+
]
105+
106+
107+
def main(argv: list[str]) -> int:
108+
if len(argv) != 2:
109+
sys.stderr.write("usage: pr_comment.py <scan.json>\n")
110+
return 64
111+
src = Path(argv[1])
112+
try:
113+
payload = json.loads(src.read_text(encoding="utf-8"))
114+
except (OSError, json.JSONDecodeError) as exc:
115+
sys.stderr.write(f"could not read JSON from {src}: {exc}\n")
116+
return 3
117+
body, exit_code = render(payload)
118+
sys.stdout.write(body)
119+
return exit_code
120+
121+
122+
if __name__ == "__main__": # pragma: no cover - thin CLI wrapper
123+
raise SystemExit(main(sys.argv))

0 commit comments

Comments
 (0)