Hands-On Labs
Run these in a disposable environment. Commands are illustrative starting points, not hardened production configuration.
Scan a Container Image
Objective Scan a public container image with Trivy and interpret the findings.
Prerequisites
- Docker or Podman installed
- Trivy installed locally or run via container
01 Pull a deliberately outdated image
bashdocker pull python:3.8-slimAn older base image gives you real findings to work with. 02 Run a vulnerability scan
bashtrivy image --severity HIGH,CRITICAL python:3.8-slimRestrict severity so the output stays readable on a first run. 03 Compare against a current base image
bashtrivy image --severity HIGH,CRITICAL python:3.12-slimNote how much of the finding count is attributable to base image age alone. 04 Filter to fixable issues only
bashtrivy image --ignore-unfixed --severity CRITICAL python:3.8-slimThis is the set a team can actually act on today.
Expected result
You have two reports and can explain the difference between total findings and fixable findings, and how base image choice changes both.
Cleanup
docker rmi python:3.8-slim python:3.12-slimLessons learned
- Base image selection dominates image vulnerability counts.
- Fixable findings are the actionable metric for pipeline gates.
- Severity filtering is essential for adoption.
Detect Committed Secrets
Objective Use Gitleaks to find a secret committed to git history and practise the response.
Prerequisites
- git installed
- Gitleaks installed
01 Create a throwaway repository
bashmkdir secret-lab && cd secret-lab && git init02 Commit a fake credential
bashprintf 'AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"\n' > .envgit add .env && git commit -m "add config"Use an obviously fake example value; never use a real key in a lab. 03 Remove it the naive way
bashrm .env && git commit -am "remove config"This is what teams usually do first. The next step shows why it is not enough. 04 Scan the full history
bashgitleaks detect --source . --redact -vThe secret is still detected because it remains in an earlier commit. 05 Add prevention at commit time
bashcat > .pre-commit-config.yaml <<'YAML'repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.0 hooks: - id: gitleaksYAMLpre-commit install
Expected result
Gitleaks reports the credential from history even after the file is deleted, and a pre-commit hook now blocks new occurrences.
Cleanup
cd .. && rm -rf secret-labLessons learned
- Deleting a file does not remove the secret from history.
- Any exposed credential must be rotated, not just removed.
- Prevention at commit time is cheaper than remediation.
Secure Terraform with Checkov
Objective Find and fix insecure Terraform configuration before applying it.
Prerequisites
- Python 3 and pip
- checkov installed (pip install checkov)
01 Create an intentionally insecure bucket definition
bashmkdir tf-lab && cd tf-lab && cat > main.tf <<'HCL'resource "aws_s3_bucket" "data" { bucket = "acme-lab-data"}HCL02 Scan the directory
bashcheckov -d . --compactExpect findings for missing encryption, versioning, logging and public-access blocking. 03 Remediate the highest-value findings
bashcat >> main.tf <<'HCL' resource "aws_s3_bucket_public_access_block" "data" { bucket = aws_s3_bucket.data.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true} resource "aws_s3_bucket_versioning" "data" { bucket = aws_s3_bucket.data.id versioning_configuration { status = "Enabled" }}HCL04 Re-scan and confirm the reduction
bashcheckov -d . --compact
Expected result
The failed-check count drops and you can explain what each remaining finding would require.
Cleanup
cd .. && rm -rf tf-labLessons learned
- IaC scanning gives feedback before any infrastructure exists.
- Not every default is safe; encryption and public-access blocking must be explicit.
- Remaining findings should be triaged, not blanket-suppressed.
Enforce Kubernetes Policy
Objective Scan Kubernetes manifests, then enforce a policy with Kyverno in a local cluster.
Prerequisites
- kind or minikube
- kubectl
- Helm
01 Create a local cluster
bashkind create cluster --name policy-lab02 Scan a manifest before applying it
bashtrivy config ./k8s/Static misconfiguration checks catch privileged containers and missing security contexts. 03 Install Kyverno
bashhelm repo add kyverno https://kyverno.github.io/kyvernohelm install kyverno kyverno/kyverno -n kyverno --create-namespace04 Apply a policy in Audit mode
bashkubectl apply -f https://raw.githubusercontent.com/kyverno/policies/main/best-practices/disallow-latest-tag/disallow-latest-tag.yamlAudit first so you can measure how many existing workloads would break. 05 Review policy reports
bashkubectl get policyreport -A06 Switch to Enforce and retest
bashkubectl run bad --image=nginx:latest# expect the request to be rejected once validationFailureAction is Enforce
Expected result
A non-compliant pod is rejected at admission, and you have a policy report showing pre-existing violations.
Cleanup
kind delete cluster --name policy-labLessons learned
- Audit mode is how you size the impact before enforcing.
- Admission control enforces regardless of how the manifest was applied.
- Policy reports are useful compliance evidence.
Restrict Pod Privileges
Objective Apply Pod Security Admission and fix a workload that fails the restricted profile.
Prerequisites
- A local Kubernetes cluster
- kubectl
01 Label a namespace with the restricted profile
bashkubectl create namespace hardenedkubectl label namespace hardened pod-security.kubernetes.io/enforce=restricted02 Try to run a default nginx pod
bashkubectl -n hardened run web --image=nginx:1.27This is rejected because the image expects to run as root and write to the filesystem. 03 Deploy a compliant workload
bashkubectl -n hardened apply -f - <<'YAML'apiVersion: v1kind: Podmetadata: name: webspec: containers: - name: web image: nginxinc/nginx-unprivileged:1.27 securityContext: runAsNonRoot: true runAsUser: 101 allowPrivilegeEscalation: false capabilities: { drop: ["ALL"] } seccompProfile: { type: RuntimeDefault }YAML
Expected result
The default pod is rejected, the unprivileged variant runs, and you can list the fields the restricted profile requires.
Cleanup
kubectl delete namespace hardenedLessons learned
- Many images assume root; unprivileged variants often exist.
- Pod Security Admission is built in and requires no extra components.
Build a Secure CI/CD Pipeline
Objective Assemble a pipeline containing SAST, SCA, secret detection, container scanning, SBOM generation and a security gate.
Prerequisites
- A GitLab or GitHub repository with a Dockerfile
- Ability to run CI jobs
01 Add code-level scanning stages
yamlstages: [scan, build, verify, deploy] sast: stage: scan image: returntocorp/semgrep script: [semgrep --config auto --error .] secrets: stage: scan image: zricethezav/gitleaks script: [gitleaks detect --source . --redact] sca: stage: scan image: aquasec/trivy script: [trivy fs --ignore-unfixed --severity HIGH,CRITICAL .]02 Build and scan the image
yamlbuild: stage: build script: - docker build -t "$IMAGE:$CI_COMMIT_SHA" . image_scan: stage: verify image: aquasec/trivy script: - trivy image --exit-code 1 --ignore-unfixed --severity CRITICAL "$IMAGE:$CI_COMMIT_SHA"03 Generate and store an SBOM
yamlsbom: stage: verify image: anchore/syft script: - syft "$IMAGE:$CI_COMMIT_SHA" -o cyclonedx-json=sbom.cdx.json artifacts: paths: [sbom.cdx.json] expire_in: 400 days04 Add a manual production gate
yamldeploy_prod: stage: deploy environment: { name: production } when: manual rules: - if: $CI_COMMIT_BRANCH == "main" script: [./deploy.sh]
Expected result
A pipeline that fails on fixable critical issues, stores an SBOM per build, and requires approval before production deployment.
Lessons learned
- Order stages so cheap checks fail fast.
- Gate on fixable findings to keep the pipeline actionable.
- SBOM retention must outlive the release.
Sign and Verify an Artifact
Objective Generate an SBOM, sign a container image with Cosign, and verify the signature.
Prerequisites
- cosign and syft installed
- A registry you can push to
01 Build and push by digest
bashdocker build -t "$REG/app:1.0.0" . && docker push "$REG/app:1.0.0"DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$REG/app:1.0.0")02 Generate an SBOM from the image
bashsyft "$DIGEST" -o cyclonedx-json=sbom.cdx.json03 Sign the image
bashcosign sign --yes "$DIGEST"Keyless signing opens a browser flow and records the signature in the transparency log. 04 Attach the SBOM as an attestation
bashcosign attest --predicate sbom.cdx.json --type cyclonedx --yes "$DIGEST"05 Verify with a pinned identity
bashcosign verify --certificate-identity "you@example.com" \ --certificate-oidc-issuer "https://accounts.google.com" "$DIGEST"
Expected result
Verification succeeds for your identity and fails when a different identity is supplied.
Lessons learned
- Always sign and reference digests, not tags.
- Verification is only meaningful when the expected identity is pinned.
Detect Runtime Anomalies
Objective Install Falco and trigger a runtime detection inside a container.
Prerequisites
- A cluster where you can deploy a DaemonSet
- Helm
01 Install Falco
bashhelm repo add falcosecurity https://falcosecurity.github.io/chartshelm install falco falcosecurity/falco -n falco --create-namespace02 Deploy a test workload
bashkubectl run target --image=alpine -- sleep 360003 Trigger a detection
bashkubectl exec -it target -- sh -c 'cat /etc/shadow'Both the interactive shell and the sensitive file read should match default rules. 04 Inspect the alerts
bashkubectl -n falco logs -l app.kubernetes.io/name=falco | grep -i warning
Expected result
Falco logs alerts for the shell session and the sensitive file read, including pod and image metadata.
Cleanup
kubectl delete pod target && helm uninstall falco -n falcoLessons learned
- Runtime detection sees behaviour that build-time scanning cannot.
- Default rules need tuning for legitimate debugging workflows.
Secure an Ansible Automation Workflow
Objective Lint Ansible content, remove plaintext secrets and keep sensitive values out of job output.
Prerequisites
- ansible-core and ansible-lint installed
01 Create a playbook with a plaintext secret
bashmkdir ansible-lab && cd ansible-lab && cat > site.yml <<'YAML'- hosts: all vars: db_password: "SuperSecret123" tasks: - shell: echo "{{ db_password }}" > /etc/app/db.confYAML02 Lint with the production profile
bashansible-lint --profile production site.ymlExpect findings for the unnamed task, the shell module and the missing file mode. 03 Rewrite using safe modules and no_log
bashcat > site.yml <<'YAML'- name: Configure application hosts: all tasks: - name: Write database configuration ansible.builtin.copy: content: "{{ db_password }}" dest: /etc/app/db.conf mode: "0600" no_log: trueYAML04 Encrypt the variable
bashansible-vault encrypt_string 'SuperSecret123' --name 'db_password' > group_vars/all/vault.ymlIn a platform deployment the value would come from a credential store instead of a vault file. 05 Re-lint and confirm a clean run
bashansible-lint --profile production site.yml
Expected result
Lint passes, the secret is no longer plaintext in the repository, and job output no longer echoes it.
Cleanup
cd .. && rm -rf ansible-labLessons learned
- `no_log` prevents secrets leaking into automation logs.
- Purpose-built modules are safer and more auditable than shell.
- Credential injection belongs to the platform, not the playbook.
Triage Vulnerability Findings
Objective Take raw scanner output and produce a prioritised, owner-assigned remediation list.
Prerequisites
- Trivy installed
- jq installed
01 Produce machine-readable output
bashtrivy image --format json --output scan.json "$IMAGE"02 Extract fixable high and critical findings
bashjq -r ' .Results[].Vulnerabilities // [] | map(select(.Severity == "CRITICAL" or .Severity == "HIGH")) | map(select(.FixedVersion != null)) | .[] | [.PkgName, .InstalledVersion, .FixedVersion, .VulnerabilityID] | @tsv' scan.json | sort -u03 Group by package to find the highest-leverage upgrade
bashjq -r '.Results[].Vulnerabilities // [] | .[].PkgName' scan.json | sort | uniq -c | sort -rn | headOne package upgrade often closes many findings at once.
Expected result
A short list of upgrades ordered by number of findings resolved, ready to assign to an owner.
Cleanup
rm -f scan.jsonLessons learned
- Prioritise by fix availability and leverage, not raw severity counts.
- Machine-readable output is what makes triage repeatable.