Technical Deep Dive Software Supply-Chain Security Case Study · DevForja S.L.

CSI caught a supply-chain compromise in DevForja's CI pipeline before it could exfiltrate a deploy token.

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.

Sector
SaaS engineering (Basque Country, ES)
Framework
CSI + Gitea Actions
Runtime
Local Docker · No cloud
Regulatory frame
NIS2 · DORA · SLSA v1.0 · ENS
01

The Use Case

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:

--- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,2 +15,2 @@ jobs: - name: Send deploy notification - uses: thirdparty-actions/deploy-notify@9ab12cec3a8f0e6d5b41f80d6c72a3e8919c48d7 + uses: thirdparty-actions/deploy-notify@latest --- a/services/notifier/requirements.txt +++ b/services/notifier/requirements.txt @@ -1,2 +1,2 @@ -requests==2.32.3 +reqeuests==2.32.3 pyyaml==6.0.2

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.

Why this matters. If either half of this PR lands, an attacker who can either (a) move a Git tag on 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.
02

Actors

Developer
DevForja Dev

Merges the "cleanup" PR, either from time pressure or genuine misunderstanding of the SLSA baseline.

Security co-pilot
CSI

Reviews every push in ~90s, produces the BLOCKED verdict, later drafts the NIS2 24h early warning.

Poisoned dependency
deploy-notify

Composite Gitea action whose @latest tag was force-moved to a backdoored commit.

Attacker sink
attacker-webhook

Flask sink on the same Docker network. Receives the exfiltrated deploy token from the CI runner.

03

About DevForja S.L.

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:

  • NIS2 — DevForja qualifies as an essential entity and is bound by the Art. 21.2.d supply-chain risk-management obligations plus the Art. 23 incident-notification timelines (24h early warning, 72h notification, 1-month final report to INCIBE-CERT in Spain).
  • DORA — DevForja is not a financial entity itself, but ships to at least one, which drags it into DORA's ICT-third-party regime via Art. 28.

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.

04

Metrics

~90s
CSI review latency
push → BLOCKED
2
Files in the
hypocrite commit diff
0
Tokens exfiltrated
with CSI in the loop
1 + 1
Without CSI: token exfil
+ PWNED.md write-back
05

The Counterfactual — one hypocrite commit, two futures

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.

↑ CSI IN THE LOOP · block is heeded ↓ CSI OVERRIDDEN · block is ignored T+0 Push cleanup PR 2 files · 4 lines ? T+90s BLOCKED CRITICAL · CWE-1357 T+90s Cites SLSA v1.0 Build L3 · Codecov, xz-utils precedents 0 damage 0 tokens · 0 commits T+2m Dev overrides merges anyway T+2:30 CI runs deploy.yml fires T+3:00 @latest → poisoned tag was force-moved T+3:15 Token exfil env → attacker-webhook T+3:20 PWNED.md commits to main exit 0 invisible on CI
CSI in the loop — the pre-merge review path. Ends at 0 damage in under 90 seconds. CSI overridden — five cascading events over ~3.5 minutes, all with CI exit code 0.
06

Challenge · Solution · Results

Challenge

Innocuous-looking "cleanup" hides two supply-chain regressions

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.

Solution

Every push flows through CSI before it can reach main

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.

Results

BLOCKED — CRITICAL, cited to the specific standard clauses

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.

07

Scenario Walkthrough

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.

Baseline — the pinned pipeline before the compromise

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.

Gitea repo tree showing internal-tools with a single v1.0 commit
Shot 01. Gitea's forjaadmin/internal-tools repo tree in the clean baseline state.
Tag list for thirdparty-actions/deploy-notify showing v1.0 and latest
Shot 02. Tag list on 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.
deploy.yml showing pinned 40-char SHA
Shot 03. .gitea/workflows/deploy.yml at HEAD, pinned by SHA as SLSA v1.0 requires.

Scenario 1 — CSI catches the hypocrite commit in pre-merge review

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/.

Anatomy of the hypocrite commit
Two files, four changed characters, two attacker-enabling primitives. Everything else is misdirection.
.gitea/workflows/deploy.yml
        - name: Send deploy notification
-       uses: thirdparty-actions/deploy-notify@9ab12cec3a8f0e6d5b41f80d6c72a3e8919c48d7
+       uses: thirdparty-actions/deploy-notify@latest
Before @9ab12cec3a8f0e6d5b41f80d6c72a3e8919c48d7 — 40-character commit SHA. Immutable by cryptographic construction. If any content on the referenced commit changes, the SHA changes with it. SLSA v1.0 Build L3 (immutable-identifier pinning) satisfied.
After @latest — mutable Git tag. Anyone with write access to 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.
services/notifier/requirements.txt
-requests==2.32.3
+reqeuests==2.32.3
 pyyaml==6.0.2
Real requests — the audited Python HTTP library. 100M+ downloads/month, cryptographically signed releases on PyPI.
Typosquat reqeuests — Levenshtein distance 1 from 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.
Terminal showing the hypocrite diff being pushed
Shot 04. The scenario runner prints the 2-file diff before pushing. The "Both changes read as harmless" warning frames the audience's expectation before CSI arrives.
CSI review output: BLOCKED CRITICAL verdict
Shot 05. CSI's verdict header. 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.
CSI review scrolled to the NIS2/DORA/SLSA regulatory table
Shot 06. CSI maps the finding to the specific articles (NIS2 Art. 21.2.d, DORA Art. 28, SLSA v1.0 Build L3) and the operational consequence: this change alone would fail an external SLSA attestation and NIS2 conformity assessment.
Terminal warning: The developer merges anyway. A CI run is now queued against the poisoned tag
Shot 07a. The demo continues the counterfactual: the developer overrides CSI and merges the "cleanup" PR anyway. The terminal warns that a CI run is now queued against the poisoned @latest tag.
Gitea repo view showing 3 commits including the two chore: dependency & CI hygiene commits at HEAD
Shot 07b. Gitea's 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.

Scenario 4 — the poisoned action detonates on the CI runner (live)

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.

Sequence of the detonation
Message flow between the five actors, top-to-bottom in time. Attack-phase arrows are red.
Developer devforja dev Gitea forjaadmin/internal-tools devforja-runner CI executor deploy-notify @latest · poisoned attacker-webhook Flask sink :9000 T+2m +2s +28s +30s +8s +7s +4s +1s push cleanup PR (merge override) git push origin main push webhook fires POST /webhook { after: eb9cbfcc… } resolve + git clone @latest → backdoored commit c89395fb… inject GITEA_TOKEN into action env token minted at runner startup, now in scope bash entrypoint.sh env passed in-process POST env dump ▸ EXFIL POST /collect { stage, repo, sha, run_id, env: {…GITEA_TOKEN…} } POST /repos/…/PWNED.md ▸ WRITE-BACK Authorization: token $GITEA_TOKEN exit 0 CI dashboard: green ✓ · benign stdout only ATTACK PHASE
Normal control flow — routine CI plumbing that runs on every push. Attack phase — same process, same token, but the resolved code path is attacker-controlled.
Terminal showing PWNED.md HTTP 404 before detonation
Shot 08. Baseline check before triggering. PWNED.md does not exist yet in the target repo — HTTP 404.
Terminal showing exfil landed at attacker-webhook
Shot 09. Within ~1 second of triggering the queued CI run, the attacker sink reports an exfil record. Exit code of the workflow is still 0 — this would be invisible on a stock CI dashboard.
Exfil payload showing GITEA_TOKEN in the environment dump
Shot 10. The exfiltrated payload. GITEA_TOKEN — a real Gitea PAT minted by the runner at startup — is visible in the environment dump. (Token redacted after capture.)
Terminal confirming PWNED.md now exists in forjaadmin/internal-tools, written by the stolen token
Shot 11a. Terminal: 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.
Gitea repo view showing PWNED.md in the file tree of internal-tools, authored by chore: sync deploy artefacts
Shot 11b. Same repo, browser view: 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 commit log showing chore: sync deploy artefacts at HEAD
Shot 12. Gitea's commit history. The unauthorised chore: sync deploy artefacts commit sits at HEAD, authored by the CI deploy identity itself — a real DevForja engineer glancing at the log would have no obvious reason to suspect it.
Where the token travelled
The same GITEA_TOKEN touches five environments in ~20 seconds — and returns to Gitea wearing the CI identity it was created to represent.
GITEA_TOKEN  ·  scope: repo-write  ·  identity: forjaadmin/ci-deploy
01 runner startup

Minted

Gitea · admin API

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.

identity: forjaadmin
scope: write:repository
02 T+3:00

Injected

devforja-runner · action env

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.

held by: runner (trusted)
exposure: subprocess env
03 T+3:08

Read

action · $GITEA_TOKEN

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.

held by: action (untrusted)
exposure: full env dump
04 T+3:15

Exfiltrated

attacker-webhook · POST body

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.

held by: attacker
exposure: persistent copy
05 T+3:19

Replayed

Gitea · token auth

Same token replayed via Authorization: token … against Gitea's contents API. Gitea sees a valid token, writes PWNED.md under the CI identity.

identity: forjaadmin (per Gitea)
artefact: unauthorised commit
The mint side is fine. Gitea has no way to know that the token it created was later abused — the credential is valid by construction, and the caller presents it correctly.
The trust boundary is a single env-var read. Once the token crosses from the runner's memory into an attacker-controlled process, every downstream use looks legitimate. The attacker doesn't need to escalate privileges — CI already had the privileges they wanted.

Scenario 4 — CSI produces the incident report + Spanish 24h notification

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).

CSI report showing MITRE ATT&CK TTP table
Shot 13. MITRE ATT&CK for Enterprise mapping. Every observed step is tied to a specific technique ID — from T1195.001 Compromise Software Dependencies through T1041 Exfiltration Over C2 Channel to T1565.001 Stored Data Manipulation.
Spanish INCIBE-CERT early warning draft
Shot 14. CSI's Spanish-language INCIBE-CERT 24h early warning, drafted in the format expected by the Spanish transposition of NIS2 Art. 23. Ready for the security team to review, adjust, and submit.
The NIS2 Art. 23 clock — what CSI drafts, by when
Under Spain's NIS2 transposition (RDL 7/2024), a confirmed compromise starts three concurrent deadlines the moment attribution completes. CSI produces a draft for each.
DETECTION T+0 attribution complete 1 EARLY WARNING T + 24 h INCIBE-CERT first-signal notice 2 INCIDENT NOTIFICATION T + 72 h INCIBE-CERT + AEPD* impact + assessment 3 FINAL REPORT T + 1 month INCIBE-CERT root cause + remediation
▲ 24 hours · early warning

Preliminary signal to INCIBE-CERT

Short, structured notice: incident detected, sector, initial classification, whether cross-border effects are suspected.

INCIBE-CERT · Spain

CSI drafts:

  • Spanish-language ASUNTO + RESUMEN body
  • NIS2 Art. 23 §1 checklist
  • Contact + escalation stub
▲ 72 hours · incident notification

Detailed notification with impact + severity

Full technical scope, systems + data affected, mitigations already applied, working hypothesis on root cause. AEPD is looped in if personal data was in scope.

INCIBE-CERT · AEPD (if GDPR)

CSI drafts:

  • MITRE ATT&CK-mapped timeline
  • Impact matrix (CIA + data-subject count)
  • Containment actions taken
▲ 1 month · final report

Root-cause + remediation report

Confirmed root cause, effectiveness of the remediation, lessons applied to the supply-chain control set. Feeds the annual conformity review.

INCIBE-CERT · internal audit

CSI drafts:

  • Root-cause narrative (policy, not just tech)
  • Supply-chain audit gap analysis
  • Control-uplift proposal (SLSA v1.0 Build L3)

Scenario 6 — consolidated HTML incident report

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.

HTML incident report open in a browser, Executive Summary section
Shot 15. shared/results/report.html. Executive Summary → Vulnerability Details → Hypocrite Commit attribution → Exploitation Chain → Exposure Window → Regulatory Impact → Remediation → verbatim CSI review.
08

The Attack Chain

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.

Initial Access
T1195.001
Compromise Software Dependencies and Development Tools — mutable @latest tag on thirdparty-actions/deploy-notify repointed at a backdoored commit before it reaches the CI runner.
Execution
T1059.004
Bash entrypoint runs in the CI container with GITEA_TOKEN in env.
Credential Access
T1528
Steal Application Access Token — the Gitea deploy PAT (a REST-API bearer token) is read out of the runner's process env and captured by attacker-controlled code.
Defense Evasion
T1036
Masquerading — the workflow prints the standard "deploy notification sent" banner to stdout and exits 0, so the CI dashboard shows a clean run while the compromise completes silently.
Exfiltration
T1041
POST of the env dump to http://attacker-webhook:9000/collect.
Persistence
T1078
Valid Accounts — the exfiltrated CI deploy PAT remains valid for its full lifetime and gives the attacker continued repo-write access under the CI identity, with no re-exploitation required.
Impact
T1565.001
Unauthorised PWNED.md commit lands on main, attributed to the CI deploy identity.
Command and Control
T1071.001
Application Layer Protocol: Web Protocols — the exfil channel is a plain HTTP POST to attacker-webhook:9000/collect, blending with routine web traffic that CI runners already generate.
09

Regulatory Frame

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.

FrameworkArticleObligation triggered
NIS2Art. 21.2.dSupply-chain security controls — SHA-pinning of build dependencies is the textbook expectation.
NIS2Art. 2324-hour early warning + 72-hour incident notification + 1-month final report to INCIBE-CERT (Spain's CSIRT).
DORAArt. 28ICT third-party risk management — SLA + auditability of external code shipped to financial customers.
DORAArt. 19Major-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.0Build L3Build dependencies pinned by immutable identifier (full SHA / hash).
ENSReal Decreto 311/2022MP.SW (secure software) — mandatory for Spanish public-sector suppliers.