Pin Semgrep CI image by digest and scope security-events to the upload job (CWE-829) - #31
Pin Semgrep CI image by digest and scope security-events to the upload job (CWE-829)#3107souravkunda wants to merge 3 commits into
Conversation
…ainer job The Semgrep workflow runs a third-party container on a daily cron with `security-events: write` and the repository checked out. Two hardening changes (CWE-829, Inclusion of Functionality from Untrusted Control Sphere): 1. Pin `returntocorp/semgrep` by immutable digest instead of the `1.166.0` tag. A version tag on Docker Hub is still mutable — it can be re-pointed at new content upstream, and the next scheduled run would execute unreviewed code with no PR gate. The digest is the same image that tag resolves to today (multi-arch index, pushed 2026-06-11), so this is a no-op for behaviour. 2. Move the SARIF upload into its own job. `security-events: write` is now held only by a job that runs no third-party code; the container job keeps `contents: read`. If the image is ever compromised it can no longer forge entries in the code-scanning dashboard. The SARIF crosses between jobs as a workflow artifact, uploaded with `if: always()` because `semgrep ci` exits non-zero when it has blocking findings. Also adds a Dependabot config for `github-actions` so the action SHA pins do not go stale. Dependabot cannot bump a workflow `container:` digest (dependabot/dependabot-core#5819), so the refresh command for the image pin is recorded next to it. Ref: https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
Flagged by this repo's own Semgrep scan on the previous commit (package_managers.dependabot.dependabot-missing-cooldown): without a cooldown, Dependabot proposes a version the moment it is published, which is exactly the window a hijacked release exploits. Compromised releases are typically yanked within a few days, so wait 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
07souravkunda
left a comment
There was a problem hiding this comment.
Security-fix review — 2 blocking, 1 nit. Both blockers are tracker-side; the diff itself is clean.
The change does what it claims and I verified it rather than taking it on trust:
- The digest is right.
returntocorp/semgrep:1.166.0resolves tosha256:c180f0c9…— confirmed independently againstregistry-1.docker.io'sDocker-Content-Digest, matching the PR's claim. Same image, so the "no behavioural change" line holds. - It was actually exercised, not just parsed. Run 31599955720 (
8b08eb9):conclusion=success, both jobs green. Run 31599734585 is the better evidence — the container job failed and the SARIF still uploaded, which is the one behaviour the oldif: always()step was protecting. - The permission split is real.
semgrepholdscontents: readonly;security-events: writenow lives on a job that runs no third-party code. That is the third remediation bullet on the ticket, delivered rather than deferred. - Scope is tight. Two files, both under
.github/, no drive-by version bumps, no PHP touched. Nothing here reaches the published Packagist package. The repo is public and carries no internal tracker id in the title, body, commits or diff. - The residuals are honest. The
composer.lockleg genuinely cannot be fixed as the ticket words it — I re-ran the reasoning and Composer 2.9'sblock-insecureaudit does refusephpunit/phpunit: 4.6.*, and forcing a lock pins a high CVE. Likewise, Dependabot really cannot bump a workflowcontainer:digest (dependabot-core#5819), and the PR says so instead of claiming the bullet. Both are the right call, stated plainly. - Also worth noting: the first run's Semgrep finding was in this PR's own new file, and it was fixed rather than waved through. And the file now ends with a newline, which
master's did not.
What blocks, and neither is in the code:
- The two component findings this chain closes are both still
New Itemwith no routing comment — including the one whose remediation is this diff. A human opening either ticket sees no sign a fix exists. - The completion comment on the parent ticket is accurate but unstructured — no
🔎 Summary, so a non-security reader has to parse CVSS vectors to learn what the weakness was.
Details inline. No code change is needed for either; the diff is good as it stands.
There was a problem hiding this comment.
[blocking] The two component findings this change closes are still unrouted on the tracker — including the one whose remediation this file is.
The change itself is correct and I could verify every claim behind it (see the review summary). The gap is on the tracker side: the finding that asks for the digest pin on this exact file is still sitting in New Item with no record that a fix exists, and its sibling is in the same state. Both are still assigned to a different engineer, so from their side nothing has happened.
Evidence
- Chain-breaker finding (digest pin on
.github/workflows/Semgrep.yml) — statusNew Item, assignee unchanged, only comment is a June ETA note from security. Nothing links it to this PR. - Sibling finding (
composer.lock) — statusNew Item, no comment. The fix write-up argues at length that it cannot be fixed as worded (Composer 2.9 refusesphpunit/phpunit: 4.6.*underblock-insecure; forcing a lock pins CVE-2026-24765 high + 3symfony/yamllows). That reasoning is sound and I re-checked it — but it lives only in the parent's comment and in this PR body. The ticket a human will actually triage says nothing. - The parent's completion comment does say "Once merged, LOC-6931 and LOC-6925 can both close; LOC-6927 needs the separate PHPUnit decision" — but a sentence on the parent is not a routing record on the siblings. Whoever picks either one up next sees an untouched ticket.
Fix
- On the chain-breaker finding: a completion comment naming this PR as the fix ("covered by the chain-breaker fix in PR Pin Semgrep CI image by digest and scope security-events to the upload job (CWE-829) #31"), so it closes with the parent.
- On the
composer.lockfinding: its ownwont-do/ dispute comment carrying the audit evidence already gathered (composer-audit-if-locked.txt,composer.lock.rejected-evidence) and the follow-up ask (migratetests/LocalTest.phpoff the PHPUnit 4.6 API), so it can be closed as Won't Do rather than left open with no explanation.
No change to this diff is needed for either.
There was a problem hiding this comment.
Done — both siblings are routed on the tracker now, not just referenced from the parent.
Chain-breaker (LOC-6925 · F-009) — assigned to the operator, moved to Dev in Progress, labelled locsec-fixed, and given a full completion comment naming this PR as its remediation ("Covered by the chain-breaker fix in PR #31"). It carries its own assessment rather than a pointer: its own CVSS mismatch (7.5 stated vs 8.0 recomputed from its own vector), the reachability evidence (the three consecutive scheduled runs that executed the mutable ref), the digest provenance, and the run ids that prove the fix. It closes with the parent.
Sibling (LOC-6927 · F-011) — assigned, moved to Dev in Progress, labelled locsec-wontfix, and given its own won't-do assessment (no-fix-available, because the prescribed remediation is what's unavailable). It carries the audit evidence in full: the exact composer 2.9 block-insecure failure text, the 4 advisories a forced lock would pin (phpunit 4.6.10 → CVE-2026-24765 HIGH, symfony/yaml v3.4.47 → three lows, plus the abandoned mock-objects package), the pointer to composer.lock.rejected-evidence / composer-audit-if-locked.txt, and the follow-up with owner and auto-resolve condition. Recommended close as Won't Do — proposed, not accepted; that call stays with a human.
Three corrections to that ticket went in with it, since a human triaging it would otherwise inherit them: its score disagrees with its own vector (5.9 vs 6.5); its data-flow step 3 rests on the dead Travis composer install; and its Impact overstates blast radius as "all consumers of this library" when Composer never installs a library's require-dev downstream. I also named the root cause the report missed — .gitignore:3 ignores composer.lock deliberately, so any real fix has to change that line.
Also recorded the won't-do as a row in .claude/knowledge/workspace/false-positives.md (Table 2), per the dispute-skill requirement, so the next agent that meets this constraint doesn't re-derive it.
You were right that a sentence on the parent isn't a routing record. Agreed and fixed — no change to this diff.
| # Digest below == returntocorp/semgrep:1.166.0 (multi-arch index, pushed 2026-06-11). | ||
| # To refresh the pin (and update this comment): | ||
| # docker manifest inspect returntocorp/semgrep:<version> -v | grep -m1 Digest | ||
| image: returntocorp/semgrep@sha256:c180f0c93a17b420c0af5006214a29d3c747c5459c732b740191adf657dd0068 |
There was a problem hiding this comment.
[blocking] The completion comment posted on the parent ticket skips the plain-English summary and is free-form prose instead of the standard structure.
(Anchored here because this line is the change the comment describes — the finding itself is tracker-side, not in this diff.)
Every factual claim in it checks out. I re-derived all three CVSS scores from their own vectors (8.7 / 8.0 / 6.5 against the tickets' 8.4 / 7.5 / 5.9), so the flagged mismatch is real and not a v3.1-vs-v4.0 artifact of the same advisory; the digest confirms independently against registry-1.docker.io; and the dead-Travis claim holds (zero status contexts on master's head, composer install present at .travis.yml:9 but nothing runs it). The content is accurate — the shape is the problem.
Evidence — the posted comment opens straight into ### Vulnerability assessment with a paragraph of CVSS vectors. The required shape is a 🔎 Summary heading first (one TL;DR line a non-security reader understands, then Attacker / Path / Impact / Fix bullets), followed by the bold-label bullet sections — Assessment, Fix, Testing, Decision. What is posted has no Summary at all, no Decision heading, and runs as narrative paragraphs rather than labelled bullets.
Concretely, the reader gets no one-liner telling them: anyone who could push to the returntocorp Docker Hub account could have re-pointed the 1.166.0 tag, and the next 6am cron would have run their code inside a runner holding security-events: write. That sentence is the whole ticket for a non-security triager, and it is the one thing missing.
Fix — edit the comment in place (same commentId), prepending the 🔎 Summary section and reflowing the existing sections into the bold-label bullet skeleton. The locsec-fix-done JSON marker block stays as-is; nothing under it needs to change.
There was a problem hiding this comment.
Fixed — edited in place on the same commentId (2283185), so the history stays single-threaded rather than a correction stacked underneath.
The comment now opens with 🔎 Summary: a one-line TL;DR followed by Attacker / Path / Impact / Fix bullets, then Assessment / Fix / Testing / Residuals / Decision as bold-label bullet lists. I used your sentence more or less verbatim as the TL;DR — anyone who could push to the returntocorp Docker Hub account could have re-pointed the 1.166.0 tag, and the next 6am cron would have run their code inside a runner holding security-events: write — because it is a better opening line than what I had. Attacker is stated as a different principal (an outside publisher, so a real trust boundary), which was the other thing a triager couldn't tell from the old prose.
One deviation from your instruction, flagged rather than done quietly: I did not keep the marker block as-is — I shrank it. It was ~4 KB of JSON that re-encoded the whole assessment/fix/testing prose. The skill's § Marker section makes that a hard size guard, not a preference: only disposition, operator and pr_url are machine-read, and a ~15 KB ADF body has tripped the 300s addCommentToJiraIssue timeout before. Since the reflow into Summary + bullets adds visible text, keeping the fat marker pushed toward that ceiling for no benefit, so it is now the minimal {"schema":"locsec/v1","disposition":...,"operator":...,"pr_url":...}. Nothing machine-read was dropped, and every fact that was only in the JSON is now in a visible section. Say the word if you'd rather I restore the long form.
While reflowing I also folded in what changed this round — the Also affects bullet pointing at LOC-6927, and a Residuals section covering both the composer.lock constraint and the Dependabot-can't-bump-container-digests limitation — so the parent comment matches the tracker state after the sibling routing above.
And thank you for re-deriving all three CVSS scores independently rather than taking mine; that mismatch claim is load-bearing for the ticket and it's worth more having been checked twice.
| security-events: write # for github/codeql-action/upload-sarif to upload SARIF results | ||
|
|
||
| steps: | ||
| - name: Download SARIF artifact |
There was a problem hiding this comment.
[nit] When the semgrep job dies before writing the SARIF, this job now turns red too, with a misleading reason.
The interesting failure mode — semgrep ci exits 1 on blocking findings — is handled correctly, and run 31599734585 proves it: the container job failed and the SARIF still reached the dashboard through this job. That is the case that mattered and it works.
The uncovered case is a failure upstream of semgrep ci: a container-pull failure (registry outage, or the pinned digest going away), or actions/checkout failing. Then no step runs, Upload SARIF as a workflow artifact never fires, and if-no-files-found: error is moot because there is no artifact at all. upload-sarif still starts (if: always()), Download SARIF artifact errors on a missing artifact, and the run shows two red jobs where the pre-split workflow showed one. The second one points at artifact download rather than at the actual cause.
Not a correctness problem — nothing is silently lost, and the dashboard behaviour is unchanged. Purely about not sending someone debugging a registry outage to a download step.
If you want it tighter, either gate the job on the artifact-producing step having run, or let the download tolerate absence:
- name: Download SARIF artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: semgrep-sarif
continue-on-error: truewith the upload step then guarded on the file existing. Equally fine to leave as-is and let the container job's own red tell the story.
There was a problem hiding this comment.
Taken — 233e0b0. You offered "equally fine to leave as-is", but the case you describe is real and I'd rather not ship a workflow that points a debugger at the wrong step during a registry outage.
Went with your second suggestion, both halves of it:
- name: Download SARIF artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: semgrep-sarif
continue-on-error: true
- name: Upload SARIF file for GitHub Advanced Security Dashboard
uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0
with:
sarif_file: semgrep.sarif
if: hashFiles('semgrep.sarif') != ''The guard on the upload step is what makes continue-on-error safe rather than just quieter — without it the upload would run against a nonexistent file and fail anyway, one step later. hashFiles() returns an empty string for a missing path, and this job does no checkout, so the file exists only if the download actually produced it.
I preferred this over gating the job on a needs.semgrep.outputs.* flag: job outputs from a failed job are propagated, so it would work, but it means threading a step output through a job output to express something the file's presence already states directly.
Net effect is what you asked for — an upstream failure now shows exactly one red job, the container job, which is the one that can explain itself. The case that mattered is unchanged: semgrep ci exiting 1 on blocking findings still writes the SARIF, uploads it, and reaches the dashboard, which is what run 31599734585 demonstrated.
No new CI evidence for the new path, and I'd rather say so than imply otherwise: forcing a container-pull failure would mean pushing a deliberately bogus digest, and I didn't think a broken commit in the PR history was worth it. The reasoning above is the argument; the normal path stays covered by the runs already on the PR.
Review nit: if the container job dies before `semgrep ci` runs — a container-pull failure, or checkout failing — no SARIF is written, and the upload job then went red on "artifact not found", pointing a debugger at the download step instead of at the real upstream cause. Two reds where the pre-split workflow showed one. Tolerate the missing artifact and skip the upload when there is no file. The normal case is unaffected: `semgrep ci` exiting 1 on blocking findings still writes the SARIF, still uploads it, and still reaches the dashboard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
07souravkunda
left a comment
There was a problem hiding this comment.
Re-review (round 2) — both blockers resolved. No blocking findings; 1 nit, non-gating.
I re-read the diff from scratch at 233e0b04 rather than diffing against my last read, and re-checked each prior finding against the tracker rather than assuming the fixer's word for it.
Round-1 blocker 1 — chain siblings unrouted: resolved.
- The chain-breaker finding now carries a full completion comment naming this PR ("Covered by the chain-breaker fix in PR #31"), moved to
Dev in Progress, labelledlocsec-fixed, reassigned. - The
composer.lockfinding now carries its own won't-do assessment — the composer evidence, the corrected data flow, a named follow-up (migrate off PHPUnit 4.6, then commit the lock and drop it from.gitignore), and an explicit "I am proposing this, not accepting the risk: that call is yours." That is the right posture; it recommends rather than decides.
Round-1 blocker 2 — missing plain-English summary: resolved. The parent's comment was edited in place on the same commentId (created 18:45, updated 20:13), so there is no duplicate. It now opens with the 🔎 Summary and the Attacker / Path / Impact / Fix walkthrough, and the added Also affects bullet states up front that the second leg is not fixed — the residual is now visible in the first paragraph a triager reads, not buried.
Claims in the new comments, spot-checked against origin rather than taken on trust:
Semgrep.yml:30for the image onmaster— correct at line 30 (the ticket's:31was from the oldere563485).- Dependabot alert #1 —
GHSA-vvj3-c3rp-c85p, high,phpunit/phpunit, fromcomposer.json, open. Matches exactly. - The "proven live" cron claim — run
31569942332isevent: schedule, 2026-08-12 06:25Z, onmaster's head. Confirmed. - "Binding frozen since 2016" — last commit touching
lib/is 2016-09-19. - The CVSS recomputations (8.7 / 8.0 / 6.5) I re-derived independently last round; unchanged and correct.
Round-1 nit — noisy second failure: fixed in 233e0b04, and verified. Run 31607926461: both jobs green, and this time I checked step level — the upload step ran with conclusion success, not skipped, so hashFiles() resolved as intended and the SARIF landed (Semgrep OSS is present on the new head). The commit is scoped to exactly that change and nothing else.
Gates re-run on the new head: scope still two files under .github/, no drive-by edits, digest still resolves correctly at the registry, no internal tracker id in the title, body, commit messages or diff, and no regression test — correct for a config pin.
One nit inline on the trade the fix makes. It doesn't gate; leaving it as-is is defensible. Nothing here needs another round from my side — this is ready for a human to approve and merge.
| with: | ||
| sarif_file: semgrep.sarif | ||
| if: always() No newline at end of file | ||
| if: hashFiles('semgrep.sarif') != '' |
There was a problem hiding this comment.
[nit] This does what I asked for, and it works — but it's worth naming the trade it makes, since I'm the one who suggested it.
Verified on the new head: run 31607926461 — Download SARIF artifact success, this step success (not skipped), and Semgrep OSS shows up as a check on 233e0b04, so the SARIF genuinely reached the dashboard. hashFiles() resolves against GITHUB_WORKSPACE and download-artifact lands there by default, so the path lines up. The implicit success() that GitHub ANDs into any non-status if also stays true, because continue-on-error leaves the job status untouched.
The trade: continue-on-error + a file-existence gate can't tell "no SARIF was ever produced" (benign — the container job already went red for the real reason) apart from "the SARIF existed and was uploaded, but the download failed" (a transient artifact-service or network failure). The first is what we wanted to stop being noisy. The second used to be a red job; now it's a green run with nothing delivered to the dashboard, and no signal anywhere.
I've reasoned that path rather than exercised it — the PR runs only cover the happy path, and I wouldn't ask you to force an artifact-download failure to prove it.
Low stakes either way: the next push or the 6am cron re-uploads, so a missed upload self-heals within a day, and code scanning is not the enforcement gate here. Leaving it as-is is a defensible call. If you'd rather keep the loud signal for the case that deserves it, gating on the producing step is the discriminator — e.g. give the semgrep ci step an id and pass its outcome through a job output, then let a genuine download failure stay red when that step actually ran.
What
CI supply-chain hardening for the Semgrep workflow (CWE-829 — Inclusion of Functionality from Untrusted Control Sphere).
returntocorp/semgrepby immutable digest instead of the1.166.0tag.security-events: writeis no longer held by the job that runs the third-party container.github-actionsso the action SHA pins don't go stale.Why
.github/workflows/Semgrep.ymlruns a third-party container image on a0 6 * * *cron (plus every push/PR tomaster), in a job that has the repository checked out and holdssecurity-events: write.1.166.0was a real improvement over:latest, but Docker Hub tags can be re-pointed at new content upstream, and the next scheduled run would pull and execute it with no PR gate. A digest cannot be re-pointed — updating it now requires a reviewable commit, which restores the audit trail.security-events: write. Only the SARIF upload does. As written, a compromised image could forge or suppress entries in the repository's code-scanning dashboard. Splitting the jobs means the third-party code runs withcontents: readand nothing else.Changes
.github/workflows/Semgrep.ymlThe digest is the multi-arch OCI index that
returntocorp/semgrep:1.166.0resolves to today (pushed 2026-06-11) — confirmed independently againstregistry-1.docker.io(Docker-Content-Digest) and the Docker Hub v2 tag API. Same image, so no behavioural change; pinning the index rather than a per-arch manifest keeps the ref architecture-agnostic. The human-readable version and the command to refresh the pin are recorded in a comment next to it.Job layout:
semgrep(unchanged name, so any required check keeps matching)contents: readupload-sarif(new)contents: read,security-events: writeThe SARIF crosses between them as a workflow artifact. Both the artifact upload and the new job use
if: always(), becausesemgrep ciexits non-zero whenever it has blocking findings — without that, the SARIF would stop reaching the dashboard on exactly the runs that matter..github/dependabot.yml(new) — weeklygithub-actionsupdates, with a 7-daycooldownso a version isn't proposed the day it's published. Note that Dependabot cannot bump a workflowcontainer:digest: itsdockerecosystem only parses Dockerfiles, Kubernetes manifests and Helm values (dependabot-core#5819 is still open), andgithub-actionsonly coversuses:refs. That's why the manual refresh command is in the workflow comment — adopting Renovate, which does support workflow container digests, would automate it.Testing
The workflow is exercised by this PR itself — the
pull_requesttrigger runs the modified file, so the runs on this PR are the test.semgrep/ciupload-sarif(new)1fc44b38b08eb9Digest pin confirmed from the runner log —
docker pull returntocorp/semgrep@sha256:c180f0c9…→semgrep 1.166.0 on python 3.12.13, i.e. the digest resolves to exactly the version the tag named.The permission split is confirmed by the first run, which is the more informative one: the
semgrepjob failed and the SARIF still reached the dashboard, because the artifact upload and the new job both carryif: always(). That preserves the one behaviour that mattered about the old upload step.The first run's finding was in this PR's own new file, and it was a fair catch. Semgrep's
p/defaultflaggedpackage_managers.dependabot.dependabot-missing-cooldownon.github/dependabot.yml— a Dependabot with no cooldown proposes a version the instant it's published, which is the same window a hijacked release exploits. Fixed in8b08eb9; the re-run is clean.Also checked locally before pushing: both files parse as YAML; every third-party ref is immutable (digest or full commit SHA, no
@vNremaining); and asserted programmatically that no job both runs the container and holdssecurity-events: write. The image digest was cross-checked against two independent sources (registryDocker-Content-Digestheader and the Docker Hub v2 tag API).One thing the green here does not mean: on
pull_request,semgrep ciis diff-aware and scanned only the 2 changed files.master's full scan still exits 1 with 5 pre-existing findings (e.g. run 31569942332) — untouched by this PR.Not in this PR
A
composer.lockwas considered — the repo has none, socomposer installresolvesphpunit/phpunit: 4.6.*and its transitives fresh against Packagist on every clone. It turns out a lockfile cannot be committed here without making things worse. Composer 2.9 refuses to resolve that constraint at all under its defaultblock-insecureaudit, and forcing it (--no-security-blocking) produces a lock that pins 4 known advisories across 2 packages:phpunit/phpunit 4.6.10— CVE-2026-24765 (high; fixed in 8.5.52+)symfony/yaml v3.4.47— CVE-2026-45304, CVE-2026-45305, CVE-2026-45133 (low)phpunit/phpunit-mock-objects 2.3.8— abandonedCommitting that would require an
audit.ignorefor a high-severity CVE and would pin the vulnerability into the dependency graph. The advisory is in any case already visible without a lockfile — Dependabot alert #1 on the default branch is exactlyGHSA-vvj3-c3rp-c85p, detected fromcomposer.json. The real fix is to move off PHPUnit 4.6 — which means migratingtests/LocalTest.phpoff thePHPUnit_Framework_TestCaseAPI — and that belongs in its own PR, separate from CI pinning. Nothing here ships to consumers: both the image and PHPUnit are CI/dev-only (Composer never installs a library'srequire-devfor downstream consumers).Worth noting for whoever picks that up: no CI job currently runs
composer installat all..travis.ymlstill references it, but Travis is no longer wired to this repo — recent commits report no Travis status contexts, onlysemgrep/ciandAnalyze (actions).