Hands-On Labs · The Capstone · Part 7 of 7

Part 7 — Ship Compliance Evidence & Monitor

Six parts, six real fixes — a parameterized query and a rotated key, a dependency bump behind an SCA gate, a signed non-root image, a Terraform plan two Rego rules now guard, an IDOR and an XSS confirmed gone by an authenticated DAST scan. None of that is provable yet. A fix that only exists as "the pull request merged" is a claim, not evidence, and Part 1's own threat model already named the gap this closes — finding nine, Repudiation, disposition Open — Part 7. Today you write an InSpec profile that encodes three of those fixes as controls you can rerun on demand, roll every stage's findings into one DefectDojo product, and wire a Sigma-authored Wazuh rule to Vulnerly's own access log that fires on the exact SQL-injection shape Part 1 found first — proving not just that it was fixed, but that you'd know if it ever came back.

☺ Explain it like I'm 10

Fixing six things and never checking on them again is like patching six holes in a fence and walking away without ever looking back at the fence. Today does two more jobs. First, you write down a short list of exactly how to check each patch is still there — not "I remember fixing it," a real test you can run again next month. Second, you put one camera on the one hole a fox already tried to squeeze through once, so if anyone tries that exact spot again, you find out the moment it happens, not weeks later when something's already missing.

🐿️🦊Your hosts for this part: Nutty the Squirrel & Foxy — Nutty is the one who's been filing away every scan report since Part 2 and won't call anything "done" without a dated artifact to prove it; Foxy is the one who goes looking through logs after something's already happened, and today she makes sure there's something worth looking through before it does.
⚠ Where you're arriving from, and where you're headed

Arriving: a main branch gated by secrets-scan and sast (Part 2), an SCA gate blocking any build carrying a critical CVE (Part 3), a signed image Kyverno refuses to admit unless cosign verifies it (Part 4), an iac-policy check blocking a public bucket or an open security group before apply (Part 5), and a staging-after ZAP report showing zero High alerts sitting next to a staging-before report that showed two (Part 6). Six fixes, six pull requests, zero standing proof any of them are still true this morning. Leaving this page: a DefectDojo product named vulnerly with an Engagement per stage and every finding rolled into one backlog; a three-control InSpec profile that passes against the hardened environment and — proven, not assumed — fails against a deliberately reverted one; and a Sigma rule, hand-translated into a live Wazuh decoder and rule, that fires the moment anything resembling Part 1's original SQL-injection attempt hits Vulnerly's search endpoint again. This is the last part — there's no Part 8. What comes after is the CDP exam itself.

What Part 1's ninth finding is still waiting on

☺ Like you're 10: Eight gaps in the fence got patched one at a time. The ninth gap was never a hole at all — it was "nobody's checking whether the patches hold."

Reread Part 1's findings table for a moment. Eight of its nine rows name a concrete flaw in Vulnerly's code, dependencies, container, or Terraform — SQL injection, the leaked key, the IDOR, the vulnerable dependencies, the unsigned image, the public bucket, the open security group — and Parts 2 through 6 closed every one of them, in order, each with its own committed proof. The ninth row is different in kind, not degree: "No centralized findings, evidence trail, or alerting — even once Parts 2-6 fix six real bugs, nothing proves any of it happened or stays fixed," tagged Repudiation, disposition Open — Part 7 (DefectDojo + InSpec + Wazuh). That row was never about a bug in the app. It's about the app's proof — and proof doesn't get produced automatically just because the other eight rows did.

Three real gaps sit inside that one row, and they need three different tools, not one bigger one. First: six parts each produced findings in a different shape — Semgrep JSON, a gitleaks report, Trivy JSON, Checkov and Conftest output, ZAP's before/after reports — scattered across six pipeline runs with no single place that shows "here is everything wrong with vulnerly, right now." Second: every fix so far is proven exactly once, at merge time, by the check that caught the original bug. Nothing re-asks the question next week. Third: even a perfectly fixed app has no way to notice someone trying the old attack again — a patched SQL injection and a SQL injection nobody's watching for look identical from the outside until the day the patch quietly regresses.

ThingNameIntroduced
Access loggingmorgan("combined") middleware in app/src/index.jsPart 7 — this page
DefectDojo productvulnerly — one Engagement per capstone part (2, 3, 5, and 6 today)Part 7
InSpec profilesecurity/inspec-profile/inspec.yml + three controlsPart 7
Compliance evidencesecurity/evidence/trivy-image-report.json, inspec-results.jsonPart 7
Detection ruledetections/vulnerly-sqli-attempt.yml (Sigma) → local_decoder.xml + local_rules.xml, rule 100200Part 7

The shape of this part: evidence, verification, watching

☺ Like you're 10: Three separate jobs, three separate tools — one that files every report in one drawer, one that re-checks the patches with its own hands, and one that stands at the one door somebody already tried to pick.

Each of the three tools this page uses solves exactly one of the three gaps above, and none of them substitutes for the others — DefectDojo detects nothing, InSpec doesn't watch anything continuously, and Wazuh doesn't care whether last month's Terraform plan was clean. That's deliberate: compliance & governance and detection engineering & security observability both make this same point in general — verification and monitoring are different disciplines with different failure modes, and bolting them together into one tool tends to do both jobs worse than doing them separately, well.

Roll up findings Part 2 Part 3 Part 5 Part 6 DefectDojo Product vulnerly, 1 Engagement/stage Re-verify the fixes Hardened environment container · bucket · image InSpec 3 controls pass/fail evidence JSON rerun on demand, not just once Watch the door already tried once Vulnerly access log morgan combined Sigma → Wazuh decoder + rule 100200 matches Part 1's attack path 1 shape Alert, level 12 Three tools, three jobs — proof the fixes are real, and a watch on the one door someone already tried. None of the three substitutes for either of the others.

Adding the one thing the app never needed before: access logging

☺ Like you're 10: You can't put a camera on a door if nobody ever writes down who walked through it — so first, someone has to start keeping a guest book.

Every earlier part checked Vulnerly from the outside, at build time, or against a snapshot. None of them needed Vulnerly to remember its own request traffic, so it never has — app/src/index.js still boots a plain Express app with no request logging at all. That has to change before Wazuh has anything to watch. Add morgan in Apache's combined format, which — critically for what's coming — includes the raw request line, query string and all:

// app/src/index.js — Part 7 adds the one line Parts 1–6 never needed
const express = require("express");
const morgan = require("morgan");
require("dotenv").config();

const app = express();
app.use(morgan("combined"));   // every request, including the raw query string, to stdout

// ...the rest of app.js is unchanged since Part 6's route split into app/src/routes/transactions.js

Commit it, let Part 2's and Part 3's gates wave a one-dependency, one-line change through, and redeploy the same way every earlier part did — a new signed digest, admitted because Kyverno still verifies it:

kubectl set image -n vulnerly deploy/vulnerly vulnerly=ghcr.io/YOU/vulnerly:sha-<new digest>
kubectl -n vulnerly rollout status deploy/vulnerly
kubectl -n vulnerly logs deploy/vulnerly --tail=5

A combined-format line looks like this — this exact shape is what the decoder later in this page parses, so look at it closely before moving on:

10.0.1.23 - - [17/Aug/2026:10:22:03 +0000] "GET /reconciliation/search?merchant=acme HTTP/1.1" 200 512 "-" "curl/8.4.0"

Rolling every stage's findings into one DefectDojo product

☺ Like you're 10: One filing cabinet, one drawer per part, instead of six different desks each holding one part's own pile of sticky notes.

Stand up DefectDojo the way the hub already placed it — in the platform namespace of the same vulnerly-dev cluster Kyverno already lives in, via its Helm chart rather than the Docker Compose quickstart, since this is meant to stay up alongside everything else you've already built:

helm repo add defectdojo https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/helm-charts
helm repo update
helm install defectdojo defectdojo/django-defectdojo -n platform \
  --set createSecret=true \
  --set host=defectdojo.platform.svc.cluster.local

kubectl -n platform get pods -l app.kubernetes.io/name=django-defectdojo
kubectl -n platform port-forward svc/defectdojo-django-defectdojo 8443:443   # leave running

# from the dashboard: Settings → API v2 Key, then export it
export DD_API_TOKEN=<your-token>
export DD_URL=https://localhost:8443

Create the one Product every Engagement will hang off of, then import each part's own report into its own Engagement — import-scan for the first run of each, exactly as the DefectDojo tool page describes:

curl -sk -X POST "$DD_URL/api/v2/products/" \
  -H "Authorization: Token $DD_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"vulnerly","description":"The whole seven-part capstone app","prod_type":1}'

# Part 2 — two Tests in one Engagement: the gitleaks and Semgrep gates
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 2 · SAST & secrets" \
  -F "scan_type=Semgrep JSON Report" -F "file=@semgrep-report.json" -F "active=true"
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 2 · SAST & secrets" \
  -F "scan_type=Gitleaks Scan" -F "file=@gitleaks-report.json"

# Part 3 — Trivy's SCA gate
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 3 · SCA gate" \
  -F "scan_type=Trivy Scan" -F "file=@security/evidence/trivy-image-report.json"

# Part 5 — Checkov's IaC scan
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 5 · IaC policy" \
  -F "scan_type=Checkov Scan" -F "file=@checkov-report.json"

# Part 6 — both ZAP reports, before and after, side by side
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 6 · DAST run" \
  -F "scan_type=ZAP Scan" -F "file=@security/zap/reports/staging-before.json"
curl -sk -X POST "$DD_URL/api/v2/import-scan/" -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=vulnerly" -F "engagement_name=vulnerly · Part 6 · DAST run" \
  -F "scan_type=ZAP Scan" -F "file=@security/zap/reports/staging-after.json"

gitleaks-report.json doesn't exist yet — Part 2's job gates on exit code alone, with no JSON artifact saved. Rerun it once with --report-format json --report-path gitleaks-report.json added to produce the evidence file rather than reopening that page to change a job that already works. Confirm each parser name against your own installed version's /api/v2/test_types/ before trusting the strings above verbatim — the tool page flags exactly this as something that shifts release to release.

⚠ Every run after the first uses reimport-scan, never import-scan again

Calling import-scan on the same Engagement a second time creates a brand-new, disconnected Test instead of updating the one you already have — silently breaking auto-close on a fix and discarding every False Positive marking a triager already made. The commands above are each a first import. The next time any of these five gates reruns — including the ones you'll deliberately break in the next section — capture the returned test_id and call reimport-scan against it instead. This is the single most common DefectDojo mistake, named as such on its own tool page, and it's exactly as easy to make here as anywhere else.

Writing the InSpec profile: three controls, real evidence

☺ Like you're 10: Three short questions you can ask the real, running system at any time — not "did we fix it once," but "is it still true right now."

Scaffold the profile and give it four inputs so nothing below hardcodes a name that might change:

inspec init profile security/inspec-profile
inspec check security/inspec-profile   # structure only — no target contacted yet
# security/inspec-profile/inspec.yml
name: vulnerly-capstone-baseline
title: Vulnerly Capstone Compliance Baseline
maintainer: you
license: Apache-2.0
summary: Three controls proving Parts 3, 4, and 5's fixes are still true today, not just the day they merged.
version: 1.0.0
inputs:
  - name: k8s_namespace
    type: string
    value: vulnerly
  - name: k8s_deployment
    type: string
    value: vulnerly
  - name: export_bucket
    type: string
    value: vulnerly-transaction-exports
  - name: aws_endpoint
    type: string
    value: "--endpoint-url http://localhost:4566"   # LocalStack; blank this out for real AWS
supports:
  - platform: linux

Control 1 — the running Pod, not the Dockerfile, must not run as root. Part 4 added a non-root USER to the Dockerfile and a Kyverno policy that refuses an image without one. Neither of those is what actually matters day to day: what matters is the UID the real process is running as, right now, inside the real Pod. This control skips the source entirely and asks the cluster directly:

# security/inspec-profile/controls/container.rb
control 'vulnerly-container-01' do
  impact 1.0
  title 'The running vulnerly Pod must not execute as root'
  desc 'Part 4 hardened the Dockerfile and gated admission with Kyverno. This control proves the deployed reality matches that promise, independent of whether the source or the policy have drifted since.'
  tag part: 'Part 4 — sign & harden the container'

  describe command("kubectl -n #{input('k8s_namespace')} exec deploy/#{input('k8s_deployment')} -- id -u") do
    its('exit_status') { should eq 0 }
    its('stdout.strip') { should_not be_empty }
    its('stdout.strip') { should_not eq '0' }
  end
end

Control 2 — the export bucket must not be public-read. If you haven't already, apply Part 5's now-fixed infra/main.tf against a free, local LocalStack sandbox — a few seconds to stand up, nothing to tear down that costs anything:

docker run -d --name localstack -p 4566:4566 localstack/localstack

# tflocal wraps terraform and rewrites the AWS provider's endpoints for you —
# infra/main.tf itself needs no changes to run against LocalStack
pip install terraform-local
cd infra && tflocal init -input=false
tflocal apply -auto-approve

Then the control itself. InSpec's typed AWS resources don't cleanly expose an ACL's grantee list, so this reaches for the same escape hatch the InSpec tool page already shows for exactly this reason — the generic command resource — and checks for the one string that means "world-readable," the AWS-defined AllUsers grantee group URI:

# security/inspec-profile/controls/iac.rb
control 'vulnerly-iac-01' do
  impact 1.0
  title 'The transaction-export bucket must not be public-read'
  desc "Part 5's Checkov + OPA gate blocks a public-read ACL from ever being *applied* through Terraform. This control checks the *deployed* bucket instead — the only way to catch a change made by hand, straight through a console, after the fact, which a plan-time policy can never see."
  tag part: 'Part 5 — IaC scanning & policy'

  describe command("aws s3api get-bucket-acl --bucket #{input('export_bucket')} #{input('aws_endpoint')} --output json") do
    its('exit_status') { should eq 0 }
    its('stdout') { should_not match(/AllUsers/) }
  end
end

Control 3 — no unresolved critical-severity CVE in the built image. Rather than re-running Trivy inside the InSpec run itself, this control reads Trivy's own JSON output as durable evidence, using InSpec's built-in json resource — a real reused artifact, not a fresh scan pretending to be one:

mkdir -p security/evidence
trivy image --format json --output security/evidence/trivy-image-report.json \
  ghcr.io/YOU/vulnerly:sha-<current digest>
# security/inspec-profile/controls/sca.rb
control 'vulnerly-sca-01' do
  impact 1.0
  title 'No unresolved critical-severity CVEs in the built image'
  desc "Part 3's Trivy gate already blocks a build carrying a critical CVE at merge time. This control re-reads that same scan shape as durable evidence, so 'the gate passed once' survives past the one CI run that produced it."
  tag part: 'Part 3 — SCA gate (Trivy)'

  report = json('../evidence/trivy-image-report.json')
  criticals = report.params.fetch('Results', []).to_a
    .flat_map { |r| (r['Vulnerabilities'] || []) }
    .select { |v| v['Severity'] == 'CRITICAL' }

  describe criticals do
    it { should be_empty }
  end
end

Run the whole profile, keep a dated JSON reporter output as evidence, and hand that file to DefectDojo the same way every other stage's report already landed there — a profile result is a finding source like any other:

inspec exec security/inspec-profile \
  --reporter cli json:security/evidence/inspec-results.json

# InSpec — control vulnerly-container-01: PASSED
# InSpec — control vulnerly-iac-01: PASSED
# InSpec — control vulnerly-sca-01: PASSED
# 3 successful, 0 failures, 0 skipped

Proving the profile fails, not just passes

☺ Like you're 10: A smoke detector nobody's ever actually tested with smoke is a hope, not a working alarm. Prove it screams before you trust that it will.

A profile that has only ever returned green hasn't proven anything yet — it might be checking the wrong thing entirely and you'd have no way to tell. Deliberately break exactly one of the three fixes, rerun the same profile, and confirm the matching control — and only that one — turns red:

# re-widen the bucket by hand, straight through the API — no Terraform involved at all
aws s3api put-bucket-acl --bucket vulnerly-transaction-exports \
  --endpoint-url http://localhost:4566 --acl public-read

inspec exec security/inspec-profile --reporter cli
# InSpec — control vulnerly-container-01: PASSED
# InSpec — control vulnerly-iac-01: FAILED   (exactly the one you broke)
# InSpec — control vulnerly-sca-01: PASSED

# put it back
aws s3api put-bucket-acl --bucket vulnerly-transaction-exports \
  --endpoint-url http://localhost:4566 --acl private
inspec exec security/inspec-profile --reporter cli
# InSpec — control vulnerly-iac-01: PASSED   (green again, within the same minute)
⚠ A profile that only ever passes hasn't proven anything

The failing run above is not optional busywork — it's the only evidence that vulnerly-iac-01 is actually testing the bucket's ACL and not, say, silently passing because of a typo in the bucket name or a swallowed AWS CLI error. The same logic Part 1 applied to proving both drift and prune, and Part 5 applied to proving Conftest fails on the broken plan and passes on the fixed one, applies here identically: a check you've only ever seen succeed is a check you've never actually verified.

Closing the loop: a Sigma rule, a Wazuh rule, and a real test that fires it

☺ Like you're 10: Fixing the lock stops today's fox. A camera on that exact door tells you the moment a fox tries the handle again — even after the lock is already good.

Part 2 already made Vulnerly's reconciliation search endpoint safe against a real SQL injection — the query is parameterized now, and no crafted merchant value can change its structure. That fix does not mean nobody will ever try. Write the detection logic once, portably, the way detection engineering & security observability argues every rule should exist — as a Sigma rule, in git, before it's translated into whatever backend actually runs it:

# detections/vulnerly-sqli-attempt.yml
title: SQL Injection Attempt Against Vulnerly's Reconciliation Search
id: 5e2a9c40-6b31-4f2e-9c1a-7d3b8e410007
status: experimental
description: >
  Flags a request to /reconciliation/search whose query string contains a
  SQL metacharacter or keyword shaped like an injection attempt — the exact
  endpoint Part 1's threat model flagged as Tampering, and Part 2 later
  fixed with a parameterized query. This rule proves the attempt itself
  stays visible even though it can no longer succeed.
references:
  - capstone-threat-model.html#attack-path-1
  - https://attack.mitre.org/techniques/T1190/
author: Shift-Left Squad
date: 2026-08-17
logsource:
  category: webserver
  product: vulnerly
detection:
  selection:
    cs-uri-query|contains:
      - 'UNION'
      - 'SELECT'
      - 'OR 1=1'
      - "OR '1'='1"
      - '--'
      - '%27'
  path_filter:
    cs-uri-stem: '/reconciliation/search'
  condition: selection and path_filter
falsepositives:
  - A merchant name that legitimately contains the substring OR or a stray apostrophe — rare; check Vulnerly's own merchant table before tuning this out
level: high
tags:
  - attack.initial_access
  - attack.t1190

Wazuh isn't a native Sigma compilation target — the detection-engineering page says as much plainly — so this gets hand-translated into Wazuh's own decoder and rule XML rather than run through a converter. First, teach the manager to parse a combined-format access log line into fields:

<!-- /var/ossec/etc/decoders/local_decoder.xml -->
<decoder name="vulnerly-access">
  <prematch>^\S+ \S+ \S+ \[\d</prematch>
</decoder>

<decoder name="vulnerly-access-request">
  <parent>vulnerly-access</parent>
  <regex>"(\S+) (\S+) HTTP\S+" (\d+) </regex>
  <order>http_method, http_uri, http_status</order>
</decoder>

Then the rule itself — Sigma's selection block becomes a single pcre2 field match, and its path_filter is folded straight into the same pattern since Wazuh rules don't have Sigma's separate named-selector syntax. Local IDs start at 100000, exactly as the Wazuh tool page insists on, so a future upstream rule at a low ID can never collide with this one:

<!-- /var/ossec/etc/rules/local_rules.xml -->
<group name="vulnerly,sqli,">
<rule id="100200" level="12">
  <decoded_as>vulnerly-access-request</decoded_as>
  <field name="http_uri" type="pcre2">/reconciliation/search\?.*(UNION|SELECT|OR%201%3D1|OR\s+'1'='1|--|%27)</field>
  <description>Possible SQL injection attempt against Vulnerly's reconciliation search endpoint — Part 1, attack path 1</description>
  <mitre>
    <id>T1190</id>
  </mitre>
  <group>attack.initial_access,attack.t1190,</group>
</rule>
</group>

Stand up Wazuh with its own all-in-one quickstart — a single host is plenty for this lab — then point its collector at the log stream Part 7's new morgan middleware is now producing:

curl -sO https://packages.wazuh.com/4.x/wazuh-install.sh
sudo bash wazuh-install.sh -a

sudo mkdir -p /var/log/vulnerly
kubectl -n vulnerly logs -f deploy/vulnerly >> /var/log/vulnerly/access.log &
<!-- /var/ossec/etc/ossec.conf — appended on the manager -->
<localfile>
  <log_format>syslog</log_format>
  <location>/var/log/vulnerly/access.log</location>
</localfile>
sudo /var/ossec/bin/wazuh-control restart

# lint the new rule against a known-bad line before trusting it against real traffic
sudo /var/ossec/bin/wazuh-logtest
> 10.0.1.23 - - [17/Aug/2026:10:22:03 +0000] "GET /reconciliation/search?merchant=%27%20OR%20%271%27%3D%271 HTTP/1.1" 200 512 "-" "curl/8.4.0"
=> **Rule id: '100200'** (level 12) -> 'Possible SQL injection attempt against Vulnerly's reconciliation search endpoint — Part 1, attack path 1'

Trigger it for real, against the live endpoint — the parameterized query means the response comes back clean, but the alert is what's being tested here, not the query:

curl -s "http://vulnerly.local/reconciliation/search?merchant=foo%27%20OR%20%271%27%3D%271" -o /dev/null

tail -f /var/ossec/logs/alerts/alerts.json | jq 'select(.rule.id=="100200")'
# {
#   "rule": { "id": "100200", "level": 12, "description": "Possible SQL injection attempt..." },
#   "data": { "http_uri": "/reconciliation/search?merchant=%27%20OR%20%271%27%3D%271", "http_status": "200" }
# }

That's the loop closed: the exact attack shape Rocky named in Part 1, unable to succeed since Part 2, is now something the platform notices the instant anyone tries it — regression or fresh attempt, either one. The same decoder-plus-rule pattern generalizes directly: a second, much smaller rule watching CI logs for a credential-shaped string reappearing in a future commit would close the matching half of Part 1's attack path 1 the identical way, catching a regression of Part 2's other fix with the same "count and combine" shape this page just walked through once, in full.

What "done" looks like for Part 7 — and for the whole capstone

☺ Like you're 10: Nine gaps found on day one, eight fixed one at a time, and now proof the fixes hold plus a camera on the one door someone already tried.

At the end of this part: a DefectDojo product named vulnerly holding an Engagement for Parts 2, 3, 5, and 6 with every finding rolled into one backlog; a three-control InSpec profile, proven in both directions, that passes against the real hardened environment and fails the instant one of those fixes is reverted; and a Wazuh rule, authored first as portable Sigma and hand-translated into live XML, that fired on a real request carrying Part 1's original attack shape. That closes Part 1's ninth finding — Repudiation, Open — Part 7 — and with it, the capstone: nine findings named on day one, six concrete fixes proven working, and now durable evidence plus a live watch on the one door somebody already tried. The hub tracks all seven parts in one place if you want the full arc end to end; from here, the CDP study plan and the CDP exam guide are where this hands-on rep turns into exam readiness.

🎬 At the Shift-Left Squad
🐿️

Nutty the Squirrel: Product's created, all four Engagements imported. Six parts of findings, one backlog, finally.

🐢

Timmy the Turtle: Imported isn't proven. Did the InSpec profile actually fail when you widened the bucket back open, or did it just also pass?

🐿️

Nutty: It failed. vulnerly-iac-01, right on cue. Fixed it again and it went green inside a minute.

🦝

Rocky the Raccoon: While you were doing that, I went back and reread my own attack path 1 from Part 1. Leaked key, SQL injection, two doors into the same room. Anyone actually watching that door now?

🦊

Foxy: As of an hour ago. Sent a crafted merchant parameter at the search endpoint myself — ' OR '1'='1 — and rule 100200 fired inside the second. Wouldn't have stopped Part 1's you. Would have told me you were there.

🦝

Rocky: That's the actual answer to my own question, six parts later. Not "we fixed it" — "we'd know if it came back."

🦉

Professor Owl: Which is the whole arc, start to finish. A diagram that named nine gaps in Part 1, six controls that closed them one at a time, and now evidence and a watchtower proving none of it quietly rotted. That's what "done" means for a security program — not just a security fix.

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 / 12 milestones complete
1Confirm Parts 2, 3, 4, 5, and 6 are still green
Rerun each stage's required check once — secrets-scan, sast, the SCA gate, the Kyverno admission check, iac-policy — before building anything new on top of them.
Done when: every earlier gate reports success against main right now, not just at the commit that originally closed it.
2Add access logging and redeploy
Add morgan("combined") to app/src/index.js, push, and confirm the new signed digest is running in vulnerly.
Done when: kubectl -n vulnerly logs deploy/vulnerly shows a real request line, query string included.
3Stand up DefectDojo and create the vulnerly Product
Install the Helm chart into platform, generate an API token, and POST the Product.
Done when: the dashboard shows one Product named vulnerly with zero Engagements.
4Import Parts 2, 3, 5, and 6's findings
Run each import-scan call above, one Engagement per part, including regenerating a JSON gitleaks report for Part 2.
Done when: all four Engagements exist, each with at least one Test and a non-zero finding count where the original scan had findings.
5Scaffold the InSpec profile
inspec init profile security/inspec-profile, then write inspec.yml with the four inputs shown above.
Done when: inspec check security/inspec-profile reports the profile is valid.
Tool: InSpec
6Write and pass vulnerly-container-01
Add controls/container.rb exactly as shown and run it against the real running Pod.
Done when: the control reports PASSED and the printed UID is not 0.
7Write and pass vulnerly-iac-01
Apply Part 5's fixed Terraform against LocalStack (or your own real deployment), then add and run controls/iac.rb.
Done when: the control reports PASSED and the raw get-bucket-acl output contains no AllUsers grant.
8Write and pass vulnerly-sca-01
Generate security/evidence/trivy-image-report.json against the current digest, then add and run controls/sca.rb.
Done when: the control reports PASSED and the computed criticals array is empty.
Tool: Trivy
9Deliberately break one fix and watch the matching control fail
Re-widen the bucket ACL by hand, rerun the profile, confirm only vulnerly-iac-01 turns red, then restore it.
Done when: you've personally seen the profile both pass and fail for the same control, not just pass.
10Write the Sigma rule and translate it into Wazuh
Commit detections/vulnerly-sqli-attempt.yml, then hand-translate it into local_decoder.xml and local_rules.xml (rule 100200) and restart the manager.
Done when: wazuh-logtest against the sample log line above shows rule 100200 firing at level 12.
11Trigger the rule against the live endpoint
Send the crafted curl request above at /reconciliation/search and tail alerts.json.
Done when: a real alert for rule 100200 lands in alerts.json within seconds of the request, triggered by you, not by the log-test tool.
Next: revisit Part 1 and confirm its ninth finding now reads Mitigated, not Open.
12Say out loud what closes — for this part, and the whole capstone
Confirm: DefectDojo's rollup exists, the InSpec profile has been proven in both directions, and the Wazuh rule fired on a real request carrying Part 1's original attack shape.
Done when: you can describe all three without looking anything up — that's the done-when for Part 7, and for the capstone itself.
✓ Checkpoint

1. Why does vulnerly-container-01 run kubectl exec ... -- id -u against the live Pod instead of just reading the Dockerfile's USER line or trusting Kyverno's policy? 2. Why does vulnerly-iac-01 default to a LocalStack endpoint, and what's the one input you'd change to run it against a real AWS account instead? 3. What's the practical difference between import-scan and reimport-scan once Part 7 has already pulled in four parts' worth of findings, and why does it matter across every rescan still to come? 4. Rule 100200 fires on a request that Part 2's fix has already made harmless. Why build a detection rule for an attack that can no longer succeed?

Check your answers
  1. Because the Dockerfile and the Kyverno policy both describe intent — what should be true — not what's actually running this minute. A hand-edited Deployment, a Pod that predates a policy change, or a security-context field that silently didn't take effect would all look fine on paper while running as root in reality. Checking the live process's UID directly is the only version of this check that can't be fooled by a stale or unenforced declaration.
  2. Because it's free, local, and torn down in seconds, matching the "local and throwaway" philosophy the hub sets for the whole capstone, and it means the control is fully runnable without provisioning anything against a real cloud account. To run it against real AWS, blank out the aws_endpoint input (or drop the flag it expands to) so the aws CLI falls back to its normal default endpoint and your configured credentials.
  3. import-scan creates a brand-new, disconnected Test every time it's called; reimport-scan updates the existing Test, preserving its history. Calling import-scan repeatedly across four Engagements each future rescan touches would silently break auto-close on every fix and discard every False Positive marking a triager already made — exactly the mistake DefectDojo's own tool page names as the single most common misconfiguration.
  4. Because "fixed" and "nobody will ever try again" are different claims, and only the first one is actually true here. An attacker (or a future regression) doesn't know the query was parameterized in Part 2 — the attempt itself is a real signal worth seeing, whether it succeeds or not, and the same rule that would have caught Part 1's original, successful injection now catches every future attempt as reconnaissance, which is exactly the kind of early warning a purely reactive "we'll notice when data actually leaks" posture never provides.

Part 7 closes the capstone: a DefectDojo product rolling up six parts of findings, an InSpec profile proven in both directions against the real environment, and a Sigma-authored Wazuh rule that fires on the exact attack shape Part 1 found first. Step back to the capstone hub for the full seven-part arc in one place, revisit Compliance & governance and Detection engineering & security observability for the concepts behind what you just built, and when you're ready to turn this hands-on work into exam readiness, the CDP study plan and the CDP exam guide are where to go next.