Build a DevSecOps Pipeline with Security Gates
Build a four-gate GitHub Actions pipeline with Gitleaks, Trivy and Checkov that blocks secrets, vulnerable dependencies and insecure IaC.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a GitHub Actions pipeline with four security gates: Gitleaks for secrets, Trivy for vulnerable dependencies and the container image, and Checkov for Terraform and Dockerfile misconfigurations. A gate is a job that fails the run when it finds a problem, so insecure code can never reach the image that gets published.
You will plant three problems on purpose (a vulnerable dependency, an insecure Terraform file, a root container), watch each gate catch them, fix them, and then prove the gates still block when a problem comes back. The result is a repository you can show in a portfolio and walk through in an interview.
Why gates instead of reports
A scan that only writes a report is easy to ignore. A security gate is different: the pipeline stops, and the person who pushed the change sees the exact finding while the code is still fresh in their head. Three ideas make the gates trustworthy:
- Scan before you publish. The image is built once, scanned as a file, and only that same image is pushed to the registry. Nothing is rebuilt after the scan.
- Run independent gates in parallel. Secrets, dependencies and IaC do not depend on each other, so they run side by side and the developer sees every problem in one run.
- Pin the scanners. Every tool is installed at an exact version and the downloads are checked against a SHA-256 checksum, so a compromised release or a surprise update cannot change what your gates do.
What you will build
| Gate | Tool | What it blocks |
|---|---|---|
| 1. Secrets | Gitleaks | API keys and tokens anywhere in git history |
| 2. Dependencies | Trivy (filesystem) | HIGH and CRITICAL CVEs that have a fix |
| 3. IaC | Checkov | Open SSH, public S3 buckets, root containers |
| 4. Image | Trivy (image) | HIGH and CRITICAL CVEs in the built image |
What you need first
- A GitHub account and a new empty repository (private is fine)
- Git, Docker and Python 3.12 on a Linux or macOS machine (or WSL)
- Basic comfort with a terminal and with reading YAML
TipThe version numbers in this project were current when it was written. Newer releases usually work the same way. If you upgrade, change the version and the checksum together.
SecurityThe "before" files in this project are deliberately insecure. Keep them in a throwaway repository and never run
terraform applyon them. This project never applies Terraform; it only scans it.
Milestone 1: Create the App and Plant Three Problems
This milestone creates a tiny Flask service with one unit test, then adds three problems for the gates to find later: an old dependency, an insecure Terraform file and a Dockerfile that runs as root. A pipeline that has nothing to catch proves nothing, so you build the failures first.
Create the repository layout
mkdir gates-demo && cd gates-demogit init -b mainmkdir -p app tests terraform scripts .github/workflowstouch app/__init__.py tests/__init__.pyWrite the application and its test
# app/main.pyfrom flask import Flask, jsonify app = Flask(__name__) @app.get("/health")def health(): return jsonify(status="ok") @app.get("/")def index(): return jsonify(service="gates-demo", message="hello from a gated pipeline")# tests/test_app.pyimport unittest from app.main import app class HealthTest(unittest.TestCase): def test_health_returns_ok(self): client = app.test_client() response = client.get("/health") self.assertEqual(response.status_code, 200) self.assertEqual(response.get_json(), {"status": "ok"}) if __name__ == "__main__": unittest.main()Plant problem 1: an old dependency
The pinned urllib3 below is an old release with known, fixed vulnerabilities. Pinning an exact version matters: a scanner can only judge a version it can read.
# requirements.txtFlask==3.1.3urllib3==1.26.4Plant problem 2: an insecure Terraform file
This file describes a bucket that allows public access and a security group that opens SSH to the whole internet. You will scan it, never apply it.
# terraform/main.tfterraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }} provider "aws" { region = "eu-west-1"} resource "aws_s3_bucket" "assets" { bucket = "gates-demo-assets-example"} resource "aws_s3_bucket_public_access_block" "assets" { bucket = aws_s3_bucket.assets.id block_public_acls = false block_public_policy = false ignore_public_acls = false restrict_public_buckets = false} resource "aws_security_group" "app" { name = "gates-demo-app" description = "Application security group" ingress { description = "SSH from anywhere" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }}Plant problem 3: a container that runs as root
This Dockerfile never creates a user, so the process runs as root, and it has no health check.
# DockerfileFROM python:3.12-slimWORKDIR /srvCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app ./appEXPOSE 8080CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]Run the test and make the first commit
python3 -m venv .venv && source .venv/bin/activatepip install -r requirements.txtpython3 -m unittest discover -s tests -t .printf '.venv/\n__pycache__/\n*.pyc\n' > .gitignoregit add . && git commit -m "Add demo app with three planted problems"| Command | What it does |
|---|---|
git init -b main |
Creates the repository with main as the first branch |
unittest discover -s tests -t . |
Finds and runs every test under tests/ |
Common MistakeForgetting
.gitignorebefore the first commit. If.venv/gets committed, scanners later report hundreds of findings from files you do not own. Add the ignore file first, and if you already committed the folder, rungit rm -r --cached .venv.
Milestone 2: Install Pinned Scanners and Run Them Locally
Running the scanners on your own machine first shows you what a finding looks like before it appears in a failed pipeline. You will install the tools at exact versions, and the install script verifies each download against a SHA-256 checksum, so you get the same tool every time.
Why pin versions and verify checksums
Security tools run with access to your source code and your CI credentials, so a tampered release is a serious risk. Installing "latest" means a change you did not review. Pinning a version and checking its hash means the only way the tool changes is a commit that you can see in review.
Write the install script
#!/usr/bin/env bash# scripts/install-scanners.sh# Usage: scripts/install-scanners.sh gitleaks trivyset -euo pipefail GITLEAKS_VERSION="8.30.1"GITLEAKS_SHA256="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"TRIVY_VERSION="0.75.0"TRIVY_SHA256="c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f" BIN_DIR="${HOME}/.local/bin"TMP_DIR="$(mktemp -d)"mkdir -p "${BIN_DIR}" install_tool() { local name="$1" url="$2" sha="$3" curl -fsSL -o "${TMP_DIR}/${name}.tar.gz" "${url}" echo "${sha} ${TMP_DIR}/${name}.tar.gz" | sha256sum -c - tar -xzf "${TMP_DIR}/${name}.tar.gz" -C "${TMP_DIR}" "${name}" install -m 0755 "${TMP_DIR}/${name}" "${BIN_DIR}/${name}"} for tool in "$@"; do case "${tool}" in gitleaks) install_tool gitleaks \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ "${GITLEAKS_SHA256}" ;; trivy) install_tool trivy \ "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ "${TRIVY_SHA256}" ;; *) echo "unknown tool: ${tool}" >&2; exit 1 ;; esacdone if [ -n "${GITHUB_PATH:-}" ]; then echo "${BIN_DIR}" >> "${GITHUB_PATH}"fiecho "installed: $*"chmod +x scripts/install-scanners.shscripts/install-scanners.sh gitleaks trivyexport PATH="$HOME/.local/bin:$PATH"gitleaks version && trivy --versionpip install checkov==3.3.26TipThe script targets Linux on x86-64, which matches GitHub's
ubuntu-24.04runners. On macOS, usebrew install gitleaks trivyfor local runs. The pipeline still uses the script, so CI stays pinned.
Gate 1: secrets with Gitleaks
Gitleaks reads your git history and flags text that looks like a credential. It scans commits, not just the files in your working folder.
gitleaks git --no-banner --redact .Expected result for now: a line saying no leaks were found. Your first commit has no secret in it. You will break that on purpose in Milestone 5.
Gate 2: dependencies with Trivy
Trivy compares every package version it finds against a vulnerability database. Software composition analysis (checking the libraries you depend on) is the name for this kind of scan.
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .Expected result: a table that lists urllib3 with the installed version 1.26.4, the CVE IDs and the version that fixes each one, and the command exits with code 1. The exact list changes as the database updates, so do not expect identical rows.
Gate 3: infrastructure as code with Checkov
Checkov reads Terraform and Dockerfiles and checks them against rules for known misconfigurations. You pick the rules to enforce with --check, which keeps the gate focused and predictable.
checkov -d . --framework terraform,dockerfile \ --check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \ --compact --quietExpected result: failures that include CKV_AWS_24 (SSH open to the world), CKV_AWS_53 to CKV_AWS_56 (public access block settings), CKV_AWS_21 (no versioning), CKV_DOCKER_3 (no container user) and CKV_DOCKER_2 (no health check).
| Check ID | What it enforces |
|---|---|
CKV_AWS_53 to CKV_AWS_56 |
The four S3 public access block settings are on |
CKV_AWS_24 |
No security group allows SSH from 0.0.0.0/0 |
CKV_AWS_21 |
S3 versioning is enabled |
CKV2_AWS_6 |
The bucket has a public access block |
CKV_DOCKER_2 |
The image defines a HEALTHCHECK |
CKV_DOCKER_3 |
The image runs as a non-root user |
Common MistakeRunning Checkov with no
--checklist and then wondering why it reports a dozen findings you never planted, such as logging, replication and lifecycle rules. Those are valid best practices but not the point of this project. Scope the gate to rules you can explain and fix.
Milestone 3: Fix the Findings and Get a Clean Run
This milestone fixes every problem from Milestone 1 so all three local scans pass. Fixing a finding teaches more than reading it, because you see which exact line the scanner cared about.
Fix the dependency
Upgrade urllib3 to a release that contains the fixes. For a real project, run your tests after any upgrade, because newer major versions can change behaviour.
# requirements.txtFlask==3.1.3urllib3==2.8.0pip install -r requirements.txtpython3 -m unittest discover -s tests -t .trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .echo "exit code: $?"Expected result: the tests pass and Trivy exits with code 0.
Fix the Terraform
Turn on all four public access block settings, replace the world-open SSH rule with HTTPS from inside your network range, and enable versioning.
# terraform/main.tfterraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }} provider "aws" { region = "eu-west-1"} resource "aws_s3_bucket" "assets" { bucket = "gates-demo-assets-example"} resource "aws_s3_bucket_public_access_block" "assets" { bucket = aws_s3_bucket.assets.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true} resource "aws_s3_bucket_versioning" "assets" { bucket = aws_s3_bucket.assets.id versioning_configuration { status = "Enabled" }} resource "aws_security_group" "app" { name = "gates-demo-app" description = "Application security group" ingress { description = "HTTPS from inside the VPC" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["10.0.0.0/16"] }}Fix the Dockerfile
Create a system user and switch to it before the container starts, and add a health check so the platform can tell when the service is unhealthy.
# DockerfileFROM python:3.12-slimWORKDIR /srvCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt \ && useradd --system --no-create-home appuserCOPY app ./appUSER appuserEXPOSE 8080HEALTHCHECK --interval=30s --timeout=3s CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8080/health')"]CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]TipFlask's built-in server is fine for a demo. For a real service, put a production server such as Gunicorn in front of your app.
Re-run the IaC gate and the image scan
checkov -d . --framework terraform,dockerfile \ --check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \ --compact --quietecho "checkov exit code: $?" docker build -t gates-demo:local .docker save gates-demo:local -o image.tartrivy image --input image.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1echo "image scan exit code: $?"rm image.tarExpected result: Checkov reports every check as passed and exits with 0. The image scan should exit with 0 as well. If it reports an operating system package, that is the image gate doing its job: rebuild with docker build --pull to get a newer base image.
Commit the clean state
git add . && git commit -m "Fix dependency, Terraform and Dockerfile findings"Common MistakeSilencing a finding with a skip comment or by dropping the check from the list so the pipeline goes green. A skip needs a written reason and an owner. If you cannot explain why the risk is acceptable, fix the code instead.
Milestone 4: Build the Four-Gate GitHub Actions Workflow
This milestone turns the local commands into a pipeline. Three gates (secrets, dependencies, IaC) run in parallel, the image gate waits for all of them, and a summary job reports the result. The same scanners and the same pinned versions run in CI as on your laptop.
How the jobs connect
The key line is needs. A job with needs: [secrets, dependencies, iac] only starts when all three succeed. That single line is what makes the earlier gates actually block the image build.
Create the workflow
# .github/workflows/pipeline.ymlname: Security gates on: push: branches: [main] pull_request: branches: [main] permissions: contents: read concurrency: group: gates-${{ github.ref }} cancel-in-progress: true jobs: secrets: name: "Gate 1: Secrets" runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install Gitleaks run: scripts/install-scanners.sh gitleaks - name: Scan full git history run: gitleaks git --no-banner --redact --exit-code 1 --report-format json --report-path gitleaks-report.json . - uses: actions/upload-artifact@v4 if: always() with: name: gitleaks-report path: gitleaks-report.json if-no-files-found: ignore dependencies: name: "Gate 2: Dependencies" runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies and run tests run: | pip install -r requirements.txt python -m unittest discover -s tests -t . - name: Install Trivy run: scripts/install-scanners.sh trivy - name: Scan dependencies run: trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 . iac: name: "Gate 3: IaC" runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install Checkov run: pip install checkov==3.3.26 - name: Scan Terraform and Dockerfile run: | checkov -d . --framework terraform,dockerfile \ --check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \ --compact --quiet image: name: "Gate 4: Image" runs-on: ubuntu-24.04 needs: [secrets, dependencies, iac] steps: - uses: actions/checkout@v4 - name: Build the image once run: | docker build -t gates-demo:${{ github.sha }} . docker save gates-demo:${{ github.sha }} -o image.tar - name: Install Trivy run: scripts/install-scanners.sh trivy - name: Scan the saved image run: trivy image --input image.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 - uses: actions/upload-artifact@v4 with: name: scanned-image path: image.tar retention-days: 1 summary: name: Results summary runs-on: ubuntu-24.04 needs: [secrets, dependencies, iac, image] if: always() steps: - name: Write job summary run: | { echo "| Gate | Result |" echo "| --- | --- |" echo "| Secrets | ${{ needs.secrets.result }} |" echo "| Dependencies | ${{ needs.dependencies.result }} |" echo "| IaC | ${{ needs.iac.result }} |" echo "| Image | ${{ needs.image.result }} |" } >> "$GITHUB_STEP_SUMMARY"Push and watch the first run
git add .github scriptsgit commit -m "Add four-gate security pipeline"git remote add origin git@github.com:YOUR-USER/gates-demo.gitgit push -u origin mainOpen the Actions tab. You should see three jobs start together, then Gate 4: Image, then the summary table on the run page.
| Setting | Why it is there |
|---|---|
permissions: contents: read |
The token can only read code, which limits damage if a step is compromised |
fetch-depth: 0 |
Gitleaks needs the full history, not only the latest commit |
--ignore-unfixed |
Blocks only what you can actually fix today |
if: always() on the summary |
The table still appears when a gate fails |
TipFor stronger supply chain safety, pin each
uses:line to a full commit SHA instead of a version tag, and keep the tag in a comment. Dependabot can update SHA pins for you.
Common MistakeUsing
continue-on-error: trueon a scan step "to see the results." The job then shows green even when it finds critical issues, and nobody notices. If you only want to look at results, run the scan in a separate workflow that is not a required check.
SecurityThe Trivy database downloads from a public registry that can rate-limit shared CI runners. If a run fails with a "too many requests" error rather than a finding, re-run the job. Do not respond by loosening the gate.
Milestone 5: Prove the Gates Block Real Problems
A gate you have never seen fail is a gate you cannot trust. In this milestone you reintroduce each problem on a throwaway branch and confirm the right job fails and the image job never starts.
Test 1: a leaked secret
Create a branch and commit a file that contains a token-like value. The value below is made up and unlocks nothing.
git switch -c test/leakcat > config.py << 'EOF'API_TOKEN = "xk9f3Qz81LmVt7RbPw20HdYc5NaUe4Sj6GoI"EOFgit add config.py && git commit -m "test: add a fake token"git push -u origin test/leakOpen a pull request into main. Expected result: Gate 1: Secrets fails with a finding of the generic-api-key rule, the line is redacted in the log, and Gate 4: Image is skipped because its needs were not met.
What to do about a real leak
If this had been a real credential, deleting the file would not be enough. The secret is still in git history, and Gitleaks keeps flagging that commit, which is the correct behaviour. The order of response is:
- Revoke and rotate the credential first, because anyone who saw it can still use it.
- Remove the commit from history (or delete the branch if it was never merged).
- Only then, if needed, record the old finding's fingerprint in a
.gitleaksignorefile with a comment explaining why.
git switch maingit push origin --delete test/leakgit branch -D test/leakTest 2: a vulnerable dependency
git switch -c test/old-dependencysed -i 's/^urllib3==.*/urllib3==1.26.4/' requirements.txtgit commit -am "test: downgrade urllib3"git push -u origin test/old-dependencyExpected result: Gate 2: Dependencies fails and the log shows urllib3 with its fixed versions. The other two parallel gates still pass, so the developer sees the whole picture in one run. Delete the branch afterwards.
Test 3: SSH open to the world
git switch maingit switch -c test/open-sshpython3 - << 'EOF'import rep = "terraform/main.tf"s = open(p).read()s = s.replace('from_port = 443', 'from_port = 22').replace('to_port = 443', 'to_port = 22').replace('10.0.0.0/16', '0.0.0.0/0')open(p, "w").write(s)EOFgit commit -am "test: open SSH to the world"git push -u origin test/open-sshExpected result: Gate 3: IaC fails on CKV_AWS_24. Delete the branch afterwards.
git switch maingit push origin --delete test/old-dependency test/open-sshgit branch -D test/old-dependency test/open-ssh| Test | Failing gate | Evidence in the log |
|---|---|---|
| Fake token | Gate 1: Secrets | generic-api-key finding, redacted |
Old urllib3 |
Gate 2: Dependencies | CVE rows for urllib3 |
SSH to 0.0.0.0/0 |
Gate 3: IaC | CKV_AWS_24 FAILED |
Common MistakeTesting only on
main. A failing push tomainblocks nothing that has already merged, and it can break the branch for everyone. Always break things on a branch and open a pull request, which is also where reviewers will see the result.
Milestone 6: Make Gates Mandatory and Publish the Scanned Image
Passing gates only matter if nobody can skip them. This milestone makes the four jobs required checks for merging, then adds a publish job that pushes the exact image you scanned, and only from main.
Require the gates before merging
In your repository, open Settings, then Rules, then Rulesets, and create a ruleset for the default branch with these options:
- Require a pull request before merging
- Require status checks to pass, and add
Gate 1: Secrets,Gate 2: Dependencies,Gate 3: IaCandGate 4: Image - Block force pushes
Common MistakeA required check is matched by the job name. If you rename a job later, GitHub waits forever for a check with the old name and every pull request is stuck. Rename the job and update the ruleset in the same change.
Add the publish job
The job downloads the saved image from the artifact, loads it and pushes it. Nothing is rebuilt, so what you scanned is what you ship. Add this job to the end of the jobs: section.
publish: name: Publish scanned image runs-on: ubuntu-24.04 needs: [image] if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read packages: write steps: - uses: actions/download-artifact@v4 with: name: scanned-image - name: Load, tag and push env: REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | docker load -i image.tar TARGET="ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}" docker tag "gates-demo:${GITHUB_SHA}" "${TARGET}" echo "${REGISTRY_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin docker push "${TARGET}" echo "Published ${TARGET}" >> "$GITHUB_STEP_SUMMARY"Commit and push through a pull request, merge it, and watch the publish job run on main only.
Why the image is tagged with the commit SHA
A SHA tag is immutable in practice: it always points at one build of one commit. A floating tag such as latest can silently move to a different image, which makes it hard to say what was scanned.
| Design choice | What it protects against |
|---|---|
| Publish needs the image gate | An unscanned image reaching the registry |
| Same tarball is loaded and pushed | A rebuild that differs from the scanned image |
packages: write only on this job |
Giving every job registry write access |
main push only |
Pull request code publishing images |
SecurityPull requests from forks run with a read-only token and no secrets by design. Never switch the workflow to
pull_request_targetto "fix" this, because it runs untrusted code with write access.
TipContainer registries are private by default for new packages. If you want to pull the image from another machine, sign in with
docker login ghcr.iousing a token that has theread:packagesscope.
Validation & Testing
Use this checklist to confirm the project works end to end and that you can explain it. Each item has a command or a place to look, plus the result you should see.
Local checks
# 1. Scanner versions match the pinsgitleaks version # 8.30.1trivy --version # Version: 0.75.0checkov --version # 3.3.26 # 2. Clean state passes all gates locallygitleaks git --no-banner --redact . ; echo "gitleaks: $?"trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 . ; echo "trivy fs: $?"checkov -d . --framework terraform,dockerfile \ --check CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_24,CKV_AWS_21,CKV2_AWS_6,CKV_DOCKER_2,CKV_DOCKER_3 \ --compact --quiet ; echo "checkov: $?"Expected result: every exit code is 0.
Pipeline checks
- The first run on
mainshows Gates 1 to 3 in parallel, then Gate 4, then the publish job, and the summary table lists foursuccessrows. - The package page for your repository shows an image tagged with the commit SHA.
- Each branch from Milestone 5 failed in exactly one gate, and
Gate 4: Imagewas skipped. - A pull request cannot be merged while a required gate is red.
Reflection questions
Answer these in your README. They are the questions an interviewer is likely to ask.
- Why does the pipeline scan a saved tarball and push that same file instead of rebuilding?
- Why can a deleted secret still fail the secrets gate, and what must you do first?
- What does
--ignore-unfixedtrade away, and when would you remove it? - Why pin scanner versions and verify checksums instead of using
latest?
Portfolio deliverable
Your README should contain the pipeline diagram, a screenshot of one failed gate with the finding visible, a screenshot of the passing run with the summary table, and a short list of what each gate does not catch. That honesty is what makes it credible.
Stretch goals
- Pin every
uses:line to a commit SHA and add Dependabot for GitHub Actions. - Add a nightly scheduled run so new CVEs in old code are found without a push.
- Sign the published image and verify the signature before deploying.
Common MistakeTreating a green pipeline as proof of security. These gates catch known patterns, known CVEs and listed misconfigurations. They do not find logic bugs or a missing authorization check, so keep code review and testing alongside them.
TipRe-run Test 1 to Test 3 from Milestone 5 whenever you change the workflow file. A gate that quietly stopped blocking is the most common way these pipelines fail.