A hypocrite commit swapped a pinned Actions SHA for @latest and typosquatted a Python dependency in a single "cleanup" PR. CSI blocked it in pre-merge code review with a CRITICAL verdict, mapped the finding to NIS2 Art. 21.2.d, DORA Art. 28, and SLSA v1.0, and — in a controlled counterfactual run — reconstructed the full attack chain including a live token exfiltration and a retaliatory commit written back to the repository under the CI identity.
DevForja S.L. is a mid-sized Basque SaaS engineering shop whose internal-tools monorepo ships a Python notifier and a Node ticketing façade behind a Gitea CI pipeline. Because DevForja is an in-scope essential entity under NIS2 and a supplier to at least one DORA-regulated financial customer, every third-party build dependency is expected to live under an SLSA v1.0 baseline: pin by full commit SHA, never by tag or branch.
A developer opens a pull request titled chore: dependency & CI hygiene. Two files change:
The commit message reads as innocuous maintenance: "swap the pinned SHA for @latest so we pick up upstream fixes automatically" and "correct typo in requests package name". Both framings invert the actual risk. Automatic upstream pickup is the property that makes a mutable ref dangerous — SLSA v1.0 exists precisely to prevent it. And the "correction" is the typosquat, not the fix: the real Python package is requests; the diff introduces the squat.
thirdparty-actions/deploy-notify or (b) register the PyPI name reqeuests gets arbitrary code execution inside DevForja's CI runner with the GITEA_TOKEN in scope. This is the primitive behind Codecov 2021, ua-parser-js 2021, event-stream 2018, and (structurally) xz-utils 2024.Merges the "cleanup" PR, either from time pressure or genuine misunderstanding of the SLSA baseline.
Reviews every push in ~90s, produces the BLOCKED verdict, later drafts the NIS2 24h early warning.
Composite Gitea action whose @latest tag was force-moved to a backdoored commit.
Flask sink on the same Docker network. Receives the exfiltrated deploy token from the CI runner.
DevForja S.L. is a fictional Basque SaaS engineering shop (~120 engineers, ~40 M€ ARR) chosen for this deep-dive because it sits at the intersection of the two regulatory regimes most bruising to European software vendors right now:
DevForja's documented supply-chain baseline is SLSA v1.0. What this case study demonstrates is what happens when a single developer merges a change that regresses the baseline in one file, in one commit, in one PR — and whether CSI catches it before the CI pipeline detonates.
Same commit, same pipeline, same regulator obligations. The only variable is whether the developer heeds CSI's block. The timeline below traces both branches from T+0 to the final artefact each produces — the top lane is the demo you saw; the bottom lane is what happens if the block is overridden.
A trusted internal developer opens a 2-file PR that simultaneously introduces a mutable-ref supply-chain regression and a typosquatted Python dep, framed as ordinary hygiene.
Both changes pass syntax review, both compile, both look plausible to a hurried reviewer. Neither is caught by DevForja's linting or unit tests. The commit message is written to invert the actual risk narrative.
Every push to internal-tools/main fires a Gitea webhook into CSI. CSI receives the diff, the commit message, and the vertical context (NIS2 / DORA / SLSA v1.0 policy). It reasons about both changes together, not just their syntax.
Verdict is written back to shared/results/latest.txt, tagged for the exact commit SHA, in under 90 seconds.
BLOCKED — CRITICAL. CSI cited CWE-829 + CWE-494 + CWE-1357 + SLSA v1.0 Build L3, referenced four historical precedents by name, produced the exact one-line fix (repin to the audited SHA + add --require-hashes), and flagged the NIS2 / DORA obligations that would trigger if the change reached production.
In the counterfactual run where the block is overridden, CSI then reconstructed the attack chain end-to-end and drafted the Spanish-language INCIBE-CERT early warning.
The demo runs six scenarios end-to-end (~10 minutes on a laptop). The walkthrough below curates the three that carry the strongest visual case — Scenario 1 (pre-merge review), Scenario 4 (live detonation + attribution), and Scenario 6 (consolidated report) — plus the baseline state before the compromise. Scenarios 2 (CI-log anomaly triage), 3 (supply-chain vulnerability prioritisation, 8 findings), and 5 (build-platform multi-asset audit) run in the same execution but are documented in the repository rather than shown as shots here. Every artefact shown is produced live by CSI + the in-stack CI runner — nothing is fabricated.
Gitea shows internal-tools at v1.0, one commit, pushed by the platform team. The deploy.yml workflow pins thirdparty-actions/deploy-notify to a full 40-character SHA, and the thirdparty-actions org exposes both a v1.0 and a latest tag — this is the state a real DevForja engineer would see when they open the repo on Monday morning.
forjaadmin/internal-tools repo tree in the clean baseline state.
thirdparty-actions/deploy-notify. v1.0 is at the audited SHA; latest has already been silently repointed to the backdoored commit at demo-setup time — the audience just doesn't know it yet.
.gitea/workflows/deploy.yml at HEAD, pinned by SHA as SLSA v1.0 requires.The developer pushes the "cleanup" PR. Gitea fires the webhook. The ci-handler service builds the CSI prompt from the vertical config (NIS2 / DORA / SLSA), attaches the diff, and enqueues a review task. The CSI agent processes it and writes back to shared/results/.
- name: Send deploy notification - uses: thirdparty-actions/deploy-notify@9ab12cec3a8f0e6d5b41f80d6c72a3e8919c48d7 + uses: thirdparty-actions/deploy-notify@latest
thirdparty-actions/deploy-notify can force-push the tag to point at arbitrary code at any moment. No signature verification, no audit hook. Directly violates SLSA v1.0 Build L3 and NIS2 Art. 21.2.d supply-chain controls.
-requests==2.32.3 +reqeuests==2.32.3 pyyaml==6.0.2
requests (single inserted e between q and u). The PyPI name was unregistered at demo time; any actor can claim it and ship a package whose setup.py runs arbitrary code on pip install. Historical precedents: python-nose squats, colourama, reuests.
BLOCKED — CRITICAL in the first line, with CWE-1357 + SLSA v1.0 + OpenSSF-Scorecard classification and a per-half explanation of why the commit message inverts reality.
@latest tag.
forjaadmin/internal-tools immediately after the merge: chore: dependency & CI hygiene now sits at HEAD (commit 455fa73ce7), the workflow reference has flipped, and CI is one webhook away from executing the poisoned action.When CI resolves uses: thirdparty-actions/deploy-notify@latest, it lands on the backdoored commit. Three things happen in the same run, all with exit code 0: (1) the poisoned entrypoint dumps the CI environment to http://attacker-webhook:9000/collect, (2) it uses the exfiltrated Gitea deploy token via the Gitea contents API to commit PWNED.md back to internal-tools under the CI identity, (3) it still prints the normal "deploy notification sent" banner to stdout so the workflow run reads as a clean successful deploy.
PWNED.md does not exist yet in the target repo — HTTP 404.
GITEA_TOKEN — a real Gitea PAT minted by the runner at startup — is visible in the environment dump. (Token redacted after capture.)
PWNED.md — written using the exfiltrated GITEA_TOKEN — is now present in forjaadmin/internal-tools. The write-back happened silently, mid-workflow, under the CI deploy identity.
PWNED.md is now in the file tree of internal-tools, authored by an unremarkable-looking chore: sync deploy artefacts commit (e6348ea16e). Nothing on the CI dashboard indicates something is wrong.
GITEA_TOKEN touches five environments in ~20 seconds — and returns to Gitea wearing the CI identity it was created to represent.At container boot the runner authenticates as admin and calls POST /users/<user>/tokens to create a real Gitea PAT with repo-write scope. Once minted, the token sits in the runner's memory for the entire container lifetime.
Runner spawns the poisoned action's subprocess and passes GITEA_TOKEN inline in its environment. The trust boundary is about to be crossed — the token is one bash read away from attacker-controlled code.
Poisoned entrypoint.sh reads the env var. Boundary crossed — token is now inside attacker-controlled code and the entire process env is available for exfiltration.
Token POST'd to /collect as JSON. Persisted to /shared/exfil/collected.jsonl. Now under attacker control indefinitely — even after the runner rotates the token, the exfiltrated copy remains valid for as long as it's not revoked.
Same token replayed via Authorization: token … against Gitea's contents API. Gitea sees a valid token, writes PWNED.md under the CI identity.
With the artefacts now on record, the same CSI agent is given the timeline, the exfil sink log, and the compromised commit. It produces the full incident report suitable for the CISO — including a Spanish-language INCIBE-CERT early warning in the format expected by Spain's NIS2 transposition (Real Decreto-ley 7/2024).
Short, structured notice: incident detected, sector, initial classification, whether cross-border effects are suspected.
CSI drafts:
Full technical scope, systems + data affected, mitigations already applied, working hypothesis on root cause. AEPD is looped in if personal data was in scope.
CSI drafts:
Confirmed root cause, effectiveness of the remediation, lessons applied to the supply-chain control set. Feeds the annual conformity review.
CSI drafts:
The report generator pulls the scenario-1 review, the scenario-4 detonation artefacts, and the scenario-5 platform audit into a single self-contained HTML document. It's the deliverable that leaves the security team's desk and lands on the CISO's + the auditor's.
shared/results/report.html. Executive Summary → Vulnerability Details → Hypocrite Commit attribution → Exploitation Chain → Exposure Window → Regulatory Impact → Remediation → verbatim CSI review.CSI's incident report maps the compromise to MITRE ATT&CK for Enterprise. Every stage below corresponds to an observable log entry in either Gitea's activity feed, the CI runner's execution log, or the attacker-webhook sink.
@latest tag on thirdparty-actions/deploy-notify repointed at a backdoored commit before it reaches the CI runner.GITEA_TOKEN in env.http://attacker-webhook:9000/collect.PWNED.md commit lands on main, attributed to the CI deploy identity.POST to attacker-webhook:9000/collect, blending with routine web traffic that CI runners already generate.DevForja's exposure isn't hypothetical. Every regulation below has a specific article that the exploit chain violates and a specific notification obligation that fires if the compromise reaches production.
| Framework | Article | Obligation triggered |
|---|---|---|
| NIS2 | Art. 21.2.d | Supply-chain security controls — SHA-pinning of build dependencies is the textbook expectation. |
| NIS2 | Art. 23 | 24-hour early warning + 72-hour incident notification + 1-month final report to INCIBE-CERT (Spain's CSIRT). |
| DORA | Art. 28 | ICT third-party risk management — SLA + auditability of external code shipped to financial customers. |
| DORA | Art. 19 | Major-incident classification & reporting to Banco de España (competent authority for the financial customer), forwarded upstream to the ESAs per Art. 19(2). |
| SLSA v1.0 | Build L3 | Build dependencies pinned by immutable identifier (full SHA / hash). |
| ENS | Real Decreto 311/2022 | MP.SW (secure software) — mandatory for Spanish public-sector suppliers. |