From 1ffc819a13fe20286eab0f49645815c517bb4e2f Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 11 Jun 2026 23:19:11 +0400 Subject: [PATCH 01/11] fix(lec1,labs): repair Slide 5 mermaid syntax; 4-backtick template fences; lab1 /api/Products endpoint - lec1 Slide 5: '-.-->' is not valid mermaid (use '-.->'), quote emoji labels - all submission templates with nested code blocks now use 4-backtick outer fences so GitHub stops closing them at the first inner fence - lab1 template + acceptance criteria still referenced /rest/products, which 404s in Juice Shop v20.0.0 (moved to /api/Products) Signed-off-by: Dmitrii Creed --- labs/lab1.md | 12 ++++++------ labs/lab11.md | 12 ++++++------ labs/lab12.md | 12 ++++++------ labs/lab6.md | 4 ++-- labs/lab7.md | 8 ++++---- labs/lab8.md | 12 ++++++------ labs/lab9.md | 12 ++++++------ lectures/lec1.md | 8 ++++---- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/labs/lab1.md b/labs/lab1.md index f33e69842..c37b0b302 100644 --- a/labs/lab1.md +++ b/labs/lab1.md @@ -95,7 +95,7 @@ Open `http://127.0.0.1:3000` in your browser. Note what you see: Create `submissions/lab1.md` and **fill in the template below** with your real observations. Don't paraphrase — record what you actually saw. -```markdown +````markdown # Lab 1 — Submission ## Triage Report: OWASP Juice Shop @@ -115,7 +115,7 @@ Create `submissions/lab1.md` and **fill in the template below** with your real o ### Health Check - HTTP code on `/`: -- API check (first 200 chars of `/rest/products`): +- API check (first 200 chars of `/api/Products`): ``` ``` @@ -143,7 +143,7 @@ Which of these are MISSING? (cross-reference Lecture 1 OWASP Top 10:2025 — A06 1. **** — 2. **** — 3. **** — -``` +```` ### 1.4: Cleanup (when done) @@ -274,7 +274,7 @@ A "GitHub Community" section with 1-2 sentences explaining: ### B.3: Document in `submissions/lab1.md` -```markdown +````markdown ## Bonus: CI Smoke Test - Workflow file: `.github/workflows/lab1-smoke.yml` @@ -285,7 +285,7 @@ A "GitHub Community" section with 1-2 sentences explaining: ``` ``` -``` +```` --- @@ -316,7 +316,7 @@ PR checklist (paste this into your PR body): ### Task 1 (6 pts) - ✅ Juice Shop v20.0.0 container running on `127.0.0.1:3000` (proof: `docker ps` output in submission) -- ✅ Homepage returns HTTP 200; `/rest/products` returns a JSON list +- ✅ Homepage returns HTTP 200; `/api/Products` returns a JSON list - ✅ Triage report has all six sections filled in with **real** values (no template placeholders left) - ✅ At least three security headers are correctly identified as present or missing - ✅ Top 3 risks each mapped to an OWASP Top 10:2025 category (A01–A10) diff --git a/labs/lab11.md b/labs/lab11.md index 54e4104d2..fb145dc9b 100644 --- a/labs/lab11.md +++ b/labs/lab11.md @@ -123,7 +123,7 @@ curl -skI https://localhost | tee labs/lab11/results/headers.txt ### 11.4: Document in `submissions/lab11.md` -```markdown +````markdown # Lab 11 — BONUS — Submission ## Task 1: TLS + Security Headers @@ -155,7 +155,7 @@ curl -skI https://localhost | tee labs/lab11/results/headers.txt - Referrer-Policy: ... - Permissions-Policy: ... - Content-Security-Policy: ... -``` +```` --- @@ -212,7 +212,7 @@ Write the 7-step runbook described in Reading 11 (Detect expiry → Order → Va ### 11.8: Document in `submissions/lab11.md` -```markdown +````markdown ## Task 2: Production Posture ### Rate limit proof @@ -243,7 +243,7 @@ Write the 7-step runbook described in Reading 11 (Detect expiry → Order → Va ### What OCSP stapling buys you (2-3 sentences, reference Reading 11) Why is OCSP stapling useful for production but not for a self-signed lab cert? -``` +```` --- @@ -313,7 +313,7 @@ docker compose exec waf cat /var/log/modsec/audit.log | tail -50 \ ### B.5: Document in `submissions/lab11.md` -```markdown +````markdown ## Bonus: WAF Sidecar with OWASP CRS ### Setup choice @@ -344,7 +344,7 @@ Rule ID: **** — OWASP CRS rule name: ** Build[🏗️] - Ops[🖥️ Ops] -.--> Build - Sec[🛡️ Sec] -.--> Build - Build --> DevSecOps[🚀 DevSecOps
Continuous, automated security
at every stage] + Dev["👩‍💻 Dev"] -.-> Build["🏗️ Build"] + Ops["🖥️ Ops"] -.-> Build + Sec["🛡️ Sec"] -.-> Build + Build --> DevSecOps["🚀 DevSecOps
Continuous, automated security
at every stage"] style DevSecOps fill:#FF9800,color:#fff ``` From ba40087d944de5e7bb5a4630b427e1b64352652e Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 12 Jun 2026 21:17:00 +0400 Subject: [PATCH 02/11] fix(lab2): shorten Threagile model title to fit Excel 31-char sheet-name limit Threagile uses the model title as the risks.xlsx sheet name; the shipped 41-char title crashed report generation at the Excel step ('the sheet name length exceeds the 31 characters limit'), leaving JSONs/diagrams but no risks.xlsx or report.pdf. Verified full output with threagile/threagile:0.9.1. Also documented the pitfall in lab2.md. Signed-off-by: Dmitrii Creed --- labs/lab2.md | 1 + labs/lab2/threagile-model.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/labs/lab2.md b/labs/lab2.md index 02fc18ea0..30fdf1b07 100644 --- a/labs/lab2.md +++ b/labs/lab2.md @@ -369,6 +369,7 @@ PR checklist body: - 🚨 **`docker: invalid reference format`** — make sure you wrote `threagile/threagile:0.9.1` not `threagile:v0.9.1` (no namespace). - 🚨 **Output directory empty after run** — Threagile needs write access. Verify the volume mount `-v "$(pwd)/labs/lab2":/app/work` and that `output/` exists with write perms before running. - 🚨 **`undefined protocol: xyz`** — Threagile validates protocol enums. Common typo: `JDBC-encrypted` (capitalized) — use lowercase `jdbc-encrypted`. +- 🚨 **`the sheet name length exceeds the 31 characters limit`** — Threagile uses your model's `title:` as the Excel sheet name in `risks.xlsx`; Excel caps sheet names at 31 characters. Keep `title:` short (≤ 31 chars). The run dies at the Excel step, so you get JSONs and diagrams but no `risks.xlsx`/`report.pdf`. (The `Fontconfig error` lines are harmless noise — ignore them.) - 🚨 **PDF is huge / slow to open** — that's normal. Use `risks.json` + `jq` for fast iteration; open the PDF only for the final report. - 🚨 **Secure variant has MORE risks than baseline** — usually means you added a new asset without declaring its security requirements. Threagile rules can fire on new assets you accidentally introduced; review your diff carefully. - 🚨 **"My auth-flow model has 50 risks!"** — that's usually because you copied the baseline model and trimmed it. Build the auth model **from scratch** — minimum viable assets + links + data. Threagile rules multiply on under-specified models. diff --git a/labs/lab2/threagile-model.yaml b/labs/lab2/threagile-model.yaml index 85c01a799..6d7ca4e76 100644 --- a/labs/lab2/threagile-model.yaml +++ b/labs/lab2/threagile-model.yaml @@ -1,6 +1,6 @@ threagile_version: 1.0.0 -title: OWASP Juice Shop — Local Lab Threat Model +title: OWASP Juice Shop Threat Model date: 2025-09-18 author: From 44f7579e48d04e5feca0828bd7167e15a2bf6f09 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Tue, 23 Jun 2026 22:10:50 +0400 Subject: [PATCH 03/11] fix(lab5): juice-shop hostname, OOM cap, compare_zap params Signed-off-by: Dmitrii Creed --- labs/lab5.md | 5 ++++- labs/lab5/scripts/compare_zap.sh | 12 ++++-------- labs/lab5/scripts/zap-auth.yaml | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/labs/lab5.md b/labs/lab5.md index e80954eff..c6a65e44f 100644 --- a/labs/lab5.md +++ b/labs/lab5.md @@ -97,7 +97,9 @@ docker run --rm --network lab5-net \ ```bash # The provided zap-auth.yaml drives the Automation Framework +# _JAVA_OPTIONS caps ZAP's JVM heap; without it the active scan OOM-kills the container docker run --rm --network lab5-net \ + -e _JAVA_OPTIONS="-Xmx512m" \ -v "$(pwd)/labs/lab5:/zap/wrk" \ ghcr.io/zaproxy/zaproxy:stable \ zap.sh -cmd -autorun /zap/wrk/scripts/zap-auth.yaml -port 8090 @@ -369,7 +371,8 @@ PR checklist body:
⚠️ Common Pitfalls -- 🚨 **`zap-auth.yaml` "context not found"** — the YAML hardcodes Juice Shop running at `juice-shop:3000` (Docker network internal name). If you renamed the container or didn't put both on the same `lab5-net` network, ZAP can't reach it. +- 🚨 **`zap-auth.yaml` "context not found"** — the YAML targets `juice-shop:3000` (Docker network internal name). If you renamed the container or didn't attach both to the same `lab5-net` network, ZAP can't reach it. The container name in `docker run --name` must match exactly. +- 🚨 **Active scan dies silently (CPU 100% → container exit)** — ZAP's active scan is memory-hungry. The `_JAVA_OPTIONS="-Xmx512m"` flag in step 5.3 caps the JVM heap; omitting it lets ZAP consume all available RAM until the container is OOM-killed with no error message. - 🚨 **Auth scan finds 0 alerts** — the `loginRequestBody` in `zap-auth.yaml` ships with the default Juice Shop admin creds (`admin@juice-sh.op` / `admin123`). If you changed them, auth scan logs in as anonymous and only sees unauth surface. - 🚨 **`zap.sh -port 8090` instead of default 8080** — added in the plumbing because the previous lab version conflicted with users running things on 8080. Don't change it unless you also change the YAML. - 🚨 **Semgrep `Parse error: ...`** — Juice Shop's TS sources occasionally hit edge cases. Add `--exclude='**/test/**'` to skip test fixtures if a single parse error blocks the whole scan. diff --git a/labs/lab5/scripts/compare_zap.sh b/labs/lab5/scripts/compare_zap.sh index a32cbec09..f3bd7ee1a 100755 --- a/labs/lab5/scripts/compare_zap.sh +++ b/labs/lab5/scripts/compare_zap.sh @@ -3,10 +3,10 @@ set -e -NOAUTH="labs/lab5/zap/zap-report-noauth.json" -AUTH="labs/lab5/zap/zap-report-auth.json" -OUT="labs/lab5/analysis/zap-comparison.txt" -mkdir -p labs/lab5/analysis +NOAUTH="${1:-labs/lab5/results/baseline-report.json}" +AUTH="${2:-labs/lab5/results/auth-report.json}" +OUT="${3:-labs/lab5/results/zap-comparison.txt}" +mkdir -p "$(dirname "$OUT")" parse_report() { local file="$1" @@ -29,8 +29,6 @@ by_risk = {'3': 0, '2': 0, '1': 0, '0': 0} risk_names = {'3': 'High', '2': 'Medium', '1': 'Low', '0': 'Info'} for site in sites: - if 'localhost:3000' not in site.get('@name', ''): - continue for alert in site.get('alerts', []): risk = alert.get('riskcode', '0') by_risk[risk] = by_risk.get(risk, 0) + 1 @@ -43,8 +41,6 @@ for code in ['3','2','1','0']: # count unique URLs scanned urls = set() for site in sites: - if 'localhost:3000' not in site.get('@name', ''): - continue for alert in site.get('alerts', []): for inst in alert.get('instances', []): urls.add(inst.get('uri', '')) diff --git a/labs/lab5/scripts/zap-auth.yaml b/labs/lab5/scripts/zap-auth.yaml index 0501fffa6..1cbd7e14e 100644 --- a/labs/lab5/scripts/zap-auth.yaml +++ b/labs/lab5/scripts/zap-auth.yaml @@ -2,9 +2,9 @@ env: contexts: - name: "Juice Shop Auth" urls: - - "http://localhost:3000" + - "http://juice-shop:3000" includePaths: - - "http://localhost:3000.*" + - "http://juice-shop:3000.*" excludePaths: - ".*\\.js$" - ".*\\.css$" @@ -15,8 +15,8 @@ env: authentication: method: "json" parameters: - loginPageUrl: "http://localhost:3000/rest/user/login" - loginRequestUrl: "http://localhost:3000/rest/user/login" + loginPageUrl: "http://juice-shop:3000/rest/user/login" + loginRequestUrl: "http://juice-shop:3000/rest/user/login" loginRequestBody: '{"email":"{%username%}","password":"{%password%}"}' verification: method: "poll" @@ -24,7 +24,7 @@ env: loggedOutRegex: ".*\"error\".*" pollFrequency: 60 pollUnits: "requests" - pollUrl: "http://localhost:3000/rest/user/whoami" + pollUrl: "http://juice-shop:3000/rest/user/whoami" pollAdditionalHeaders: - header: "Authorization" value: "Bearer {%token%}" @@ -43,13 +43,13 @@ jobs: - type: "spider" parameters: maxDuration: 5 - url: "http://localhost:3000" + url: "http://juice-shop:3000" user: "admin" - type: "spiderAjax" parameters: maxDuration: 10 - url: "http://localhost:3000" + url: "http://juice-shop:3000" user: "admin" - type: "passiveScan-config" @@ -64,19 +64,19 @@ jobs: - type: "activeScan" parameters: user: "admin" - maxScanDurationInMins: 15 - maxRuleDurationInMins: 3 + maxScanDurationInMins: 10 + maxRuleDurationInMins: 2 - type: "report" parameters: template: "traditional-html" - reportDir: "/zap/wrk/zap/" - reportFile: "report-auth.html" + reportDir: "/zap/wrk/results/" + reportFile: "auth-report.html" reportTitle: "ZAP Authenticated Scan Report" - type: "report" parameters: template: "traditional-json" - reportDir: "/zap/wrk/zap/" - reportFile: "zap-report-auth.json" + reportDir: "/zap/wrk/results/" + reportFile: "auth-report.json" reportTitle: "ZAP Authenticated Scan (JSON)" From 2f54b0e8a9b3b4b21cc9d8654ae02e6d8177ecee Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sat, 27 Jun 2026 01:29:19 +0400 Subject: [PATCH 04/11] fix(lab6): correct Checkov jq, scope Pulumi to KICS, align plumbing README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 jq broke on real Checkov output: a directory scan runs multiple frameworks (terraform + secrets), so results_json.json is a JSON array and .results.failed_checks[] errored with 'Cannot index array with string'. Use .[] to iterate frameworks. Open-source Checkov assigns no severities (a Prisma Cloud feature), so the severity breakdown is replaced with passed/failed and triage is framed around rule frequency. Pulumi was referenced in Task 1's report, acceptance criteria, and rubric but Checkov never scanned it (no pulumi framework; the 'shipped' rendered-state file never existed). Pulumi is scanned by KICS in Task 2 — moved its severity table there and fixed the Task 2 jq paths (kics-ansible/, kics-pulumi/ instead of a kics/ dir that is never created). Closed the 6.3 numbering gap. README aligned to the actual lab (Checkov + KICS + custom Checkov policy): dropped tfsec/Terrascan/OPA references, corrected Pulumi as AWS (not GCP), and removed the nonexistent EKS resource from the Terraform description. Reported by Albert Khechoyan. Signed-off-by: Dmitrii Creed --- labs/lab6.md | 139 ++++++++++++++++------------- labs/lab6/vulnerable-iac/README.md | 77 ++++++---------- 2 files changed, 102 insertions(+), 114 deletions(-) diff --git a/labs/lab6.md b/labs/lab6.md index 18392b445..af7c44f16 100644 --- a/labs/lab6.md +++ b/labs/lab6.md @@ -5,7 +5,7 @@ ![points](https://img.shields.io/badge/points-10%2B2-orange) ![tech](https://img.shields.io/badge/tech-Checkov%20%2B%20KICS-informational) -> **Goal:** Scan vulnerable Terraform + Pulumi with Checkov, scan vulnerable Ansible with KICS, then (bonus) write a custom Checkov policy for a project-specific rule. +> **Goal:** Scan vulnerable Terraform with Checkov, scan vulnerable Ansible + Pulumi with KICS, then (bonus) write a custom Checkov policy for a project-specific rule. > **Deliverable:** A PR from `feature/lab6` with `submissions/lab6.md` (findings tables + analysis) and (bonus) a custom Checkov policy file. Submit PR link via Moodle. --- @@ -13,8 +13,8 @@ ## Overview In this lab you will practice: -- **Checkov 3.x** on Terraform + Pulumi (~2,500 built-in policies, including 800+ graph-based) — Lecture 6 -- **KICS** on Ansible (~2,400 Rego-based queries) — Lecture 6 +- **Checkov 3.x** on Terraform (~2,500 built-in policies, including 800+ graph-based) — Lecture 6 +- **KICS** on Ansible + Pulumi (~2,400 Rego-based queries) — Lecture 6 - **Triage at the module level** (Lecture 6 slide 17 — one fix at module level closes many findings) - (Bonus) Writing a **custom Checkov policy** in YAML for a project-specific rule @@ -59,10 +59,10 @@ mkdir -p labs/lab6/results ``` > **Plumbing provided** (in `labs/lab6/vulnerable-iac/`): -> - `terraform/` — deliberately misconfigured AWS resources (S3, IAM, RDS, EKS) -> - `pulumi/` — deliberately misconfigured GCP resources (Python Pulumi) +> - `terraform/` — deliberately misconfigured AWS resources (IAM, RDS, DynamoDB, security groups) +> - `pulumi/` — deliberately misconfigured AWS resources (Python + YAML) > - `ansible/` — deliberately misconfigured Linux hardening playbook -> - `README.md` — documents which CKV_* / KICS rules each file targets +> - `README.md` — documents the vulnerability classes each file targets --- @@ -82,50 +82,45 @@ checkov -d labs/lab6/vulnerable-iac/terraform \ ### 6.2: Triage by rule frequency +Checkov scans the directory with several frameworks at once (`terraform` and `secrets`), so +`results_json.json` is a JSON **array** — one object per framework. Open-source Checkov doesn't +assign severities (that's a Prisma Cloud feature), so you triage by **how often each rule fires**: +the most frequent rule is the one a single module-level fix can clear in bulk (Lecture 6 slide 17). + ```bash -# Top 5 rule IDs by count (Lecture 6 slide 17 — module-level leverage) -jq '[.results.failed_checks[].check_id] | group_by(.) | map({rule: .[0], count: length}) | - sort_by(-.count) | .[:5]' \ +# Top 5 rule IDs by count — the highest-leverage fixes +jq '[.[].results.failed_checks[]?.check_id] + | group_by(.) | map({rule: .[0], count: length}) + | sort_by(-.count) | .[:5]' \ labs/lab6/results/checkov-terraform/results_json.json -# Severity breakdown -jq '[.results.failed_checks[].severity] | group_by(.) | map({severity: .[0], count: length})' \ +# Passed / failed per framework +jq 'map({framework: .check_type, passed: .summary.passed, failed: .summary.failed})' \ labs/lab6/results/checkov-terraform/results_json.json ``` -### 6.4: Document in `submissions/lab6.md` +### 6.3: Document in `submissions/lab6.md` ```markdown # Lab 6 — Submission -## Task 1: Checkov on Terraform + Pulumi - -### Terraform scan -- Total checks: -- Passed: -- Failed: +## Task 1: Checkov on Terraform -| Severity | Count | -|----------|------:| -| Critical | | -| High | | -| Medium | | -| Low | | +### Terraform scan (passed/failed per framework) +| Framework | Passed | Failed | +|-----------|-------:|-------:| +| terraform | | | +| secrets | | | ### Top 5 rule IDs (by frequency) | Rule ID | Count | What it checks | |---------|------:|----------------| | | | <1-line description> | -### Pulumi scan -| Severity | Count | -|----------|------:| -| ... | - ### Module-leverage analysis (Lecture 6 slide 17) Looking at your top-5 Terraform rules, which ONE fix would eliminate the most findings if applied -at the module level? (2-3 sentences. e.g., "If the S3 module had `block_public_acls = true` as default, -the 8 findings of CKV_AWS_56 would all go away.") +at the module level? (2-3 sentences. e.g., "If the shared IAM policy dropped its `Resource: "*"` +wildcard, the CKV_AWS_355/289/290 findings on every policy would collapse into one fix.") ``` --- @@ -136,7 +131,7 @@ the 8 findings of CKV_AWS_56 would all go away.") **Objective:** Run KICS against the Ansible playbook AND the Pulumi source; see how Rego-based queries surface different findings than Checkov, and demonstrate KICS's broader format coverage. -### 6.5: Run KICS on Ansible +### 6.4: Run KICS on Ansible ```bash docker run --rm \ @@ -147,7 +142,7 @@ docker run --rm \ --report-formats json,sarif ``` -### 6.5b: Run KICS on Pulumi (natively supported) +### 6.4b: Run KICS on Pulumi (natively supported) ```bash docker run --rm \ @@ -158,33 +153,49 @@ docker run --rm \ --report-formats json,sarif ``` -### 6.6: Analyze +### 6.5: Analyze + +Each scan wrote its own `results.json` (`kics-ansible/` and `kics-pulumi/`). KICS reports a single +JSON object with a `.queries` array, and — unlike Checkov — it assigns severities, so here you can +triage by severity as well as frequency. ```bash -# Severity breakdown -jq '[.queries[].severity] | group_by(.) | map({severity: .[0], count: length})' \ - labs/lab6/results/kics/results.json - -# Top queries by impact -jq '[.queries[] | {query: .query_name, severity, count: (.files | length)}] | - sort_by(-.count) | .[:5]' \ - labs/lab6/results/kics/results.json +# Severity breakdown — for each scan +for scan in kics-ansible kics-pulumi; do + echo "== $scan ==" + jq '[.queries[].severity] | group_by(.) | map({severity: .[0], count: length})' \ + labs/lab6/results/$scan/results.json +done + +# Top queries by impact (Ansible shown; repeat for kics-pulumi) +jq '[.queries[] | {query: .query_name, severity, count: (.files | length)}] + | sort_by(-.count) | .[:5]' \ + labs/lab6/results/kics-ansible/results.json ``` -### 6.7: Document in `submissions/lab6.md` +### 6.6: Document in `submissions/lab6.md` ```markdown -## Task 2: KICS on Ansible +## Task 2: KICS on Ansible + Pulumi + +### Ansible — severity breakdown +| Severity | Count | +|----------|------:| +| HIGH | | +| MEDIUM | | +| LOW | | +| INFO | | -### Severity breakdown +### Pulumi — severity breakdown | Severity | Count | |----------|------:| +| CRITICAL | | | HIGH | | | MEDIUM | | | LOW | | | INFO | | -### Top 5 KICS queries (by frequency) +### Top 5 KICS queries — Ansible (by frequency) | Query | Severity | Files | |-------|----------|------:| | | | | @@ -247,8 +258,9 @@ checkov -d labs/lab6/vulnerable-iac/terraform \ ### B.4: Verify your policy fires ```bash -# Look for your custom rule ID in the results -jq '.results.failed_checks[] | select(.check_id | startswith("CKV2_CUSTOM_"))' \ +# Look for your custom rule ID among the failed checks +jq '[.[].results.failed_checks[]?] + | map(select(.check_id | startswith("CKV2_CUSTOM_")))' \ labs/lab6/results/checkov-custom/results_json.json ``` @@ -263,7 +275,7 @@ jq '.results.failed_checks[] | select(.check_id | startswith("CKV2_CUSTOM_"))' \ ``` ### Rule fires -Output of `jq '.results.failed_checks[] | select(.check_id | startswith("CKV2_CUSTOM_"))'`: +Output of the B.4 jq (must show ≥1 failed check whose `check_id` starts with `CKV2_CUSTOM_`): ``` ``` @@ -289,8 +301,8 @@ git push -u origin feature/lab6 PR checklist body: ```text -- [x] Task 1 — Checkov on Terraform + Pulumi with top-5 rules and module-leverage analysis -- [ ] Task 2 — KICS on Ansible with Checkov-vs-KICS comparison +- [x] Task 1 — Checkov on Terraform with top-5 rules and module-leverage analysis +- [ ] Task 2 — KICS on Ansible + Pulumi with Checkov-vs-KICS comparison - [ ] Bonus — Custom Checkov policy demonstrably firing on the vulnerable sample ``` @@ -299,14 +311,14 @@ PR checklist body: ## Acceptance Criteria ### Task 1 (6 pts) -- ✅ Checkov runs complete for both Terraform and Pulumi -- ✅ Severity tables match actual JSON output (no placeholders) -- ✅ Top-5 rules table populated with real CKV_AWS_*/CKV_GCP_* IDs + descriptions +- ✅ Checkov scan completes on the Terraform sample +- ✅ Passed/failed table matches actual JSON output (no placeholders) +- ✅ Top-5 rules table populated with real CKV_AWS_* IDs + descriptions - ✅ Module-leverage analysis identifies ONE concrete fix with multi-finding impact ### Task 2 (4 pts) -- ✅ KICS scan completes on the Ansible sample -- ✅ Severity + top-5 tables populated with real query names +- ✅ KICS scan completes on both the Ansible and Pulumi samples +- ✅ Ansible + Pulumi severity tables and the Ansible top-5 table use real values - ✅ Checkov-vs-KICS comparison has substantive 2-3-sentence answers per question ### Bonus Task (2 pts) @@ -321,8 +333,8 @@ PR checklist body: | Task | Points | Criteria | |------|-------:|----------| -| **Task 1** — Checkov | **6** | Terraform + Pulumi scans + top-5 rules + module-leverage analysis | -| **Task 2** — KICS | **4** | Ansible scan + Checkov-vs-KICS comparison with concrete examples | +| **Task 1** — Checkov | **6** | Terraform scan + top-5 rules + module-leverage analysis | +| **Task 2** — KICS | **4** | Ansible + Pulumi scans + Checkov-vs-KICS comparison with concrete examples | | **Bonus Task** — Custom policy | **2** | Valid YAML schema + actually firing on vulnerable resource + business justification | | **Total** | **12** | 10 main + 2 bonus | @@ -344,12 +356,11 @@ PR checklist body:
⚠️ Common Pitfalls -- 🚨 **`pulumi preview --json` fails with "no Pulumi.yaml found"** — use the pre-rendered fallback `labs/lab6/vulnerable-iac/pulumi/pulumi-state-rendered.json` (shipped as plumbing). -- 🚨 **Checkov scans 0 files** — `-d` expects a DIRECTORY; `-f` expects a single file. Pulumi's rendered state is `-f`; Terraform is `-d`. -- 🚨 **KICS finds 0 issues on Ansible** — make sure the path includes the playbook YAML (`-p .../ansible/`). KICS sometimes silently skips files it doesn't recognize as Ansible. Check `kics list-platforms` to verify Ansible is in the supported list (it is). -- 🚨 **Custom policy doesn't fire** — the most common cause is `attribute` path typos. Test on a known-failing resource first (e.g., your custom S3 rule on a bucket without your required block). Add `severity: HIGH` even if you don't need it; Checkov is picky about required fields. -- 🚨 **`CKV_CUSTOM_*` ID conflicts with built-ins** — use `CKV2_CUSTOM_1+` (the `2` prefix marks graph rules and avoids collisions with the built-in numerical sequence). -- 💡 **Read the plumbing README** — `labs/lab6/vulnerable-iac/README.md` lists which Checkov/KICS rules each vulnerable file is designed to trigger. Useful for sanity-checking your scans found what they should. +- 🚨 **Checkov scans 0 files** — `-d` expects a DIRECTORY (the whole `terraform/` folder); `-f` expects a single file. This lab uses `-d`. +- 🚨 **KICS finds 0 issues on Ansible** — point `-p` at the playbook directory (`-p .../ansible/`). Run `kics list-platforms` to confirm Ansible is supported (it is). +- 🚨 **Custom policy doesn't fire** — usually an `attribute` path typo. Test on a known-failing resource first, and always include a `severity:` field; Checkov is strict about required fields. +- 🚨 **`CKV_CUSTOM_*` ID collides with a built-in** — use `CKV2_CUSTOM_1+`; the `2` prefix marks graph rules and stays clear of the built-in sequence. +- 💡 **Read the plumbing README** — `labs/lab6/vulnerable-iac/README.md` lists the vulnerability classes each file targets, so you can sanity-check that your scans found what they should.
diff --git a/labs/lab6/vulnerable-iac/README.md b/labs/lab6/vulnerable-iac/README.md index 65cc1e178..da6146e2b 100644 --- a/labs/lab6/vulnerable-iac/README.md +++ b/labs/lab6/vulnerable-iac/README.md @@ -191,61 +191,41 @@ vulnerable-iac/ --- -## 🛠️ Tools to Use +## 🛠️ Tools Used in This Lab -Students should scan this code with: +| Format | Tool | Why | +|--------|------|-----| +| **Terraform** | **Checkov 3.x** | ~2,500 built-in policies; native HCL support (Task 1) | +| **Pulumi** | **KICS (Checkmarx)** | First-class Pulumi YAML support; Checkov has no Pulumi framework (Task 2) | +| **Ansible** | **KICS (Checkmarx)** | Comprehensive Rego-based Ansible queries (Task 2) | +| **Policy-as-Code** | **Custom Checkov policy (YAML)** | Catch organization-specific rules the catalog doesn't ship (Bonus) | -### Terraform -- **tfsec**: Fast Terraform security scanner -- **Checkov**: Policy-as-code security scanner -- **Terrascan**: OPA-based compliance scanner - -### Pulumi -- **KICS (Checkmarx)**: Open-source scanner with first-class Pulumi YAML support - - Dedicated Pulumi queries catalog (AWS/Azure/GCP/Kubernetes) - - Auto-detects Pulumi platform - - Provides comprehensive security analysis - -### Ansible -- **KICS (Checkmarx)**: Open-source scanner with comprehensive Ansible security queries - - Dedicated Ansible queries catalog - - Auto-detects Ansible playbooks - - Provides comprehensive security analysis - -### Policy-as-Code -- **Conftest/OPA**: Custom policy enforcement for all IaC types +See `labs/lab6.md` for the exact commands. --- ## 📋 Expected Student Outcomes Students should: -1. Identify all 80+ security vulnerabilities across Terraform, Pulumi, and Ansible code - - Note: Pulumi code includes both Python and YAML formats for comprehensive analysis -2. Compare detection capabilities of different tools -3. Compare security issues between declarative (Terraform HCL) and programmatic (Pulumi Python/YAML) IaC +1. Surface the security vulnerabilities across the Terraform, Pulumi, and Ansible samples + - Note: Pulumi code includes both Python and YAML formats; KICS scans the YAML +2. Triage findings by rule frequency (Checkov) and severity (KICS) to find the highest-leverage fixes +3. Compare how Checkov (HCL) and KICS (Rego) surface different findings on the same resource types 4. Evaluate KICS's first-class Pulumi support and query catalog -5. Understand false positives vs true positives -6. Write custom policies to catch organizational-specific issues -7. Provide remediation steps for each vulnerability class -8. Recommend tool selection strategies for CI/CD pipelines +5. Write a custom Checkov policy to catch an organization-specific rule the catalog doesn't ship +6. Reason about tool selection (Checkov vs KICS) for a CI/CD pipeline --- ## 🔧 How to Use (Students) -```bash -# Copy vulnerable code to your lab directory -cp -r vulnerable-iac/terraform/* labs/lab6/terraform/ -cp -r vulnerable-iac/pulumi/* labs/lab6/pulumi/ -cp -r vulnerable-iac/ansible/* labs/lab6/ansible/ - -# Scan with multiple tools (see lab6.md for commands) -docker run --rm -v "$(pwd)/labs/lab6/terraform":/src aquasec/tfsec:latest /src -docker run --rm -v "$(pwd)/labs/lab6/terraform":/tf bridgecrew/checkov:latest -d /tf -docker run -t --rm -v "$(pwd)/labs/lab6/pulumi":/src checkmarx/kics:latest scan -p /src -o /src/kics-report --report-formats json,html -# ... and more -``` +Scan these samples in place — no copying needed. Follow `labs/lab6.md` step by step: + +- **Task 1** — `checkov -d labs/lab6/vulnerable-iac/terraform ...` +- **Task 2** — `kics scan -p .../ansible/` and `kics scan -p .../pulumi/` +- **Bonus** — re-run Checkov with `--external-checks-dir labs/lab6/policies` + +> Don't fix these files — analyze them. The findings are the deliverable. --- @@ -269,15 +249,12 @@ docker run -t --rm -v "$(pwd)/labs/lab6/pulumi":/src checkmarx/kics:latest scan ## ✅ Validation To verify students have completed the lab successfully, check that they: -- [ ] Identified at least 20 Terraform vulnerabilities -- [ ] Identified at least 15 Pulumi vulnerabilities -- [ ] Identified at least 15 Ansible vulnerabilities -- [ ] Compared at least 4 scanning tools (tfsec, Checkov for Terraform, KICS for Pulumi, Terrascan, ansible-lint) -- [ ] Analyzed differences between Terraform (HCL) and Pulumi (Python/YAML) security issues -- [ ] Evaluated KICS's Pulumi-specific query catalog and platform support -- [ ] Created at least 3 custom OPA policies -- [ ] Provided remediation guidance -- [ ] Explained tool selection rationale +- [ ] Ran Checkov on the Terraform sample and reported real findings (top-5 rules + passed/failed) +- [ ] Ran KICS on both the Ansible and Pulumi samples and reported real severities +- [ ] Compared Checkov (HCL) vs KICS (Rego) with concrete examples +- [ ] Identified a module-level fix that clears multiple Terraform findings at once +- [ ] (Bonus) Wrote a custom Checkov policy that demonstrably fires on the sample +- [ ] Explained Checkov-vs-KICS tool selection rationale --- From 9222ec68f92809593d8e1545149542ff86fb267d Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 29 Jun 2026 01:13:39 +0400 Subject: [PATCH 05/11] fix(labs): align lab7 & lab9 with pinned tool behavior Student-reported breakage where lab instructions assumed tool behavior the pinned versions no longer have. Each fix verified against the real binaries. Lab 7 (Trivy 0.69): - 7.3 jq: add `installed: .InstalledVersion` so the 7.4 "Installed" column has data - 7.7: `trivy k8s --namespace` was removed in 0.69 -> `--include-namespaces` Lab 9 (Falco 0.43.1, Conftest 0.68): - manifests are k8s/juice-{hardened,unhardened}.yaml, not flat good-pod/bad-pod - 9.3 trigger B: a write to /usr/local/bin fires nothing in the 0.43.1 default ruleset (rule moved to incubating feed) -> `cat /etc/shadow` (Read sensitive file untrusted), confirmed firing in a live Falco run - 9.4 hint: point at the real `open_write` macro instead of a non-existent rule - bonus: `tcp.dport` -> `fd.sport` (real Falco field) - add inotify pitfall + `-o watch_config_files=false` workaround for WSL2 - wire the shipped compose policy into 9.9 as a second target-shape demo Reported by students in the DevSecOps-Intro cohort. Signed-off-by: Dmitrii Creed --- labs/lab7.md | 6 ++-- labs/lab9.md | 96 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 66 insertions(+), 36 deletions(-) diff --git a/labs/lab7.md b/labs/lab7.md index 770e02f02..4b1d52c44 100644 --- a/labs/lab7.md +++ b/labs/lab7.md @@ -99,7 +99,7 @@ trivy config /tmp/Dockerfile-bad --severity HIGH,CRITICAL --format table ```bash # Top 10 CVEs with fixes (Lecture 7 slide 9 — "fix available AND severity ≥ HIGH first") jq '[.Results[].Vulnerabilities[]? | select(.FixedVersion != null) | - {cve: .VulnerabilityID, severity: .Severity, pkg: .PkgName, fix: .FixedVersion}] | + {cve: .VulnerabilityID, severity: .Severity, pkg: .PkgName, installed: .InstalledVersion, fix: .FixedVersion}] | sort_by(.severity) | .[:10]' \ labs/lab7/results/trivy-image.json ``` @@ -215,11 +215,11 @@ kubectl -n juice-shop describe pod -l app=juice-shop | grep -A 3 -i "security co ### 7.7: Trivy K8s scan ```bash -trivy k8s --namespace juice-shop \ +trivy k8s --include-namespaces juice-shop \ --severity HIGH,CRITICAL \ --format json --output labs/lab7/results/trivy-k8s.json -trivy k8s --namespace juice-shop \ +trivy k8s --include-namespaces juice-shop \ --severity HIGH,CRITICAL \ --report=summary ``` diff --git a/labs/lab9.md b/labs/lab9.md index ad2b85c00..e19498a70 100644 --- a/labs/lab9.md +++ b/labs/lab9.md @@ -54,8 +54,8 @@ mkdir -p labs/lab9/{falco/{rules,logs},policies/extra,analysis} ``` > **Plumbing provided** (already in `labs/lab9/`): -> - [`labs/lab9/manifests/`](lab9/manifests/) — sample K8s manifests of varying compliance (pass + fail cases) -> - [`labs/lab9/policies/`](lab9/policies/) — starter Conftest policies (you'll extend them) +> - [`labs/lab9/manifests/`](lab9/manifests/) — `k8s/juice-{hardened,unhardened}.yaml` + `compose/juice-compose.yml` +> - [`labs/lab9/policies/`](lab9/policies/) — starter Conftest policies for both shapes (`k8s-security.rego`, `compose-security.rego`) > > Read these files before writing your own — they show the Rego style + sample manifest shape. @@ -102,12 +102,12 @@ sleep 5 # Trigger A: Terminal shell in container — built-in rule docker exec -it lab9-target /bin/sh -lc 'echo "shell-in-container test"' -# Trigger B: Container drift — write under /usr/local/bin -docker exec --user 0 lab9-target /bin/sh -lc 'echo "drift" > /usr/local/bin/drift.txt' +# Trigger B: Read a sensitive file — built-in "Read sensitive file untrusted" rule +docker exec lab9-target /bin/sh -lc 'cat /etc/shadow' # Wait a few seconds, then check Falco alerts sleep 3 -grep -E "(Terminal shell|Write below)" labs/lab9/falco/logs/falco.log | head -10 +grep -E "(Terminal shell|Read sensitive file)" labs/lab9/falco/logs/falco.log | head -10 ``` ### 9.4: Write 1 custom Falco rule @@ -123,9 +123,10 @@ Create `labs/lab9/falco/rules/custom-rules.yaml`: # - priority: WARNING # - tags: [container, drift] # -# Hint: existing default rules write under /usr/local/bin — yours is similar but -# /tmp is a different path. Look at /etc/falco/falco_rules.yaml for the macro -# open_write to understand the syntax. +# Hint: Falco ships the `open_write` macro — read it inside the container: +# docker exec falco cat /etc/falco/falco_rules.yaml | grep -A2 'macro: open_write' +# Your rule combines open_write + a container check (container.id != host) + +# fd.name startswith /tmp/. ``` Falco auto-reloads rules in `/etc/falco/rules.d/`. To force reload after editing: @@ -155,7 +156,7 @@ JSON alert from Falco logs (paste the most relevant lines): ``` -### Baseline alert B — Container drift (write below binary dir) +### Baseline alert B — Read sensitive file untrusted (`cat /etc/shadow`) ```json ``` @@ -183,19 +184,22 @@ often write to /tmp). What's your tuning approach? (2-3 sentences referencing th > ⏭️ Optional. Skipping won't affect future labs. -**Objective:** Write Rego policies for Conftest that catch ≥3 K8s manifest hardening issues at CI time. +**Objective:** Write Rego policies for Conftest that catch ≥3 K8s manifest hardening issues at CI time, then run the shipped compose policy to see the same `deny[msg]` skill generalize to a second target shape. ### 9.7: Read the provided manifests + starter policies ```bash -ls labs/lab9/manifests/ -# Should show: good-pod.yaml, bad-pod-runasroot.yaml, bad-pod-no-resources.yaml, etc. +ls labs/lab9/manifests/k8s/ +# Should show: juice-hardened.yaml (compliant), juice-unhardened.yaml (non-compliant) ls labs/lab9/policies/ -# Should show: starter Rego files demonstrating Conftest patterns +# Two starter policies, one per target shape: +# k8s-security.rego (package k8s.security) — K8s Deployments (input.spec.template.spec) +# compose-security.rego (package compose.security) — docker-compose (input.services) cat labs/lab9/policies/*.rego -# Read the starter policies — your task extends them +# Read both — note how the SAME deny[msg] pattern adapts to two different input shapes. +# Task 2 has you EXTEND the K8s one (9.8) and RUN the compose one (9.9). ``` ### 9.8: Write your Conftest policies @@ -219,20 +223,42 @@ Add to `labs/lab9/policies/extra/`: # (requires Rego v1 — recent OPA/Conftest versions) ``` -### 9.9: Run Conftest against good + bad manifests +### 9.9: Run Conftest — your K8s policy + the shipped compose policy + +**A. Your K8s policy** (`policies/extra/`) against the shipped manifests: ```bash -# Good manifest — should PASS -conftest test labs/lab9/manifests/good-pod.yaml \ +# Compliant manifest — should PASS (0 failures) +conftest test labs/lab9/manifests/k8s/juice-hardened.yaml \ --policy labs/lab9/policies/extra/ -# Bad manifest #1 — should FAIL with deny messages -conftest test labs/lab9/manifests/bad-pod-runasroot.yaml \ +# Non-compliant manifest — should FAIL with multiple deny messages +# (juice-unhardened has no securityContext, no resources, and a :latest tag, +# so it trips several of your rules at once) +conftest test labs/lab9/manifests/k8s/juice-unhardened.yaml \ --policy labs/lab9/policies/extra/ +``` -# Bad manifest #2 — should FAIL -conftest test labs/lab9/manifests/bad-pod-no-resources.yaml \ - --policy labs/lab9/policies/extra/ +**B. The shipped compose policy** — same `deny[msg]` skill, a different target shape. +It declares `package compose.security`, so Conftest needs `--namespace compose.security` +to find its rules (Conftest defaults to the `main` namespace): + +```bash +# Shipped hardened compose — should PASS +conftest test labs/lab9/manifests/compose/juice-compose.yml \ + --policy labs/lab9/policies/compose-security.rego \ + --namespace compose.security + +# A deliberately unhardened compose — should FAIL (no user / read_only / cap_drop) +cat > /tmp/bad-compose.yml <<'EOF' +services: + app: + image: nginx:latest + ports: ["8080:80"] +EOF +conftest test /tmp/bad-compose.yml \ + --policy labs/lab9/policies/compose-security.rego \ + --namespace compose.security ``` ### 9.10: Document in `submissions/lab9.md` @@ -245,19 +271,21 @@ conftest test labs/lab9/manifests/bad-pod-no-resources.yaml \ ``` -### Good manifest passes +### Compliant manifest passes (juice-hardened.yaml) ``` ``` -### Bad manifest 1 fails (runAsRoot) +### Non-compliant manifest fails (juice-unhardened.yaml) ``` - + ``` -### Bad manifest 2 fails (no resources) +### Compose policy generalizes (shipped compose-security.rego) ``` - + ``` ### Why CI-time vs admission-time (Lecture 9 slide 9) @@ -279,7 +307,7 @@ Common cryptominer indicators (any 2 are sufficient for the rule): | Indicator | Pattern | |---|---| -| Connection to mining pool port | `tcp.dport in (3333, 4444, 5555, 7777, 14444, 19999, 45700)` | +| Connection to mining pool port | `fd.sport in (3333, 4444, 5555, 7777, 14444, 19999, 45700)` | | DNS query for known pool hostname | `evt.type=connect and fd.sockfamily=ip and fd.cip.name contains "minexmr"` | | Process name matches known miner | `proc.name in (xmrig, ethminer, cgminer, t-rex, claymore)` | | High CPU + low network ratio | (Out of scope — needs metrics) | @@ -361,7 +389,7 @@ PR checklist body: ```text - [x] Task 1 — 2 baseline + 1 custom Falco alert with tuning discussion -- [ ] Task 2 — ≥3 Conftest rules, passing on good manifest, failing on bad +- [ ] Task 2 — ≥3 Conftest rules (K8s pass/fail) + shipped compose policy run - [ ] Bonus — Cryptominer detection rule with triggered alert ``` @@ -371,15 +399,16 @@ PR checklist body: ### Task 1 (6 pts) - ✅ Falco running with modern eBPF (verify with `docker logs falco | grep -i engine`) -- ✅ Both baseline alerts (Terminal shell + Container drift) appear in Falco logs +- ✅ Both baseline alerts (Terminal shell + Read sensitive file) appear in Falco logs - ✅ Custom rule `custom-rules.yaml` exists with required fields - ✅ Custom rule fires (visible in Falco log after the test trigger) - ✅ Tuning consideration mentions `exceptions:` block OR `and not` pattern with reasoning ### Task 2 (4 pts) - ✅ ≥3 Rego rules in `labs/lab9/policies/extra/` -- ✅ Good manifest PASSES (0 failures from conftest) -- ✅ ≥2 bad manifests FAIL with clear deny messages +- ✅ Compliant manifest (`juice-hardened.yaml`) PASSES (0 failures from conftest) +- ✅ Non-compliant manifest (`juice-unhardened.yaml`) FAILS with ≥2 distinct deny messages +- ✅ Shipped `compose-security.rego` run shown — PASS on `juice-compose.yml`, FAIL on a bad compose - ✅ CI-vs-admission answer demonstrates understanding of defense-in-depth ### Bonus Task (2 pts) @@ -394,7 +423,7 @@ PR checklist body: | Task | Points | Criteria | |------|-------:|----------| | **Task 1** — Falco runtime | **6** | 2 baseline + 1 custom alert + tuning discussion | -| **Task 2** — Conftest policies | **4** | 3+ Rego rules + good passes + bad fails + CI/admission reasoning | +| **Task 2** — Conftest policies | **4** | 3+ Rego rules + K8s good/bad + shipped compose policy run + CI/admission reasoning | | **Bonus Task** — Cryptominer rule | **2** | 2+ indicators + triggered alert + reflection on FN + SLA | | **Total** | **12** | 10 main + 2 bonus | @@ -417,6 +446,7 @@ PR checklist body: ⚠️ Common Pitfalls - 🚨 **Falco fails to start with "engine.kind not detected"** — your kernel might be < 5.8 (no modern eBPF). Fall back to the legacy eBPF driver: add `-e FALCO_BPF_PROBE=""` to the docker run command. +- 🚨 **Falco dies with `could not initialize inotify handler`** — WSL2 / crowded-Docker hosts hit the default `fs.inotify.max_user_instances=128` (same wall as Lab 7). Two fixes: (a) as root, `sysctl -w fs.inotify.max_user_instances=1024`; or (b) if you can't sudo, append `-o watch_config_files=false` to the `falco ...` command — Falco then starts without the file watcher, and you reload rules manually with the `SIGHUP` step below (which doesn't need inotify). - 🚨 **No alerts fire after triggering** — Falco needs a few seconds to load rules. Wait 5+ seconds between starting Falco and triggering. Also confirm rules loaded with `docker logs falco | grep -i "loaded rule"`. - 🚨 **Custom rule has YAML parse error and silently doesn't load** — `docker logs falco | grep -i error` shows the parse error. Common cause: indentation. Validate with `yq eval . custom-rules.yaml`. - 🚨 **`docker kill --signal=SIGHUP falco`** — used to reload rules; if you instead `docker restart falco`, the log file gets truncated. From 63136a87924ab6bb03360eba0b074fed2ce3737b Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 29 Jun 2026 01:18:28 +0400 Subject: [PATCH 06/11] fix(lab9): pin juice-hardened image by digest so the optional digest rule passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardened manifest pinned its image by tag (:v19.0.0), so a student who implements the optional 5th hardening rule ("image must use sha256 digest, not :tag") saw the GOOD manifest FAIL — contradicting 9.9-A's "should PASS (0 failures)". Pin by the resolved v19.0.0 multi-arch index digest; the unhardened manifest (:latest) still fails the rule as the bad example. Digest resolved via 'docker buildx imagetools inspect', not fabricated. Signed-off-by: Dmitrii Creed --- labs/lab9/manifests/k8s/juice-hardened.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/lab9/manifests/k8s/juice-hardened.yaml b/labs/lab9/manifests/k8s/juice-hardened.yaml index 105211966..37d153d2f 100644 --- a/labs/lab9/manifests/k8s/juice-hardened.yaml +++ b/labs/lab9/manifests/k8s/juice-hardened.yaml @@ -12,7 +12,7 @@ spec: spec: containers: - name: juice - image: bkimminich/juice-shop:v19.0.0 + image: bkimminich/juice-shop@sha256:2765a26de7647609099a338d5b7f61085d95903c8703bb70f03fcc4b12f0818d # juice-shop v19.0.0, pinned by digest securityContext: runAsNonRoot: true allowPrivilegeEscalation: false From a17362dc3e097d98885af182c0f02609437fb93a Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 29 Jun 2026 01:20:57 +0400 Subject: [PATCH 07/11] fix(lab9): pin compose image by digest for consistency with juice-hardened Signed-off-by: Dmitrii Creed --- labs/lab9/manifests/compose/juice-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/lab9/manifests/compose/juice-compose.yml b/labs/lab9/manifests/compose/juice-compose.yml index acfcb64bf..58f1ea645 100644 --- a/labs/lab9/manifests/compose/juice-compose.yml +++ b/labs/lab9/manifests/compose/juice-compose.yml @@ -1,6 +1,6 @@ services: juice: - image: bkimminich/juice-shop:v19.0.0 + image: bkimminich/juice-shop@sha256:2765a26de7647609099a338d5b7f61085d95903c8703bb70f03fcc4b12f0818d # juice-shop v19.0.0, pinned by digest ports: ["3006:3000"] user: "10001:10001" read_only: true From c81b7d547b70865f17c1eacc76fc031ddd462e4c Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Tue, 30 Jun 2026 00:04:34 +0400 Subject: [PATCH 08/11] docs(lab9): Colima workaround for Falco on macOS/Apple Silicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Desktop's LinuxKit VM kernel lacks BTF + tracepoints, so Falco runs but detects nothing on Mac (esp. Apple Silicon). The Setup section wrongly said Docker Desktop 'is fine' — corrected it, and added a Common Pitfalls entry with the Colima fix and a 'test -f /sys/kernel/btf/vmlinux' gate check. Reported by a student on an M-series Mac. Signed-off-by: Dmitrii Creed --- labs/lab9.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/labs/lab9.md b/labs/lab9.md index e19498a70..6f6a43ce2 100644 --- a/labs/lab9.md +++ b/labs/lab9.md @@ -41,7 +41,7 @@ You need: - **Docker** (Falco runs containerized) - **`jq`** - **`conftest`** v0.68.x — `brew install conftest` (Lab 7 bonus used this if you did it) -- **Linux host with kernel ≥ 5.8** (for modern eBPF). On macOS/Windows, Docker Desktop's Linux VM is fine +- **A Linux kernel with eBPF + BTF** (for Falco's modern driver). Native Linux and WSL2 (kernel ≥ 5.8) work out of the box. **macOS — including Apple Silicon — does NOT work through Docker Desktop**: its LinuxKit VM kernel ships without BTF, so Falco runs but detects nothing. Use **Colima** instead — see Common Pitfalls → "macOS / Apple Silicon" ```bash git switch main && git pull @@ -446,6 +446,14 @@ PR checklist body: ⚠️ Common Pitfalls - 🚨 **Falco fails to start with "engine.kind not detected"** — your kernel might be < 5.8 (no modern eBPF). Fall back to the legacy eBPF driver: add `-e FALCO_BPF_PROBE=""` to the docker run command. +- 🚨 **macOS / Apple Silicon — Falco starts but detects nothing** — Docker Desktop runs a stripped LinuxKit VM whose kernel ships without BTF (and the raw tracepoints Falco's modern-eBPF probe attaches to), so Falco loads cleanly and stays blind. This is **not** a CPU/arch problem — the image is multi-arch (arm64 included) — it's the VM kernel. Fix: give Falco a real Ubuntu kernel via **Colima** (free, Homebrew, a Lima-based Docker backend whose VM has BTF): + ```bash + brew install colima docker + colima start --cpu 4 --memory 6 --disk 30 + # Gate check — must print "BTF OK" before you bother running Falco: + colima ssh -- test -f /sys/kernel/btf/vmlinux && echo "BTF OK" + ``` + Colima becomes your Docker backend, so steps 9.1–9.5 run **unchanged** (clone the repo under your home dir — Colima mounts `$HOME` into the VM). The universal 5-second check for *any* environment: `test -f /sys/kernel/btf/vmlinux` — present ⇒ modern eBPF attaches; absent (Docker Desktop) ⇒ Falco is blind. Task 2 (Conftest) is pure userspace — `brew install conftest` and run it natively on macOS, no VM needed. *(A plain Ubuntu VM via multipass/Lima/UTM works too; Colima is just the least-disruptive since it keeps the Docker CLI workflow.)* - 🚨 **Falco dies with `could not initialize inotify handler`** — WSL2 / crowded-Docker hosts hit the default `fs.inotify.max_user_instances=128` (same wall as Lab 7). Two fixes: (a) as root, `sysctl -w fs.inotify.max_user_instances=1024`; or (b) if you can't sudo, append `-o watch_config_files=false` to the `falco ...` command — Falco then starts without the file watcher, and you reload rules manually with the `SIGHUP` step below (which doesn't need inotify). - 🚨 **No alerts fire after triggering** — Falco needs a few seconds to load rules. Wait 5+ seconds between starting Falco and triggering. Also confirm rules loaded with `docker logs falco | grep -i "loaded rule"`. - 🚨 **Custom rule has YAML parse error and silently doesn't load** — `docker logs falco | grep -i error` shows the parse error. Common cause: indentation. Validate with `yq eval . custom-rules.yaml`. From eb41fc383b97b6ee0dde1326c130456229425782 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Tue, 30 Jun 2026 13:41:38 +0400 Subject: [PATCH 09/11] fix(lab10): rewrite DefectDojo importer to match the course + run on macOS The run-imports.sh helper did not work as shipped: - Line 55 used mapfile, a bash 4+ builtin absent on stock macOS bash 3.2, so the script aborted on macOS. Replaced with a portable while-read loop and set -u-safe array handling; verified under bash 3.2.57. - Every hardcoded input path was wrong. Corrected to the real Lab 4-7 outputs and anchored them to the repo root so the script runs from any directory. - The script required DD_API while lab10.md tells students to export DD_URL, so require_env aborted immediately. It now accepts DD_URL and derives the API base. - It imported Nuclei (not in this course) and skipped Checkov/KICS/Trivy image/Trivy operator. It now imports exactly the documented Lab 4-7 table, with scan_type fallbacks verified against the DefectDojo 2.58.3 parser source. lab10.md aligned: correct script name (was import-all.sh), KICS split into kics-ansible/kics-pulumi, fixed the manual curl template path, and added the env.sample the plumbing promised. Importer outputs are now gitignored. Reported by Albert Khechoyan. Signed-off-by: Dmitrii Creed --- .gitignore | 1 + labs/lab10.md | 24 +++-- labs/lab10/imports/env.sample | 14 +++ labs/lab10/imports/run-imports.sh | 172 +++++++++++++++--------------- 4 files changed, 116 insertions(+), 95 deletions(-) create mode 100644 labs/lab10/imports/env.sample diff --git a/.gitignore b/.gitignore index b5172ad70..c6b279fad 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ labs/lab7/results/ labs/lab8/results/ labs/lab9/falco/logs/ labs/lab10/work/dd/ +labs/lab10/imports/import-*.json labs/lab11/logs/ labs/lab11/reverse-proxy/certs/ labs/lab12/results/ diff --git a/labs/lab10.md b/labs/lab10.md index 02eb6ec37..1e34b3b70 100644 --- a/labs/lab10.md +++ b/labs/lab10.md @@ -30,7 +30,7 @@ In this lab you will practice: **You should have from Labs 4-9** (regenerate if missing): - Lab 4: `juice-shop.cdx.json`, `grype-from-sbom.json`, `trivy.json` - Lab 5: `auth-report.json` (ZAP), `semgrep.json` -- Lab 6: `checkov-terraform/results_json.json`, `kics/results.json` +- Lab 6: `checkov-terraform/results_json.json`, `kics-ansible/results.json`, `kics-pulumi/results.json` - Lab 7: `trivy-image.json`, `trivy-k8s.json` - Lab 8: `verify-original.json` (Cosign verify output) - Lab 9: `falco/logs/falco.log` (custom alerts) @@ -58,8 +58,9 @@ mkdir -p labs/lab10/work ``` > **Plumbing provided** (in `labs/lab10/imports/`): -> - Import script that maps the Lab 4-9 file paths to DefectDojo scan-type names -> - Sample environment variables file +> - `run-imports.sh` — imports every Lab 4-7 report into DefectDojo (paths resolved +> from the repo root; portable to stock macOS bash 3.2) +> - `env.sample` — the environment variables the script reads (`DD_URL`, `DD_TOKEN`, …) --- @@ -132,7 +133,7 @@ echo "Engagement: $ENGAGEMENT_ID" ### 10.4: Import scan files -For each prior lab, run: +For each prior lab, run (from the repo root): ```bash # Template — repeat for each scan type @@ -140,7 +141,7 @@ curl -X POST "$DD_URL/api/v2/import-scan/" \ -H "Authorization: Token $DD_TOKEN" \ -F "scan_type=Trivy Scan" \ -F "engagement=$ENGAGEMENT_ID" \ - -F "file=@../../lab7/results/trivy-image.json" + -F "file=@labs/lab7/results/trivy-image.json" ``` Scan-type names to use: @@ -152,12 +153,18 @@ Scan-type names to use: | 5 | `semgrep.json` | `Semgrep JSON Report` | | 5 | `auth-report.json` | `ZAP Scan` | | 6 | `checkov-terraform/results_json.json` | `Checkov Scan` | -| 6 | `kics/results.json` | `KICS Scan` | +| 6 | `kics-ansible/results.json` | `KICS Scan` | +| 6 | `kics-pulumi/results.json` | `KICS Scan` | | 7 | `trivy-image.json` | `Trivy Scan` | | 7 | `trivy-k8s.json` | `Trivy Operator Scan` | | 9 | `falco/logs/falco.log` | (custom-format — skip if not supported, document instead) | -Use the importer script in `labs/lab10/imports/import-all.sh` to automate. +To automate all of the above, just run the importer — it reuses the `DD_URL` + `DD_TOKEN` +you exported in 10.2 (see `labs/lab10/imports/env.sample` for every variable it reads): + +```bash +bash labs/lab10/imports/run-imports.sh +``` ### 10.5: Verify import + dedup @@ -197,7 +204,8 @@ curl -s -H "Authorization: Token $DD_TOKEN" \ | 5 | Semgrep JSON Report | semgrep.json | | | 5 | ZAP Scan | auth-report.json | | | 6 | Checkov Scan | results_json.json | | -| 6 | KICS Scan | results.json | | +| 6 | KICS Scan | kics-ansible/results.json | | +| 6 | KICS Scan | kics-pulumi/results.json | | | 7 | Trivy Scan (image) | trivy-image.json | | | 7 | Trivy Operator Scan | trivy-k8s.json | | | **Total raw imports** | | | | diff --git a/labs/lab10/imports/env.sample b/labs/lab10/imports/env.sample new file mode 100644 index 000000000..ba4551f4a --- /dev/null +++ b/labs/lab10/imports/env.sample @@ -0,0 +1,14 @@ +# Lab 10 — DefectDojo importer environment +# Copy these into your shell (or `source labs/lab10/imports/env.sample`) before +# running run-imports.sh. + +# DefectDojo base URL — the importer derives the API base ($DD_URL/api/v2). +export DD_URL="http://localhost:8080" + +# API token: DefectDojo UI -> Profile -> API v2 Key. +export DD_TOKEN="paste-your-token-here" + +# Optional overrides (defaults shown — match step 10.3): +# export DD_PRODUCT_TYPE="Engineering" +# export DD_PRODUCT="OWASP Juice Shop" +# export DD_ENGAGEMENT="Course Semester Run" diff --git a/labs/lab10/imports/run-imports.sh b/labs/lab10/imports/run-imports.sh index 0f0e33c93..f4875323e 100644 --- a/labs/lab10/imports/run-imports.sh +++ b/labs/lab10/imports/run-imports.sh @@ -1,21 +1,27 @@ #!/usr/bin/env bash set -euo pipefail -# Batch import helper for Lab 10 -# - Auto-detects scan_type names from your Dojo instance -# - Imports whichever files exist among ZAP, Semgrep, Trivy, Nuclei (and optional Grype) +# Batch import helper for Lab 10. +# Imports every Lab 4-7 scan report that exists into DefectDojo, auto-detecting +# the scan_type names from your instance (with sane fallbacks if discovery fails). # # Usage: -# export DD_API="http://localhost:8080/api/v2" -# export DD_TOKEN="" -# # Optional overrides (defaults shown) -# export DD_PRODUCT_TYPE="${DD_PRODUCT_TYPE:-Engineering}" -# export DD_PRODUCT="${DD_PRODUCT:-Juice Shop}" -# export DD_ENGAGEMENT="${DD_ENGAGEMENT:-Labs Security Testing}" +# export DD_URL="http://localhost:8080" # base URL (same as lab10.md step 10.2) +# export DD_TOKEN="" # Profile -> API v2 Key in the UI # bash labs/lab10/imports/run-imports.sh +# +# Optional overrides (defaults shown): +# DD_API="$DD_URL/api/v2" +# DD_PRODUCT_TYPE="Engineering" +# DD_PRODUCT="OWASP Juice Shop" +# DD_ENGAGEMENT="Course Semester Run" +# +# File paths are resolved relative to the repo root, so the script runs from any +# directory. Portable to bash 3.2 (stock macOS) — no mapfile/readarray. here_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" out_dir="$here_dir" +repo_root="$(cd "$here_dir/../../.." && pwd)" # labs/lab10/imports -> repo root require_env() { local name="$1" @@ -25,12 +31,13 @@ require_env() { fi } -require_env DD_API require_env DD_TOKEN +DD_URL="${DD_URL:-http://localhost:8080}" +DD_API="${DD_API:-$DD_URL/api/v2}" DD_PRODUCT_TYPE="${DD_PRODUCT_TYPE:-Engineering}" -DD_PRODUCT="${DD_PRODUCT:-Juice Shop}" -DD_ENGAGEMENT="${DD_ENGAGEMENT:-Labs Security Testing}" +DD_PRODUCT="${DD_PRODUCT:-OWASP Juice Shop}" +DD_ENGAGEMENT="${DD_ENGAGEMENT:-Course Semester Run}" echo "Using context:" echo " DD_API=$DD_API" @@ -40,95 +47,86 @@ echo " DD_ENGAGEMENT=$DD_ENGAGEMENT" have_jq=true command -v jq >/dev/null 2>&1 || have_jq=false -if ! $have_jq; then - echo "WARN: jq not found; falling back to defaults for scan_type names." >&2 -fi - -# Discover scan type names from your instance if jq is available -SCAN_ZAP="${SCAN_ZAP:-}" -SCAN_SEMGREP="${SCAN_SEMGREP:-}" -SCAN_TRIVY="${SCAN_TRIVY:-}" -SCAN_NUCLEI="${SCAN_NUCLEI:-}" +$have_jq || echo "WARN: jq not found; using default scan_type names." >&2 +# Discover scan_type names from the instance. Fallbacks keep the script working +# even if discovery fails (no jq, instance not up, auth error). +types=() if $have_jq; then echo "Discovering importer names from /test_types/ ..." - mapfile -t types < <(curl -sS -H "Authorization: Token $DD_TOKEN" "$DD_API/test_types/?limit=2000" | jq -r '.results[].name') - choose_type() { - local pat="$1" - local fallback="$2" - local val="" + while IFS= read -r name; do + [[ -n "$name" ]] && types+=("$name") + done < <(curl -sS -H "Authorization: Token $DD_TOKEN" \ + "$DD_API/test_types/?limit=2000" 2>/dev/null | jq -r '.results[].name' 2>/dev/null) +fi + +choose_type() { + local pat="$1" fallback="$2" t + if [[ ${#types[@]} -gt 0 ]]; then for t in "${types[@]}"; do - if [[ "$t" =~ $pat ]]; then val="$t"; break; fi + if [[ "$t" =~ $pat ]]; then echo "$t"; return; fi done - if [[ -z "$val" ]]; then val="$fallback"; fi - echo "$val" - } - SCAN_ZAP="${SCAN_ZAP:-$(choose_type '^ZAP' 'ZAP Scan')}" - SCAN_SEMGREP="${SCAN_SEMGREP:-$(choose_type '^Semgrep' 'Semgrep JSON Report')}" - SCAN_TRIVY="${SCAN_TRIVY:-$(choose_type '^Trivy' 'Trivy Scan')}" - SCAN_NUCLEI="${SCAN_NUCLEI:-$(choose_type '^Nuclei' 'Nuclei Scan')}" - # Grype importer (commonly named "Anchore Grype") - if [[ -z "${SCAN_GRYPE:-}" ]]; then - SCAN_GRYPE=$(printf '%s\n' "${types[@]}" | grep -i '^Anchore Grype' | head -n1) - if [[ -z "$SCAN_GRYPE" ]]; then - SCAN_GRYPE=$(printf '%s\n' "${types[@]}" | grep -i 'Grype' | head -n1) - fi fi -else - SCAN_ZAP="${SCAN_ZAP:-ZAP Scan}" - SCAN_SEMGREP="${SCAN_SEMGREP:-Semgrep JSON Report}" - SCAN_TRIVY="${SCAN_TRIVY:-Trivy Scan}" - SCAN_NUCLEI="${SCAN_NUCLEI:-Nuclei Scan}" -fi -SCAN_GRYPE="${SCAN_GRYPE:-Anchore Grype}" + echo "$fallback" +} + +SCAN_GRYPE="$(choose_type '^Anchore Grype' 'Anchore Grype')" +SCAN_TRIVY="$(choose_type '^Trivy Scan$' 'Trivy Scan')" +SCAN_TRIVY_OP="$(choose_type '^Trivy Operator' 'Trivy Operator Scan')" +SCAN_SEMGREP="$(choose_type '^Semgrep' 'Semgrep JSON Report')" +SCAN_ZAP="$(choose_type '^ZAP' 'ZAP Scan')" +SCAN_CHECKOV="$(choose_type '^Checkov' 'Checkov Scan')" +SCAN_KICS="$(choose_type '^KICS' 'KICS Scan')" echo "Importer names:" -echo " ZAP = $SCAN_ZAP" -echo " Semgrep = $SCAN_SEMGREP" -echo " Trivy = $SCAN_TRIVY" -echo " Nuclei = $SCAN_NUCLEI" -echo " Grype = $SCAN_GRYPE" +echo " Grype = $SCAN_GRYPE" +echo " Trivy = $SCAN_TRIVY" +echo " Trivy Operator = $SCAN_TRIVY_OP" +echo " Semgrep = $SCAN_SEMGREP" +echo " ZAP = $SCAN_ZAP" +echo " Checkov = $SCAN_CHECKOV" +echo " KICS = $SCAN_KICS" import_scan() { - local scan_type="$1"; shift - local file="$1"; shift + local scan_type="$1" file="$2" + local rel="${file#"$repo_root"/}" if [[ ! -f "$file" ]]; then - echo "SKIP: $scan_type file not found: $file" + echo "SKIP: $scan_type — file not found: $rel" return 0 fi - local base out - base="$(basename "$file")" - out="$out_dir/import-${base//[^A-Za-z0-9_.-]/_}.json" - echo "Importing $scan_type from $file" - curl -sS -X POST "$DD_API/import-scan/" \ - -H "Authorization: Token $DD_TOKEN" \ - -F "scan_type=$scan_type" \ - -F "file=@$file" \ - -F "product_type_name=$DD_PRODUCT_TYPE" \ - -F "product_name=$DD_PRODUCT" \ - -F "engagement_name=$DD_ENGAGEMENT" \ - -F "auto_create_context=true" \ - -F "minimum_severity=Info" \ - -F "close_old_findings=false" \ - -F "push_to_jira=false" \ - | tee "$out" + local tag base out + tag="$(basename "$(dirname "$file")")"; tag="${tag//[^A-Za-z0-9_.-]/_}" + base="$(basename "$file")"; base="${base//[^A-Za-z0-9_.-]/_}" + out="$out_dir/import-${tag}-${base}" + echo "Importing $scan_type from $rel" + if ! curl -sS -X POST "$DD_API/import-scan/" \ + -H "Authorization: Token $DD_TOKEN" \ + -F "scan_type=$scan_type" \ + -F "file=@$file" \ + -F "product_type_name=$DD_PRODUCT_TYPE" \ + -F "product_name=$DD_PRODUCT" \ + -F "engagement_name=$DD_ENGAGEMENT" \ + -F "auto_create_context=true" \ + -F "minimum_severity=Info" \ + -F "close_old_findings=false" \ + -F "push_to_jira=false" \ + | tee "$out" >/dev/null; then + echo " WARN: import request failed for $scan_type ($base)" >&2 + fi } -# Candidate paths per tool -zap_file="labs/lab5/zap/zap-report-noauth.json" -semgrep_file="labs/lab5/semgrep/semgrep-results.json" -trivy_file="labs/lab4/trivy/trivy-vuln-detailed.json" -nuclei_file="labs/lab5/nuclei/nuclei-results.json" - -# Grype -grype_file="labs/lab4/syft/grype-vuln-results.json" - -import_scan "$SCAN_ZAP" "$zap_file" -import_scan "$SCAN_SEMGREP" "$semgrep_file" -import_scan "$SCAN_TRIVY" "$trivy_file" -import_scan "$SCAN_NUCLEI" "$nuclei_file" - -# Grype -import_scan "$SCAN_GRYPE" "$grype_file" +# Lab 4 — SCA (SBOM-derived) +import_scan "$SCAN_GRYPE" "$repo_root/labs/lab4/grype-from-sbom.json" +import_scan "$SCAN_TRIVY" "$repo_root/labs/lab4/trivy.json" +# Lab 5 — DAST + SAST +import_scan "$SCAN_SEMGREP" "$repo_root/labs/lab5/results/semgrep.json" +import_scan "$SCAN_ZAP" "$repo_root/labs/lab5/results/auth-report.json" +# Lab 6 — IaC +import_scan "$SCAN_CHECKOV" "$repo_root/labs/lab6/results/checkov-terraform/results_json.json" +import_scan "$SCAN_KICS" "$repo_root/labs/lab6/results/kics-ansible/results.json" +import_scan "$SCAN_KICS" "$repo_root/labs/lab6/results/kics-pulumi/results.json" +# Lab 7 — Container image + K8s +import_scan "$SCAN_TRIVY" "$repo_root/labs/lab7/results/trivy-image.json" +import_scan "$SCAN_TRIVY_OP" "$repo_root/labs/lab7/results/trivy-k8s.json" echo "Done. Import responses saved under $out_dir" From f9932471eb75787a0588c96ee2fc4f65ed0772e8 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Tue, 30 Jun 2026 14:04:36 +0400 Subject: [PATCH 10/11] fix(lab11): align reverse-proxy starter to the documented 80/443 ports The shipped nginx.conf listened on 8080/8443 and docker-compose.yml published 8080:8080/8443:8443, but every instruction and verification command in lab11.md assumes 80/443 (curl http://localhost, https://localhost, s_client -connect localhost:443, and the 11.1 spec 'HTTP on 80 -> HTTPS 443'). Following the lab gave connection-refused on 443. Switch nginx to listen 80/443, drop the :8443 from the redirect target, and publish 80:80/443:443. The juice backend stays behind the proxy on expose:3000 (verified end-to-end: nginx reaches juice:3000 with expose only, so publishing the backend on the host is unnecessary and would defeat the lab). Added a remap hint for rootless Docker / busy ports. Reported by Albert Khechoyan. Signed-off-by: Dmitrii Creed --- labs/lab11/docker-compose.yml | 6 ++++-- labs/lab11/reverse-proxy/nginx.conf | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/labs/lab11/docker-compose.yml b/labs/lab11/docker-compose.yml index edb9ca06b..3c1d1a95a 100644 --- a/labs/lab11/docker-compose.yml +++ b/labs/lab11/docker-compose.yml @@ -11,8 +11,10 @@ services: depends_on: - juice ports: - - "8080:8080" # HTTP (will redirect to HTTPS) - - "8443:8443" # HTTPS + - "80:80" # HTTP (redirects to HTTPS) + - "443:443" # HTTPS + # On rootless Docker or if 80/443 are taken, remap (e.g. "8080:80","8443:443") + # and adjust the verification URLs accordingly. volumes: - ./reverse-proxy/nginx.conf:/etc/nginx/nginx.conf:ro - ./reverse-proxy/certs:/etc/nginx/certs:ro diff --git a/labs/lab11/reverse-proxy/nginx.conf b/labs/lab11/reverse-proxy/nginx.conf index b90f6c476..dff91b265 100644 --- a/labs/lab11/reverse-proxy/nginx.conf +++ b/labs/lab11/reverse-proxy/nginx.conf @@ -60,8 +60,8 @@ http { # HTTP server (redirect to HTTPS) server { - listen 8080; - listen [::]:8080; + listen 80; + listen [::]:80; server_name _; # Core headers (also on redirects) @@ -73,13 +73,13 @@ http { add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; - return 308 https://$host:8443$request_uri; + return 308 https://$host$request_uri; } # HTTPS server server { - listen 8443 ssl; - listen [::]:8443 ssl; + listen 443 ssl; + listen [::]:443 ssl; http2 on; server_name _; From ef4fae2ff318af3cbfaef6fa2bcdfec1117a2c6f Mon Sep 17 00:00:00 2001 From: Student Date: Fri, 17 Jul 2026 21:44:45 +0300 Subject: [PATCH 11/11] feat(lab11): hardened nginx + WAF sidecar --- labs/lab11/reverse-proxy/nginx.conf | 19 ++- labs/lab11/waf/docker-compose.override.yml | 13 ++ submissions/lab11.md | 165 +++++++++++++++++++++ 3 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 labs/lab11/waf/docker-compose.override.yml create mode 100644 submissions/lab11.md diff --git a/labs/lab11/reverse-proxy/nginx.conf b/labs/lab11/reverse-proxy/nginx.conf index dff91b265..e726fdd80 100644 --- a/labs/lab11/reverse-proxy/nginx.conf +++ b/labs/lab11/reverse-proxy/nginx.conf @@ -30,6 +30,7 @@ http { # ~10 req/min per IP, burst of 5 limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; limit_req_status 429; + limit_conn_zone $binary_remote_addr zone=conn:10m; map $http_upgrade $connection_upgrade { default upgrade; '' close; } @@ -68,7 +69,7 @@ http { add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; @@ -82,14 +83,18 @@ http { listen [::]:443 ssl; http2 on; server_name _; + limit_conn conn 50; ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; - ssl_session_timeout 10m; + ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:EECDH+AESGCM:EDH+AESGCM"; - ssl_prefer_server_ciphers on; + ssl_session_tickets off; + ssl_protocols TLSv1.3; + # ssl_ciphers TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ecdh_curve X25519:secp384r1; + ssl_prefer_server_ciphers off; ssl_stapling off; # If using a publicly-trusted certificate, you may enable OCSP stapling: # ssl_stapling on; @@ -105,11 +110,11 @@ http { send_timeout 10s; # Security headers (include HSTS here only) - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; diff --git a/labs/lab11/waf/docker-compose.override.yml b/labs/lab11/waf/docker-compose.override.yml new file mode 100644 index 000000000..0f6231a6f --- /dev/null +++ b/labs/lab11/waf/docker-compose.override.yml @@ -0,0 +1,13 @@ +version: '3' +services: + waf: + image: owasp/modsecurity-crs:nginx-alpine + ports: + - "8080:8080" + environment: + - BACKEND=https://nginx:443 + - PARANOIA=1 + - SEC_RULE_ENGINE=On + - MODSEC_AUDIT_LOG=/var/log/modsec/audit.log + - PORT=8080 + - PROXY_SSL_VERIFY=off diff --git a/submissions/lab11.md b/submissions/lab11.md new file mode 100644 index 000000000..7c4c9884a --- /dev/null +++ b/submissions/lab11.md @@ -0,0 +1,165 @@ +# Lab 11 — BONUS — Submission + +## Task 1: TLS + Security Headers + +### nginx.conf (paste the SSL + header sections only — not the whole file) +```nginx + ssl_certificate /etc/nginx/certs/localhost.crt; + ssl_certificate_key /etc/nginx/certs/localhost.key; + ssl_session_timeout 10m; + ssl_session_cache shared:SSL:10m; + ssl_protocols TLSv1.3; + ssl_ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:EECDH+AESGCM:EDH+AESGCM"; + ssl_prefer_server_ciphers off; + ssl_stapling off; + # If using a publicly-trusted certificate, you may enable OCSP stapling: + # ssl_stapling on; + # ssl_stapling_verify on; + # resolver 1.1.1.1 8.8.8.8 valid=300s; + # resolver_timeout 5s; + # ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + + client_max_body_size 2m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 10s; + send_timeout 10s; + + # Security headers (include HSTS here only) + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" always; +``` + +### A. HTTPS redirect proof +``` +HTTP/1.1 308 Permanent Redirect +Server: nginx +Date: Fri, 17 Jul 2026 18:04:03 GMT +Content-Type: text/html +Content-Length: 164 +Connection: keep-alive +Location: https://localhost/ +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: camera=(), microphone=(), geolocation=() +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Resource-Policy: same-origin +Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' +``` + +### B. TLS 1.3 proof +``` +CONNECTION ESTABLISHED +Protocol version: TLSv1.3 +Ciphersuite: TLS_AES_256_GCM_SHA384 +Peer certificate: CN=juice.local +Hash used: SHA256 +Signature type: rsa_pss_rsae_sha256 +Verification error: self-signed certificate +Negotiated TLS1.3 group: X25519MLKEM768 +``` + +### C. Security headers proof (all 6 present) +``` +HTTP/1.1 200 OK +Server: nginx +Date: Fri, 17 Jul 2026 18:04:55 GMT +Content-Type: text/html; charset=UTF-8 +Content-Length: 9903 +Connection: keep-alive +Feature-Policy: payment 'self' +X-Recruiting: /#/jobs +Accept-Ranges: bytes +Cache-Control: public, max-age=0 +Last-Modified: Fri, 17 Jul 2026 18:01:41 GMT +ETag: W/"26af-19f713dc3d7" +Vary: Accept-Encoding +Strict-Transport-Security: max-age=63072000; includeSubDomains; preload +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: camera=(), microphone=(), geolocation=() +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Resource-Policy: same-origin +Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' +``` + +### What each header defends against (1 sentence each) +- HSTS: Prevents downgrade attacks and cookie hijacking by forcing the browser to only connect via HTTPS. +- X-Content-Type-Options: nosniff: Prevents MIME-sniffing vulnerabilities, ensuring the browser strictly adheres to the provided Content-Type header. +- X-Frame-Options: DENY: Defends against clickjacking attacks by preventing the page from being rendered within a frame or iframe. +- Referrer-Policy: Protects sensitive information in URLs from being leaked in the Referer header to cross-origin requests. +- Permissions-Policy: Restricts which web features and APIs (like camera or geolocation) can be used, limiting the attack surface. +- Content-Security-Policy: Mitigates Cross-Site Scripting (XSS) and data injection attacks by defining which dynamic resources are allowed to load. + +## Task 2: Rate Limiting, Timeouts, Cipher Hardening, Cert Rotation + +### nginx.conf (Rate Limit & Connection Limit sections) +```nginx + # Rate limit zone for login + limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; + limit_req_status 429; + limit_conn_zone $binary_remote_addr zone=conn:10m; + + # Inside server { listen 443 ssl; ... } + limit_conn conn 50; + + # Inside location = /rest/user/login { ... } + limit_req zone=login burst=5 nodelay; +``` + +### Rate limit test proof (60 concurrent POSTs to login) +``` +Count Name +----- ---- + 3 500 + 57 429 +``` + +### Cert rotation runbook (7 steps) +1. **Detect expiry**: Monitor certificate expiration using synthetic checks (e.g., Datadog, Prometheus Blackbox Exporter) alerting at 30 days before expiration. +2. **Order new cert**: Automatically generate a new CSR and request a signed certificate from the CA (e.g., using Certbot/ACME or internal Vault PKI). +3. **Validate**: Verify the new certificate chain and private key match (`openssl x509 -noout -modulus`) and that it has the correct SANs and dates. +4. **Atomic swap**: Deploy the new certificates to the server (e.g., `/etc/nginx/certs/`) without overwriting old ones immediately, then gracefully reload Nginx (`nginx -s reload`). +5. **Verify**: Run automated post-deployment checks (`openssl s_client -connect ...`) to confirm the server is presenting the new, valid certificate. +6. **Rollback plan**: Keep the previous certificate files available and have a script ready to revert the symlinks/paths and reload Nginx if the verification fails. +7. **Audit**: Log the successful rotation event to a centralized auditing system for compliance tracking and to close the initial expiry alert. + +### What OCSP stapling buys you (2-3 sentences, reference Reading 11) +OCSP stapling improves privacy and performance by having the server fetch the certificate revocation status from the CA and "staple" it to the initial TLS handshake, rather than forcing every client to make a separate external network call to the CA. It is not useful for a self-signed lab cert because there is no external Certificate Authority to query for revocation status; the self-signed cert is inherently trusted (or untrusted) locally without an ongoing revocation infrastructure. + +## Bonus: WAF Sidecar with OWASP CRS + +### Setup choice +- WAF used: ModSecurity v3 (owasp/modsecurity-crs:nginx-alpine) +- OWASP CRS version: v3.3.10 (as bundled in image) +- Paranoia level: 1 + +### Attack payload sent +`GET /rest/products/search?q=' OR 1=1--` (URL-encoded) + +### Before WAF (Nginx alone) +``` +no-waf: HTTP 500 +``` + +### After WAF +``` +with-waf: HTTP 403 +``` + +### Audit log excerpt (the rule that fired) +``` +[error] 531#531: *1 [client 172.20.0.1] ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:ANOMALY_SCORE' (Value: `5' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "81"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] +``` +Rule ID: **949110** (and implicitly the underlying SQLi rules like **942100**) — OWASP CRS rule name: **Inbound Anomaly Score Exceeded** + +### Tradeoff analysis (3 sentences) +Deploying a WAF with OWASP CRS provides real-time defense against zero-days and active exploitation attempts (like SQLi or XSS) that SAST/DAST might miss or can't patch immediately. However, it costs operational overhead, requires careful tuning to avoid false positives (especially at higher paranoia levels), and adds latency to requests. You would NOT deploy a WAF in front of internal microservices that only receive highly-trusted, authenticated traffic from within the cluster where the risk of injection is negligible and performance is paramount.