ci(security): post scanner findings as sticky PR comments (#68) #95

Merged
james merged 1 commit from 68-sticky-scanner-comments into main 2026-06-18 02:23:42 +00:00
Owner

Closes #68.

Summary

Each security scanner — npm_audit, osv_scan, image_scan, gitleaks — now posts its qualifying findings as a sticky comment on the PR in addition to writing them to $GITHUB_STEP_SUMMARY. Same Markdown, visible from the conversation view without clicking into run details.

Structure

  • scripts/ci/security-findings.mjs (new) — shared parse + render module. The four scanner parsers and the two Markdown renderers (bucketed for npm/osv/trivy, list-shape for gitleaks) extracted from security-summary.mjs verbatim. Both CLI scripts import from here.
  • scripts/ci/security-summary.mjs (refactored) — slimmed down to the step-summary CLI it always was. Behavior unchanged from a workflow caller's perspective (same args, same exit code, same qualifying=<n> last line, same Markdown).
  • scripts/ci/post-pr-comment.mjs (new) — sticky upsert via Forgejo's /api/v1/repos/.../issues/{n}/comments. Same args as the summary script; reads token + repo + PR number from env. Finds existing comment by HTML marker (<!-- scanner-comment: <name> -->), then POST / PATCH / DELETE per the matrix below.

Sticky semantics

Findings? Existing comment? Action
Yes No POST new comment
Yes Yes PATCH in place
No Yes DELETE
No No no-op

Delete-on-clean (rather than update-to-"no longer any findings") keeps the invariant "if a scanner comment exists on the PR, the finding is current" — reviewers don't have to mentally diff comment state against run state. The green CI badge handles the all-clean signalling.

Each scanner has its own marker, so the four parallel jobs don't coordinate writes.

Failure mode

The new step runs if: always() && github.event_name == 'pull_request'. A flagged gate (the case where reviewers most need the findings surfaced) doesn't skip the comment. A failure inside the comment step does NOT mask a red gate — security-summary.mjs has already run + failed the gating step before post-pr-comment.mjs gets called.

Files

File Change
scripts/ci/security-findings.mjs New — shared parsers + renderers
scripts/ci/security-summary.mjs Refactored to import the shared module
scripts/ci/post-pr-comment.mjs New — sticky-upsert CLI
.forgejo/workflows/pr.yml Three new "Post sticky PR comment" steps (npm/osv/trivy) + top-level permissions: block (issues: write, pull-requests: write)
.forgejo/workflows/secrets.yml One new "Post sticky PR comment" step (gitleaks, only on pull_request branch of the workflow) + permissions: block
docs/ci.md New "Sticky PR comments from security scanners" section — behavior, required token scope, failure mode
docs/adr/0016-sticky-scanner-pr-comments.md New ADR — trade-offs (sticky vs append, per-scanner vs aggregated, delete-on-clean vs update-to-clean, conversation vs inline)

Test plan

  • actionlint .forgejo/workflows/*.yml — clean.
  • Refactor smoke test: ran security-summary.mjs against synthetic gitleaks SARIF (empty + 1-leak), synthetic npm audit JSON, synthetic Trivy JSON. Output identical in shape to the existing behavior; qualifying=<n> last-line preserved.
  • End-to-end on this PR: the new step in each scanner job runs against this very PR. Expected outcome: clean PR → no scanner comments posted. If any scanner happens to flag something, that comment lands and the gate behavior is verified against a real PR.
  • Synthetic-finding test (manual, post-merge): cut a throwaway branch that introduces a deliberately vulnerable transitive dep (or a synthetic ghp_… for gitleaks); confirm the scanner posts a comment, then push a follow-up that fixes the finding and confirm the comment is deleted.

Note: leftover untracked private key

Still in the repo root: forgejo-signing-key (private) + .pub. Not staged here, but shred -u after you've copied it to the Forgejo host. (Repeating from #91 since they survived two more PR cycles.)

Closes #68. ## Summary Each security scanner — `npm_audit`, `osv_scan`, `image_scan`, `gitleaks` — now posts its qualifying findings as a sticky comment on the PR in addition to writing them to `$GITHUB_STEP_SUMMARY`. Same Markdown, visible from the conversation view without clicking into run details. ## Structure - **`scripts/ci/security-findings.mjs` (new)** — shared parse + render module. The four scanner parsers and the two Markdown renderers (bucketed for npm/osv/trivy, list-shape for gitleaks) extracted from `security-summary.mjs` verbatim. Both CLI scripts import from here. - **`scripts/ci/security-summary.mjs` (refactored)** — slimmed down to the step-summary CLI it always was. Behavior unchanged from a workflow caller's perspective (same args, same exit code, same `qualifying=<n>` last line, same Markdown). - **`scripts/ci/post-pr-comment.mjs` (new)** — sticky upsert via Forgejo's `/api/v1/repos/.../issues/{n}/comments`. Same args as the summary script; reads token + repo + PR number from env. Finds existing comment by HTML marker (`<!-- scanner-comment: <name> -->`), then `POST` / `PATCH` / `DELETE` per the matrix below. ## Sticky semantics | Findings? | Existing comment? | Action | |---|---|---| | Yes | No | `POST` new comment | | Yes | Yes | `PATCH` in place | | No | Yes | `DELETE` | | No | No | no-op | Delete-on-clean (rather than update-to-"no longer any findings") keeps the invariant **"if a scanner comment exists on the PR, the finding is current"** — reviewers don't have to mentally diff comment state against run state. The green CI badge handles the all-clean signalling. Each scanner has its own marker, so the four parallel jobs don't coordinate writes. ## Failure mode The new step runs `if: always() && github.event_name == 'pull_request'`. A flagged gate (the case where reviewers most need the findings surfaced) doesn't skip the comment. A failure inside the comment step does NOT mask a red gate — `security-summary.mjs` has already run + failed the gating step before `post-pr-comment.mjs` gets called. ## Files | File | Change | |---|---| | `scripts/ci/security-findings.mjs` | New — shared parsers + renderers | | `scripts/ci/security-summary.mjs` | Refactored to import the shared module | | `scripts/ci/post-pr-comment.mjs` | New — sticky-upsert CLI | | `.forgejo/workflows/pr.yml` | Three new "Post sticky PR comment" steps (npm/osv/trivy) + top-level `permissions:` block (`issues: write`, `pull-requests: write`) | | `.forgejo/workflows/secrets.yml` | One new "Post sticky PR comment" step (gitleaks, only on `pull_request` branch of the workflow) + `permissions:` block | | `docs/ci.md` | New "Sticky PR comments from security scanners" section — behavior, required token scope, failure mode | | `docs/adr/0016-sticky-scanner-pr-comments.md` | New ADR — trade-offs (sticky vs append, per-scanner vs aggregated, delete-on-clean vs update-to-clean, conversation vs inline) | ## Test plan - [x] `actionlint .forgejo/workflows/*.yml` — clean. - [x] Refactor smoke test: ran `security-summary.mjs` against synthetic gitleaks SARIF (empty + 1-leak), synthetic npm audit JSON, synthetic Trivy JSON. Output identical in shape to the existing behavior; `qualifying=<n>` last-line preserved. - [ ] **End-to-end on this PR**: the new step in each scanner job runs against this very PR. Expected outcome: clean PR → no scanner comments posted. If any scanner happens to flag something, that comment lands and the gate behavior is verified against a real PR. - [ ] **Synthetic-finding test (manual, post-merge)**: cut a throwaway branch that introduces a deliberately vulnerable transitive dep (or a synthetic `ghp_…` for gitleaks); confirm the scanner posts a comment, then push a follow-up that fixes the finding and confirm the comment is deleted. ## Note: leftover untracked private key Still in the repo root: `forgejo-signing-key` (private) + `.pub`. Not staged here, but `shred -u` after you've copied it to the Forgejo host. (Repeating from #91 since they survived two more PR cycles.)
ci(security): post scanner findings as sticky PR comments (#68)
All checks were successful
PR / Static analysis (pull_request) Successful in 39s
PR / OSV-Scanner (pull_request) Successful in 29s
Secrets / gitleaks (pull_request) Successful in 13s
PR / Trivy (image) (pull_request) Successful in 1m5s
PR / Typecheck (pull_request) Successful in 2m40s
PR / Test (postgres) (pull_request) Successful in 3m19s
PR / Test (sqlite) (pull_request) Successful in 3m21s
PR / Lint (pull_request) Successful in 3m50s
PR / npm audit (pull_request) Successful in 4m7s
PR / Build (pull_request) Successful in 5m14s
adf4ad233f
Reviewers approve PRs from the conversation view, where the only
scanner signal today is the red/green job badge. The Markdown findings
tables that security-summary.mjs writes are one click into the run
details — useful but rarely opened.

Each scanner now posts its qualifying findings as a sticky PR comment
in addition to the existing $GITHUB_STEP_SUMMARY output. Same Markdown,
visible without leaving the PR.

Structure:

  - Extracted parse/render path into scripts/ci/security-findings.mjs
    so both summary + comment generators share it byte-for-byte.
  - scripts/ci/security-summary.mjs slims to the step-summary CLI it
    always was, now an import of the shared module.
  - scripts/ci/post-pr-comment.mjs is new — sticky upsert via the
    Forgejo `/issues/{n}/comments` API, marker-matched per scanner.

Sticky semantics:

  - Findings exist + no prior comment → POST.
  - Findings exist + prior comment    → PATCH (in place; no spam on
                                              force-push).
  - Findings gone  + prior comment    → DELETE (green CI badge
                                                already says all-clean).
  - Findings gone  + no prior comment → no-op.

Each scanner has its own marker (<!-- scanner-comment: <name> -->),
so the four parallel jobs don't have to coordinate writes.

The new step runs `if: always() && github.event_name == 'pull_request'`
so a failed gate (which is exactly when reviewers need findings
surfaced) doesn't skip it. A failure inside the post step itself
does NOT mask a red gate — security-summary.mjs has already run
and exited the gating step before post-pr-comment.mjs gets a chance.

pr.yml + secrets.yml gain top-level `permissions:` blocks declaring
`issues: write` + `pull-requests: write`. Documented in docs/ci.md
"Sticky PR comments from security scanners". ADR-0016 captures the
trade-offs (sticky vs append, per-scanner vs aggregated,
delete-on-clean vs update-to-clean, conversation vs inline comments).

Closes #68.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 68-sticky-scanner-comments from adf4ad233f
All checks were successful
PR / Static analysis (pull_request) Successful in 39s
PR / OSV-Scanner (pull_request) Successful in 29s
Secrets / gitleaks (pull_request) Successful in 13s
PR / Trivy (image) (pull_request) Successful in 1m5s
PR / Typecheck (pull_request) Successful in 2m40s
PR / Test (postgres) (pull_request) Successful in 3m19s
PR / Test (sqlite) (pull_request) Successful in 3m21s
PR / Lint (pull_request) Successful in 3m50s
PR / npm audit (pull_request) Successful in 4m7s
PR / Build (pull_request) Successful in 5m14s
to 1d0d83bb7c
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 12s
PR / Static analysis (pull_request) Successful in 45s
PR / Lint (pull_request) Successful in 1m5s
PR / OSV-Scanner (pull_request) Successful in 21s
Secrets / gitleaks (pull_request) Successful in 20s
PR / Typecheck (pull_request) Successful in 1m36s
PR / npm audit (pull_request) Failing after 1m27s
PR / Test (sqlite) (pull_request) Successful in 1m51s
PR / Test (postgres) (pull_request) Successful in 1m56s
PR / Trivy (image) (pull_request) Successful in 1m14s
PR / Build (pull_request) Successful in 2m10s
Release / Build, sign, and publish (push) Successful in 44s
2026-06-18 02:23:03 +00:00
Compare
james merged commit dd2f321d6b into main 2026-06-18 02:23:42 +00:00
james deleted branch 68-sticky-scanner-comments 2026-06-18 02:23:42 +00:00
Sign in to join this conversation.
No description provided.