All lessons › Function C — Application Security with an AI SDLC › The AI SDLC — an Agentic AppSec Pipeline, Before and After Deploy
C2.7

Supply chain — SBOM, dependency vulnerabilities, and decompiling the libraries

C2
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/C2.7.mp4
Risk

A clean dependency scan on an estate carrying an undeclared third-party binary reads as evidence of safety, and is evidence of nothing but the manifest's contents.

Control

Reconcile the SBOM against what is on disk, then recover strings, imports and egress from the compiled artefact that no SBOM entry covers.

The lab

Scan an SBOM, then decompile the closed-source library it never mentions.

◉Use case relevance

The test of a research programme is not what it discovered. It is what still protects you after the person who discovered it has left — which is a much smaller list, and a much more useful one.

At CyberTravels. appsec/supplychain.py reconciles requirements.txt against what your code actually imports, and reports two things a scanner will not: mcp is a declared dependency and a first-party package in your tree, and PyJWT is imported as jwt. Without the distribution-to-import map that second one is two findings, both false, from one correct manifest. Then it reads a .pyc for strings and egress, because there is no advisory for a file nobody catalogued.

◴What this lesson is — Day 0, Day 1, Day 2

What a dependency scan actually says — nothing declared has a known vulnerability — and the two checks that say something about the system: reconciling the manifest against what the code imports, and reading the compiled artefact that no manifest entry covers.

Day 0 — why

A clean dependency scan on an estate carrying an undeclared third-party binary reads as evidence of safety.

Day 1 — how

Reconcile the SBOM against what is actually on disk, then recover strings, imports and egress from the binaries it missed.

Day 2 — measure

Artefacts on disk that appear in the SBOM. The gap is the finding, and it is usually not zero.

Measure this

what to measuredenominator or target
artefacts on disk that appear in the SBOMshare; the gap is the finding and is usually not zero

▦The framework, and how it works

   durability ladder

   5  control + eval case      prevents it, and proves it stays prevented
   4  regression case in CI    fails the build when the finding returns
   3  detection rule           fires if the precondition recurs
   2  written repro card       someone else can reproduce it
   1  slide deck               survives; nobody re-runs it
   0  chat thread              gone at the next retention sweep

   only 4 and 5 survive the author leaving

Everything the pipeline has audited so far is code CyberTravels wrote. Most of what it ships is not.

A dependency scanner answers one question, and answers it well: are any of the components I declared known to be vulnerable? Both halves of that sentence are limits, and they are the whole subject of this lesson.

It sees only what was declared. An SBOM is generated from a manifest. A vendor's integration bundle that drops a jar into lib/ is in no manifest, so it is in no SBOM, so the scanner has no identifier to look up — and the report comes back clean. That report is accurate. It is a statement about the manifest, and it is being read as a statement about the build.

It sees only what is already published. "No advisory" means nobody has published one against that identifier yet. For an undeclared artefact there is no identifier at all, so the absence of an advisory carries no information whatsoever — and it renders identically on the report to a component that has genuinely been assessed.

So the procedure has three parts, and only the first is what most teams call software composition analysis:

  1. Scan what was declared — match components against an advisory feed, with a version comparison that is numeric. 1.9 < 1.10 is false as a string and true in every versioning scheme in use, and that one mistake drops findings silently.
  2. Reconcile the SBOM against the filesystem — in both directions. On disk and not in the SBOM is undeclared code. In the SBOM and not on disk means the SBOM is describing a different build than the one you deployed.
  3. Read the artefacts nobody declared. No source, no manifest, no identifier. This is the part that is treated as exotic and is not: compiled code carries its string literals, its class references and its method references in a documented table before any bytecode, and recovering them needs no vendor cooperation and about forty lines of parser.

That third step is what jadx, procyon, Ghidra and strings all start with. The lesson runs it on a real compiled class and gets a hardcoded endpoint and a credential-shaped literal out of a library that appears in no manifest.

Where you are in the pipeline. `` [Ingestion & Mapping] ──> [Threat Modelling] ──> [Discovery] └─ stages 1-4 └─ stages 5-6 └─ stages 7-10 ──> [Dynamic Validation] ──> [Reporting] └─ stages 11-14 └─ stage 15 ``

2 · Two questions, and the one nobody asks

a dependency scanner answersit cannot answer
declaredis this component vulnerable?is anything here I did not declare?
publishedis there an advisory?is this artefact dangerous with no advisory?

The bottom-right cell is where vendored SDKs, agent plugins and MCP server distributions live. Every one of them arrives as somebody else's compiled code inside your build, and none of them appears in a report that reads a manifest.

At CyberTravels the booking provider ships an integration bundle. It works, it was signed off by procurement, and nobody has ever opened it.

3 · Where it breaks — the clean report

The scan below finds three real advisories in five declared components, and every word of it is correct. Then the reconciliation finds a sixth component that is on disk, in no manifest, and therefore in none of the three numbers above it.

The failure is not that the scanner is wrong. It is that "3 findings across 5 components" and "3 findings across 5 of 6 components, one unassessed" are the same report unless somebody counts the filesystem.

4 · The stage, as a skill

Three fixtures and one real artefact. The SBOM is CycloneDX, the advisory feed is OSV-shaped, the inventory is what an image scan returns — and VendorTelemetry.class is a genuinely compiled Java class, built with javac and committed beside the skill. The parser reads its constant pool the way a decompiler does and never sees the source.

The source is in the repository, in evidence/provenance/, so you can check the recovered strings against it. A vendor does not give you that, which is the reason the step exists.

5 · Read the recovered strings again

https://telemetry.vendor-analytics.io/v2/ingest?k=vnd_live_8f2c41a09e7b
AES/ECB/PKCS5Padding
java.net.HttpURLConnection   javax.crypto.Cipher

The endpoint and the key arrived concatenated — the compiler folded them into a single constant, which is a small gift and a real one: the artefact carries its own credential in plaintext, and no SBOM, CVE feed or licence scan would ever have shown you either.

Now the discipline that decides whether this finding survives review. What the constant pool proves is that this library can reach that endpoint and can encrypt before it does. What it sends is not in the constant pool. Write the finding as a capability and it is unarguable; write it as "exfiltrates traveller PII" and the one sentence you cannot evidence takes the rest of the report down with it.

AES/ECB is worth its own line, and it is the kind of thing this step finds by accident: ECB mode leaks structure across blocks, so whatever this is protecting, it is protecting it badly.

6 · What to do with it first

Three moves, in cost order:

  1. Count your undeclared artefacts. Diff the SBOM against the built image. The number is almost never zero and almost nobody has it.
  2. Egress-allowlist the workload, so a capability like the one above cannot become a transfer regardless of what the library intended. This is the same control as B3.3, arriving from a different direction.
  3. Make the reconciliation a build gate. An artefact appearing on disk with no manifest entry is a build-time event, and it is far cheaper to fail there than to find it in a constant pool afterwards.

▶Real time execution as skill

The skill — skills/appsec/supply-chain-decompile/SKILL.md

name: supply-chain-decompile
description: >-
  Scan an SBOM for known vulnerabilities, reconcile it against what is actually
  on disk, and decompile the dependency that no manifest declares. Use when a
  dependency scan comes back clean, when a third party ships a binary bundle,
  or when asked what a closed-source library in the build actually does.
allowed-tools: Read, Glob, Grep, Bash

What is in the build that the manifest never mentioned?

Dependency scanning answers one question well: are any of the components I declared known to be vulnerable? Both halves of that sentence are limits. It sees only what was declared, and only what is already published as an advisory.

A vendor's integration bundle drops a jar into lib/. It is in no manifest, so it is in no SBOM, so no scanner has an identifier to look up and the report comes back clean. The clean report is accurate and it is about the manifest, not about the build.

The step past that is unglamorous and mechanical: reconcile the SBOM against the filesystem, and for anything on disk with no manifest entry, read the artefact itself. Compiled code carries its strings, its class references and its method references in a structured table, and recovering them needs no vendor cooperation and no source.

When to use this

When any third party ships compiled artefacts into your build — SDKs, integration bundles, agent plugins, MCP server distributions. Also whenever a dependency scan is clean on a system that has never had a clean anything, which usually means the scanner is reading a manifest that stopped describing the build some time ago.

Procedure

1 — Scan what was declared. Parse the SBOM, match every component against an advisory feed on (package, version) with a real range comparison, and report each hit with the fixed version. This is the part existing tools do, and it is worth doing first so the gap that follows is visible against it.

2 — Reconcile against the filesystem. List what is actually present in the built image and diff it against the SBOM's components. Two directions, and both are findings: on disk, not in the SBOM is undeclared code, and in the SBOM, not on disk means the SBOM describes a different build.

3 — For undeclared artefacts, read the artefact. No source, no manifest, no identifier to look up. A compiled class file carries a constant pool: every string literal, every class it references, every method it calls, in a documented table before any bytecode. Parse it — the format is stable and the parser is about forty lines.

4 — Read the recovered strings and calls as a capability claim. A URL and an HTTP client class in the same artefact is an egress capability. A crypto class beside them tells you what the traffic will look like on the wire. Report the capability, which is provable from the constant pool, rather than the intent, which is not.

5 — Say plainly what the scan could and could not have found. An undeclared artefact has no CVE because it has no identifier, not because it is safe. Those two produce identical output on a dependency report, and only one of them is a reason to relax.

Example

Input — the fixture committed at the top of scripts/supply_chain_decompile.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

SBOM: cybertravels-booking 4.7.1 · 5 declared components

   CRITICAL org.apache.commons/commons-text 1.9     GHSA-599f-7c49-w659
             Variable interpolation permits script execution (Text4Shell)  -> fixed in 1.10.0
   HIGH     pyyaml 5.3.1   GHSA-8q59-q68h-6hv4
             Arbitrary code execution via full_load / FullLoader  -> fixed in 5.4
   MEDIUM   requests 2.31.0  GHSA-h5c8-rqwp-cp95
             Proxy-Authorization header leaked on redirect  -> fixed in 2.32.0

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "sbom": {"components": 0, "vulnerable": 0},
  "advisories": [{"package": "str", "version": "str", "id": "str",
                  "severity": "str", "fixed": "str"}],
  "reconciliation": {"undeclared_on_disk": ["str"], "declared_not_present": ["str"]},
  "decompiled": [{"artefact": "str", "strings": ["str"], "classes": ["str"],
                  "capabilities": ["str"]}],
  "unassessable_by_sbom": 0
}

unassessable_by_sbom is the number this whole procedure exists to produce. A report where it is absent has quietly claimed that undeclared code is vulnerability-free.

Failure modes

  • Comparing versions as strings. 1.9 < 1.10 is false lexically and true in every version scheme in use. It silently drops the finding.
  • Treating "no advisory" as "no vulnerability". It means nobody has published one against that identifier, and an undeclared artefact has no identifier at all.
  • Reconciling in one direction. Components in the SBOM that are not on disk are the same defect seen from the other side: the manifest and the build have diverged.
  • Reporting intent from strings. The constant pool proves an endpoint and a client class are present. It does not prove what is sent, and a report that says "exfiltrates PII" on that evidence will be dismissed along with the real finding next to it.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons C2.7

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick c2-7-supply-chain:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons C2.7type /c2-7-supply-chain
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons C2.7type /c2-7-supply-chain in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons C2.7type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons C2.7type $c2-7-supply-chain, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py C2.7

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at C2.7 --out work/cybertravels
python3 scripts/checkpoint.py --at C2.7 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

Three of five declared components carry published advisories — Text4Shell in commons-text 1.9 as critical, PyYAML 5.3.1 as high, requests 2.31.0 as medium — and commons-text only appears because the version comparison is numeric rather than lexical. Reconciliation then finds one artefact on disk that is in no manifest, which every one of those numbers excluded. Decompiling it recovers, with no source, a hardcoded telemetry endpoint with the licence key folded into the same literal, an AES/ECB cipher spec, and references to HttpURLConnection and javax.crypto.Cipher — network egress and cryptography as provable capabilities. unassessable_by_sbom: 1 is the number the report exists to produce.

✎Your turn

Generate an SBOM for one service you run, then list what is actually in the built image and diff the two. Every artefact on disk with no manifest entry has been scanned by nothing, and the clean report you have been reading each week was never about it. For the first one you find, pull the strings out of it before you ask the vendor — the conversation goes differently when you open with the endpoint.

Skill source: skills/appsec/supply-chain-decompile

← PreviousC2.6 · Sandbox replication