Hands-On Labs

Run these in a disposable environment. Commands are illustrative starting points, not hardened production configuration.

Lab 01

Scan a Container Image

Beginner20 min

Objective Scan a public container image with Trivy and interpret the findings.

DockerTrivyLinux

Prerequisites

  • Docker or Podman installed
  • Trivy installed locally or run via container
  1. 01 Pull a deliberately outdated image

    bash
    docker pull python:3.8-slim
    An older base image gives you real findings to work with.
  2. 02 Run a vulnerability scan

    bash
    trivy image --severity HIGH,CRITICAL python:3.8-slim
    Restrict severity so the output stays readable on a first run.
  3. 03 Compare against a current base image

    bash
    trivy image --severity HIGH,CRITICAL python:3.12-slim
    Note how much of the finding count is attributable to base image age alone.
  4. 04 Filter to fixable issues only

    bash
    trivy image --ignore-unfixed --severity CRITICAL python:3.8-slim
    This 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-slim

Lessons learned

  • Base image selection dominates image vulnerability counts.
  • Fixable findings are the actionable metric for pipeline gates.
  • Severity filtering is essential for adoption.
Lab 02

Detect Committed Secrets

Beginner25 min

Objective Use Gitleaks to find a secret committed to git history and practise the response.

GitleaksGitHubLinux

Prerequisites

  • git installed
  • Gitleaks installed
  1. 01 Create a throwaway repository

    bash
    mkdir secret-lab && cd secret-lab && git init
  2. 02 Commit a fake credential

    bash
    printf '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.
  3. 03 Remove it the naive way

    bash
    rm .env && git commit -am "remove config"
    This is what teams usually do first. The next step shows why it is not enough.
  4. 04 Scan the full history

    bash
    gitleaks detect --source . --redact -v
    The secret is still detected because it remains in an earlier commit.
  5. 05 Add prevention at commit time

    bash
    cat > .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-lab

Lessons 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.
Lab 03

Secure Terraform with Checkov

Beginner30 min

Objective Find and fix insecure Terraform configuration before applying it.

TerraformCheckovAWS

Prerequisites

  • Python 3 and pip
  • checkov installed (pip install checkov)
  1. 01 Create an intentionally insecure bucket definition

    bash
    mkdir tf-lab && cd tf-lab && cat > main.tf <<'HCL'resource "aws_s3_bucket" "data" {  bucket = "acme-lab-data"}HCL
  2. 02 Scan the directory

    bash
    checkov -d . --compact
    Expect findings for missing encryption, versioning, logging and public-access blocking.
  3. 03 Remediate the highest-value findings

    bash
    cat >> 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" }}HCL
  4. 04 Re-scan and confirm the reduction

    bash
    checkov -d . --compact

Expected result

The failed-check count drops and you can explain what each remaining finding would require.

Cleanup

cd .. && rm -rf tf-lab

Lessons 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.
Lab 04

Enforce Kubernetes Policy

Intermediate45 min

Objective Scan Kubernetes manifests, then enforce a policy with Kyverno in a local cluster.

KubernetesKyvernoTrivy

Prerequisites

  • kind or minikube
  • kubectl
  • Helm
  1. 01 Create a local cluster

    bash
    kind create cluster --name policy-lab
  2. 02 Scan a manifest before applying it

    bash
    trivy config ./k8s/
    Static misconfiguration checks catch privileged containers and missing security contexts.
  3. 03 Install Kyverno

    bash
    helm repo add kyverno https://kyverno.github.io/kyvernohelm install kyverno kyverno/kyverno -n kyverno --create-namespace
  4. 04 Apply a policy in Audit mode

    bash
    kubectl apply -f https://raw.githubusercontent.com/kyverno/policies/main/best-practices/disallow-latest-tag/disallow-latest-tag.yaml
    Audit first so you can measure how many existing workloads would break.
  5. 05 Review policy reports

    bash
    kubectl get policyreport -A
  6. 06 Switch to Enforce and retest

    bash
    kubectl 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-lab

Lessons 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.
Lab 05

Restrict Pod Privileges

Intermediate35 min

Objective Apply Pod Security Admission and fix a workload that fails the restricted profile.

KubernetesLinux

Prerequisites

  • A local Kubernetes cluster
  • kubectl
  1. 01 Label a namespace with the restricted profile

    bash
    kubectl create namespace hardenedkubectl label namespace hardened pod-security.kubernetes.io/enforce=restricted
  2. 02 Try to run a default nginx pod

    bash
    kubectl -n hardened run web --image=nginx:1.27
    This is rejected because the image expects to run as root and write to the filesystem.
  3. 03 Deploy a compliant workload

    bash
    kubectl -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 hardened

Lessons learned

  • Many images assume root; unprivileged variants often exist.
  • Pod Security Admission is built in and requires no extra components.
Lab 06

Build a Secure CI/CD Pipeline

Advanced90 min

Objective Assemble a pipeline containing SAST, SCA, secret detection, container scanning, SBOM generation and a security gate.

GitLabDockerTrivySemgrep

Prerequisites

  • A GitLab or GitHub repository with a Dockerfile
  • Ability to run CI jobs
  1. 01 Add code-level scanning stages

    yaml
    stages: [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 .]
  2. 02 Build and scan the image

    yaml
    build:  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"
  3. 03 Generate and store an SBOM

    yaml
    sbom:  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 days
  4. 04 Add a manual production gate

    yaml
    deploy_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.
Lab 07

Sign and Verify an Artifact

Advanced40 min

Objective Generate an SBOM, sign a container image with Cosign, and verify the signature.

DockerCycloneDXGitHub

Prerequisites

  • cosign and syft installed
  • A registry you can push to
  1. 01 Build and push by digest

    bash
    docker 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")
  2. 02 Generate an SBOM from the image

    bash
    syft "$DIGEST" -o cyclonedx-json=sbom.cdx.json
  3. 03 Sign the image

    bash
    cosign sign --yes "$DIGEST"
    Keyless signing opens a browser flow and records the signature in the transparency log.
  4. 04 Attach the SBOM as an attestation

    bash
    cosign attest --predicate sbom.cdx.json --type cyclonedx --yes "$DIGEST"
  5. 05 Verify with a pinned identity

    bash
    cosign 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.
Lab 08

Detect Runtime Anomalies

Advanced45 min

Objective Install Falco and trigger a runtime detection inside a container.

KubernetesFalcoLinux

Prerequisites

  • A cluster where you can deploy a DaemonSet
  • Helm
  1. 01 Install Falco

    bash
    helm repo add falcosecurity https://falcosecurity.github.io/chartshelm install falco falcosecurity/falco -n falco --create-namespace
  2. 02 Deploy a test workload

    bash
    kubectl run target --image=alpine -- sleep 3600
  3. 03 Trigger a detection

    bash
    kubectl exec -it target -- sh -c 'cat /etc/shadow'
    Both the interactive shell and the sensitive file read should match default rules.
  4. 04 Inspect the alerts

    bash
    kubectl -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 falco

Lessons learned

  • Runtime detection sees behaviour that build-time scanning cannot.
  • Default rules need tuning for legitimate debugging workflows.
Lab 09

Secure an Ansible Automation Workflow

Intermediate40 min

Objective Lint Ansible content, remove plaintext secrets and keep sensitive values out of job output.

AnsibleAAPLinuxVault

Prerequisites

  • ansible-core and ansible-lint installed
  1. 01 Create a playbook with a plaintext secret

    bash
    mkdir ansible-lab && cd ansible-lab && cat > site.yml <<'YAML'- hosts: all  vars:    db_password: "SuperSecret123"  tasks:    - shell: echo "{{ db_password }}" > /etc/app/db.confYAML
  2. 02 Lint with the production profile

    bash
    ansible-lint --profile production site.yml
    Expect findings for the unnamed task, the shell module and the missing file mode.
  3. 03 Rewrite using safe modules and no_log

    bash
    cat > 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: trueYAML
  4. 04 Encrypt the variable

    bash
    ansible-vault encrypt_string 'SuperSecret123' --name 'db_password' > group_vars/all/vault.yml
    In a platform deployment the value would come from a credential store instead of a vault file.
  5. 05 Re-lint and confirm a clean run

    bash
    ansible-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-lab

Lessons 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.
Lab 10

Triage Vulnerability Findings

Intermediate35 min

Objective Take raw scanner output and produce a prioritised, owner-assigned remediation list.

TrivyPythonGitLab

Prerequisites

  • Trivy installed
  • jq installed
  1. 01 Produce machine-readable output

    bash
    trivy image --format json --output scan.json "$IMAGE"
  2. 02 Extract fixable high and critical findings

    bash
    jq -r '  .Results[].Vulnerabilities // []  | map(select(.Severity == "CRITICAL" or .Severity == "HIGH"))  | map(select(.FixedVersion != null))  | .[] | [.PkgName, .InstalledVersion, .FixedVersion, .VulnerabilityID] | @tsv' scan.json | sort -u
  3. 03 Group by package to find the highest-leverage upgrade

    bash
    jq -r '.Results[].Vulnerabilities // [] | .[].PkgName' scan.json | sort | uniq -c | sort -rn | head
    One 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.json

Lessons learned

  • Prioritise by fix availability and leverage, not raw severity counts.
  • Machine-readable output is what makes triage repeatable.