diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..063f7c13c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,5 @@ +[allowlist] + description = "Allow documentation examples in submissions" + paths = [ + '''submissions/lab3\.md''', + ] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..0bd663214 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.27.2 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=500"] diff --git a/labs/lab9/falco/rules/custom-rules.yaml b/labs/lab9/falco/rules/custom-rules.yaml new file mode 100644 index 000000000..26e1f0229 --- /dev/null +++ b/labs/lab9/falco/rules/custom-rules.yaml @@ -0,0 +1,30 @@ +- rule: Write to /tmp by container + desc: Detects any write to /tmp inside a container — common indicator of dropper activity or staging + condition: > + open_write + and container + and fd.directory = /tmp + output: > + Write to /tmp detected in container + (user=%user.name container=%container.name image=%container.image.repository + file=%fd.name cmd=%proc.cmdline) + priority: WARNING + tags: [container, drift] + +- rule: Possible Cryptominer Activity + desc: > + Detects a container connecting to well-known cryptomining pool ports or running + a known miner binary — maps to the Tesla 2018 K8s dashboard incident (Lecture 1) + condition: > + container + and ( + (evt.type = connect and fd.rport in (3333, 4444, 5555, 7777, 14444, 19999, 45700)) + or + (proc.name in (xmrig, ethminer, cgminer, t-rex, claymore, nbminer)) + ) + output: > + Possible cryptominer activity in container + (container=%container.name image=%container.image.repository + proc=%proc.name conn=%fd.name cmd=%proc.cmdline) + priority: CRITICAL + tags: [container, mitre_execution, mitre_command_and_control] diff --git a/labs/lab9/policies/extra/hardening.rego b/labs/lab9/policies/extra/hardening.rego new file mode 100644 index 000000000..5dea8de5d --- /dev/null +++ b/labs/lab9/policies/extra/hardening.rego @@ -0,0 +1,42 @@ +package main + +# 1. Pod or container must set runAsNonRoot: true +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.runAsNonRoot == true + not input.spec.template.spec.securityContext.runAsNonRoot == true + msg := sprintf("Deployment '%s': container '%s' must set runAsNonRoot: true", [input.metadata.name, c.name]) +} + +# 2. allowPrivilegeEscalation must be explicitly false on every container +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.allowPrivilegeEscalation == false + msg := sprintf("Deployment '%s': container '%s' must set allowPrivilegeEscalation: false", [input.metadata.name, c.name]) +} + +# 3. capabilities.drop must include ALL on every container +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not "ALL" in c.securityContext.capabilities.drop + msg := sprintf("Deployment '%s': container '%s' must drop ALL capabilities", [input.metadata.name, c.name]) +} + +# 4. Memory limits must be set (OOM kill bound) +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.resources.limits.memory + msg := sprintf("Deployment '%s': container '%s' must set resources.limits.memory", [input.metadata.name, c.name]) +} + +# 5. Image must not use :latest tag +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + endswith(c.image, ":latest") + msg := sprintf("Deployment '%s': container '%s' uses disallowed :latest tag — pin to a digest or explicit version", [input.metadata.name, c.name]) +} diff --git a/submissions/lab3.md b/submissions/lab3.md new file mode 100644 index 000000000..d9266c565 --- /dev/null +++ b/submissions/lab3.md @@ -0,0 +1,137 @@ +# Lab 3 — Submission + +## Task 1: SSH Commit Signing + +### Local configuration +- `git config --global gpg.format` → `ssh` +- `git config --global user.signingkey` → `/home/pavel/.ssh/id_ed25519.pub` +- `git config --global commit.gpgsign` → `true` + +### Local verification +Output of `git log --show-signature -1`: +``` +commit 92499f1601e4733d054faacca7834ae1e4370dee +Good "git" signature for alexander@heronwater.com with ED25519 key SHA256:vwqxlQeyMQRmpij9axlAMAhQZB9aoV+I7goi6xeApDs +Author: Temniy Princ +Date: Thu Jun 18 17:04:36 2026 +0300 + + feat(lab3): SSH signing + gitleaks pre-commit + history rewrite practice +``` + +### GitHub verification +- Latest commit (docs update): https://github.com/wannebetheshy/DevSecOps-Intro/commit/cdf834e428199df34a611a8878e05d7e48e16ffc +- First lab3 commit: https://github.com/wannebetheshy/DevSecOps-Intro/commit/198206d60ddcb453193e67a8ff05665c270ee83c +- Screenshot of the Verified badge: ![Verified badge](verified-badge.png) + +### One-paragraph reflection +A forged-author commit allows an attacker (or a malicious insider) to plant backdoors, sabotage releases, or comply fraud while attributing the change to a trusted colleague — perfect Repudiation under STRIDE-R: the actual actor can deny the action, and the framed developer cannot prove their innocence. Without signing, `git log --author` is trivially spoofable via `git commit --author "trusted@example.com"`. The Verified badge breaks this attack by binding the commit to the SSH private key only the real author possesses: a commit showing "Unverified" next to a trusted name is an immediate red flag that triggers investigation rather than silent acceptance. + +--- + +## Task 2: Pre-commit + gitleaks + +### `.pre-commit-config.yaml` (full content) +```yaml +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.27.2 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=500"] +``` + +### `pre-commit install` output +``` +pre-commit installed at .git/hooks/pre-commit +``` + +### The blocked commit +Output of the `git commit` that gitleaks blocked: +``` +Detect hardcoded secrets.................................................Failed +- hook id: gitleaks +- exit code: 1 + +○ + │╲ + │ ○ + ○ ░ + ░ gitleaks + +Finding: GH_PAT=REDACTED +Secret: REDACTED +RuleID: github-pat +Entropy: 4.143943 +File: submissions/leak-attempt.txt +Line: 2 +Fingerprint: submissions/leak-attempt.txt:github-pat:2 + +5:02PM INF 0 commits scanned. +5:02PM INF scanned ~101 bytes (101 bytes) in 23.8ms +5:02PM WRN leaks found: 1 + +detect private key.......................................................Passed +check for added large files..............................................Passed +``` + +### Tune-out exercise + +**1. Inline allowlist** — `[allowlist]` block in `.gitleaks.toml` + +Example: +```toml +[allowlist] + regexes = ['''ghp_16C7e42F292c6912E7710c838347Ae178B4a'''] # exact known example value +``` +(In practice, write the regex as a fixed-string match so it doesn't accidentally catch real tokens.) + +This approach is OK when the pattern is highly specific (a single known example value, not a broad regex), the exemption is peer-reviewed and intentional, and the file lives in the repo itself so it's version-controlled and auditable. If you use a broad regex like `ghp_[A-Za-z0-9]+` you effectively disable the rule for the entire codebase — that's when it becomes dangerous. + +**2. Path exclusion** — `paths: [docs/]` in `.gitleaks.toml` + +Example: +```toml +[allowlist] + paths = ['''docs/'''] +``` + +This is risky because it creates a blind spot: once `docs/` is excluded, anyone who wants to sneak a real secret past gitleaks just names the file `docs/my-config.md`. Path exclusions are also fragile — a file move from `src/` into `docs/` silently removes it from scanning. Use only for tightly scoped paths (e.g., `docs/examples/fake-credentials.md`) and add a comment explaining the rationale. + +--- + +## Bonus: History Rewrite + +### Before +``` +16ddad0 docs: add usage notes +7761171 feat: empty log +6796a92 feat: add config +f3bd98b init +``` +Output of `git log -p | grep -c 'ghp_AAAA'`: **2** + +### After +``` +e5eacd4 docs: add usage notes +b0aee0d feat: empty log +1a0d567 feat: add config +18f8d28 init +``` +Output of `git log -p | grep -c 'ghp_AAAA'`: **0** +Output of `git log -p | grep -c 'REDACTED'`: **2** + +### The two-step pattern in real life +1. `git filter-repo --replace-text replacements.txt` — rewrite locally +2. **Rotate the secret immediately** — what's the MANDATORY second step in a real incident. Even after a successful `--force` push that overwrites all branches, the token was already exposed: GitHub's API, CDN caches, local clones on every contributor's machine, and any CI log that ever printed it may still hold the live value. Rewriting history removes the evidence but does not invalidate the credential. Rotation (revoking the old token and issuing a new one) is the only action that actually closes the attack window. + +### Two real-world gotchas + +1. **filter-repo refused to run because the repo was not a fresh clone.** The tool checks reflog depth and aborts with "this does not look like a fresh clone" if there is more than one entry for HEAD. In the sandbox this was hit immediately because commits had been added after `git init`. The fix is `--force`, but in a real incident you should work on a dedicated fresh clone (so your working copy stays clean) and only force-push once the rewrite is verified. + +2. **All commit SHAs change after the rewrite, breaking every in-flight PR and CI run.** After `filter-repo` runs, every commit hash is different — the history is literally a new DAG. In a team repo this means every open pull request shows as "nothing to merge" or conflicts, every local clone is now diverged, and CI pipelines referencing old SHAs point to orphaned objects. The standard playbook is: announce the rewrite, ask everyone to re-clone or hard-reset their local branches, and re-open any PRs that were in progress. diff --git a/submissions/lab9.md b/submissions/lab9.md new file mode 100644 index 000000000..de8fb93de --- /dev/null +++ b/submissions/lab9.md @@ -0,0 +1,265 @@ +# Lab 9 — Submission + +## Task 1: Runtime Detection with Falco + +**Environment:** Falco 0.43.1, modern eBPF, kernel 7.0.12+kali-amd64 +**Target container:** `alpine:3.20` named `lab9-target` + +### Baseline alert A — Terminal shell in container + +```json +{ + "hostname": "8e925df0b9ce", + "output": "2026-07-09T05:57:50.101174653+0000: Notice A shell was spawned in a container with an attached terminal | evt_type=execve user=root user_uid=0 user_loginuid=-1 process=sh proc_exepath=/bin/busybox parent=systemd command=sh -c echo \"shell-in-container test\" terminal=34816 exe_flags=EXE_WRITABLE|EXE_LOWER_LAYER container_id=acf977765e42 container_name=lab9-target container_image_repository=alpine container_image_tag=3.20 k8s_pod_name= k8s_ns_name=", + "output_fields": { + "container.id": "acf977765e42", + "container.image.repository": "alpine", + "container.image.tag": "3.20", + "container.name": "lab9-target", + "evt.type": "execve", + "proc.cmdline": "sh -c echo \"shell-in-container test\"", + "user.name": "root" + }, + "priority": "Notice", + "rule": "Terminal shell in container", + "source": "syscall", + "tags": ["container", "shell", "mitre_execution"] +} +``` + +**Trigger command:** `docker exec -t lab9-target /bin/sh -c 'echo "shell-in-container test"'` +The `-t` flag allocates a pseudo-TTY, which sets `proc.tty != 0` — the key field in Falco's "Terminal shell in container" condition. Without `-t`, the rule does not fire. + +--- + +### Baseline alert B — Read sensitive file untrusted + +```json +{ + "hostname": "8e925df0b9ce", + "output": "2026-07-09T05:58:37.183217419+0000: Warning Sensitive file opened for reading by non-trusted program | file=/etc/shadow gparent=containerd-shim ggparent=systemd evt_type=open user=root user_uid=0 process=cat proc_exepath=/bin/busybox parent=sh command=cat /etc/shadow terminal=0 container_id=acf977765e42 container_name=lab9-target container_image_repository=alpine container_image_tag=3.20", + "output_fields": { + "container.id": "acf977765e42", + "container.name": "lab9-target", + "evt.type": "open", + "fd.name": "/etc/shadow", + "proc.cmdline": "cat /etc/shadow", + "user.name": "root" + }, + "priority": "Warning", + "rule": "Read sensitive file untrusted", + "source": "syscall", + "tags": ["filesystem", "mitre_credential_access"] +} +``` + +**Trigger command:** `docker exec lab9-target /bin/sh -c 'cat /etc/shadow'` + +--- + +### Custom rule (labs/lab9/falco/rules/custom-rules.yaml) + +```yaml +- rule: Write to /tmp by container + desc: Detects any write to /tmp inside a container — common indicator of dropper activity or staging + condition: > + open_write + and container + and fd.directory = /tmp + output: > + Write to /tmp detected in container + (user=%user.name container=%container.name image=%container.image.repository + file=%fd.name cmd=%proc.cmdline) + priority: WARNING + tags: [container, drift] + +- rule: Possible Cryptominer Activity + desc: > + Detects a container connecting to well-known cryptomining pool ports or running + a known miner binary — maps to the Tesla 2018 K8s dashboard incident (Lecture 1) + condition: > + container + and ( + (evt.type = connect and fd.rport in (3333, 4444, 5555, 7777, 14444, 19999, 45700)) + or + (proc.name in (xmrig, ethminer, cgminer, t-rex, claymore, nbminer)) + ) + output: > + Possible cryptominer activity in container + (container=%container.name image=%container.image.repository + proc=%proc.name conn=%fd.name cmd=%proc.cmdline) + priority: CRITICAL + tags: [container, mitre_execution, mitre_command_and_control] +``` + +--- + +### Custom rule fired — "Write to /tmp by container" + +```json +{ + "hostname": "8e925df0b9ce", + "output": "2026-07-09T05:57:02.768832199+0000: Warning Write to /tmp detected in container (user=root container=lab9-target image=alpine file=/tmp/my-write.txt cmd=sh -c echo \"test\" > /tmp/my-write.txt)", + "output_fields": { + "container.name": "lab9-target", + "container.image.repository": "alpine", + "fd.name": "/tmp/my-write.txt", + "proc.cmdline": "sh -c echo \"test\" > /tmp/my-write.txt", + "user.name": "root" + }, + "priority": "Warning", + "rule": "Write to /tmp by container", + "source": "syscall", + "tags": ["container", "drift"] +} +``` + +**Trigger command:** `docker exec lab9-target /bin/sh -c 'echo "test" > /tmp/my-write.txt'` + +--- + +### Tuning consideration (Lecture 9 slide 8) + +The "write to /tmp" rule generates significant noise in production: logging frameworks (`logback`, `log4j`), Node.js, and many runtimes write temp files to `/tmp` legitimately. The preferred tuning approach is the `exceptions:` block over inline `and not proc.name=...` conditions because exceptions are composable — multiple exception entries can be added without rewriting the condition, and they appear in structured form that linting tools can validate. For example: + +```yaml +exceptions: + - name: trusted_loggers + fields: [proc.name] + comps: [in] + values: [["node", "python3", "java"]] +``` + +This is preferable to `condition: ... and not proc.name in (node, python3, java)` because the condition stays clean and each exception entry is independently auditable (Lecture 9 slide 8: "exceptions are first-class citizens in Falco rule governance"). + +--- + +## Task 2: Conftest Policy-as-Code + +### My policy file (labs/lab9/policies/extra/hardening.rego) + +```rego +package main + +# 1. Pod or container must set runAsNonRoot: true +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.runAsNonRoot == true + not input.spec.template.spec.securityContext.runAsNonRoot == true + msg := sprintf("Deployment '%s': container '%s' must set runAsNonRoot: true", [input.metadata.name, c.name]) +} + +# 2. allowPrivilegeEscalation must be explicitly false on every container +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.allowPrivilegeEscalation == false + msg := sprintf("Deployment '%s': container '%s' must set allowPrivilegeEscalation: false", [input.metadata.name, c.name]) +} + +# 3. capabilities.drop must include ALL on every container +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not "ALL" in c.securityContext.capabilities.drop + msg := sprintf("Deployment '%s': container '%s' must drop ALL capabilities", [input.metadata.name, c.name]) +} + +# 4. Memory limits must be set (OOM kill bound) +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.resources.limits.memory + msg := sprintf("Deployment '%s': container '%s' must set resources.limits.memory", [input.metadata.name, c.name]) +} + +# 5. Image must not use :latest tag +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + endswith(c.image, ":latest") + msg := sprintf("Deployment '%s': container '%s' uses disallowed :latest tag — pin to a digest or explicit version", [input.metadata.name, c.name]) +} +``` + +### Good manifest passes + +``` +$ conftest test labs/lab9/manifests/k8s/juice-hardened.yaml --policy labs/lab9/policies/extra/ + +10 tests, 10 passed, 0 warnings, 0 failures, 0 exceptions +``` + +### Bad manifest fails (unhardened — no securityContext, :latest tag, no resources) + +``` +$ conftest test labs/lab9/manifests/k8s/juice-unhardened.yaml --policy labs/lab9/policies/extra/ + +FAIL - juice-unhardened.yaml - main - Deployment 'juice-unhardened': container 'juice' must set allowPrivilegeEscalation: false +FAIL - juice-unhardened.yaml - main - Deployment 'juice-unhardened': container 'juice' must set resources.limits.memory +FAIL - juice-unhardened.yaml - main - Deployment 'juice-unhardened': container 'juice' must set runAsNonRoot: true +FAIL - juice-unhardened.yaml - main - Deployment 'juice-unhardened': container 'juice' uses disallowed :latest tag — pin to a digest or explicit version + +10 tests, 6 passed, 0 warnings, 4 failures, 0 exceptions +``` + +### Why CI-time vs admission-time (Lecture 9 slide 9) + +CI-time Conftest runs during PR review — it catches misconfigurations before code is merged and before any cluster is involved, giving the developer immediate feedback in the same loop where they can fix it at zero cost. Admission-time gating (Kyverno, OPA Gatekeeper) runs at `kubectl apply` time and acts as the last line of defense — it blocks deployments that somehow bypassed CI (direct `kubectl` from a developer laptop, a CI pipeline that skipped the policy step, or a manifest created by an operator without repo access). Running both creates defense-in-depth: CI-time prevents the problem from ever reaching the cluster in the normal flow; admission-time enforces the same policy unconditionally regardless of how the manifest was submitted. + +--- + +## Bonus: Cryptominer Detection Rule + +### Rule + +```yaml +- rule: Possible Cryptominer Activity + desc: > + Detects a container connecting to well-known cryptomining pool ports or running + a known miner binary — maps to the Tesla 2018 K8s dashboard incident (Lecture 1) + condition: > + container + and ( + (evt.type = connect and fd.rport in (3333, 4444, 5555, 7777, 14444, 19999, 45700)) + or + (proc.name in (xmrig, ethminer, cgminer, t-rex, claymore, nbminer)) + ) + output: > + Possible cryptominer activity in container + (container=%container.name image=%container.image.repository + proc=%proc.name conn=%fd.name cmd=%proc.cmdline) + priority: CRITICAL + tags: [container, mitre_execution, mitre_command_and_control] +``` + +### Triggered alert + +```json +{ + "hostname": "8e925df0b9ce", + "output": "2026-07-09T06:00:44.325149895+0000: Critical Possible cryptominer activity in container (container=lab9-target image=alpine proc=nc conn=172.17.0.2:39281->8.8.8.8:3333 cmd=nc -w 3 8.8.8.8 3333)", + "output_fields": { + "container.name": "lab9-target", + "container.image.repository": "alpine", + "fd.name": "172.17.0.2:39281->8.8.8.8:3333", + "proc.cmdline": "nc -w 3 8.8.8.8 3333", + "proc.name": "nc" + }, + "priority": "Critical", + "rule": "Possible Cryptominer Activity", + "source": "syscall", + "tags": ["container", "mitre_execution", "mitre_command_and_control"] +} +``` + +**Trigger command:** `docker exec lab9-target sh -c 'timeout 3 nc -w 3 8.8.8.8 3333 2>/dev/null || true'` + +### Reflection + +**Indicators used:** (1) outbound TCP connect to known mining-pool ports (`fd.rport in (3333, 4444, ...)`) + (2) process name matches known miner binaries (`proc.name in (xmrig, ethminer, ...)`). Port-based detection catches any process connecting to a mining pool, while process-name detection catches miners even on non-standard ports. Together they cover the two most common deployment patterns: downloaded miner binary and miner-as-a-service calling home. + +**What this misses (false-negative case):** A sophisticated attacker exfiltrates mining traffic over HTTPS port 443 (which is indistinguishable from legitimate outbound HTTPS), uses a custom-compiled miner binary with a renamed process (so `proc.name` doesn't match any known list), or pools mining traffic through a CDN domain. Port-based and process-name-based rules are defeated by any mining setup designed to blend in with normal web traffic — this is the "living off the land" evasion (Lecture 9 slide 8's noise/signal discussion). + +**SLA matrix integration:** Per Lecture 9's SLA matrix, a CRITICAL Falco alert maps to a 1-hour response SLA. In practice, this rule should auto-page the on-call team and simultaneously trigger a `kubectl cordon` + pod eviction to stop the resource drain while investigation proceeds — the financial impact of a cryptominer is direct (CPU/GPU billing) and measurable, which makes it one of the few runtime alerts where automated remediation (not just notification) is defensible. diff --git a/submissions/verified-badge.png b/submissions/verified-badge.png new file mode 100644 index 000000000..b47a9f5dc Binary files /dev/null and b/submissions/verified-badge.png differ