Module 6 · CI/CD and Containers
Securing the pipeline
Review a deployment pipeline for the mistakes that let untested or malicious code reach production (broad triggers, missing dependencies, excessive permissions, unpinned third-party actions and leaked secrets), and write the fixed workflow.
About 25 minutes
The problem
A deployment pipeline holds the keys to production: it has the deploy key, it can reach the servers, and whatever it runs, runs with that power. That makes it one of the most valuable targets in a company, and one of the least reviewed.
Tallybook's old workflow has six security and safety problems in 28 lines. Each is common in real repositories.
The concept
What to check in every pipeline
| Check | Risk | Fix |
|---|---|---|
| Trigger | deploys from any branch | deploy only from main (and tags) |
| Job order | deploy runs even if tests fail | needs: test |
| Permissions | the automatic token can change anything in the repo | permissions: contents: read, widened per job only where needed |
| Third-party actions | @main runs whatever its author pushes next, with your secrets | pin to a full commit SHA, and prefer well-known actions |
| Secrets in logs | printing a secret exposes it to anyone who can read logs | never print secrets; GitHub masks them, but masking can miss multi-line values like SSH keys and any transformed copy |
| Approvals | production changes with no human gate | an environment: production with required reviewers |
Deploy artifacts, not repositories
The old workflow ran git pull and npm install on the servers. The new one deploys the image that was built, tested and scanned in the pipeline.
Example
A workflow checker, in the same spirit as the Dockerfile checker:
import re
from urllib.request import urlopen
import yaml
with urlopen("https://academy.cloudtechanalytics.com/datasets/cicd/deploy.yml") as f:
workflow = yaml.safe_load(f.read().decode("utf-8"))
def review(wf):
problems = []
trigger = wf.get("on", wf.get(True)) or {}
branches = (trigger.get("push") or {}).get("branches", [])
if any(b in ("**", "*") for b in branches):
problems.append("trigger: a push to any branch runs the workflow")
if wf.get("permissions") == "write-all":
problems.append("permissions: write-all gives the token full write access")
for name, job in wf["jobs"].items():
steps = job.get("steps", [])
if name.startswith("deploy"):
if "needs" not in job:
problems.append(f"{name}: doesn't need the test job, so it runs even if tests fail")
if "environment" not in job:
problems.append(f"{name}: no environment, so no approval before production")
for step in steps:
uses = step.get("uses", "")
if uses and not uses.startswith("actions/") and not re.search(r"@[0-9a-f]{40}quot;, uses):
problems.append(f"{name}: third-party action {uses} isn't pinned to a commit SHA")
if "run" in step and "secrets." in step["run"] and re.search(r"\becho\b", step["run"]):
problems.append(f"{name}: a step prints a secret")
return problems
for p in review(workflow):
print("-", p)- trigger: a push to any branch runs the workflow
- permissions: write-all gives the token full write access
- deploy: doesn't need the test job, so it runs even if tests fail
- deploy: no environment, so no approval before production
- deploy: third-party action quickship-dev/ssh-deploy-action@main isn't pinned to a commit SHA
- deploy: a step prints a secretSix problems, as expected. The third-party action is the most dangerous kind: quickship-dev/ssh-deploy-action@main receives the production deploy key on every run, and its author (or anyone who takes over their account) can change what it does at any time. Pinning to a commit SHA means a change requires your own pull request.
Walkthrough
- Run the cells. Order the six problems from most to least dangerous.
- Find the commit SHA format: why does
@v4count as unpinned for a third-party action? - Run
reviewon your fixed workflow (the task below). Does it pass? - List who at Tallybook should be required reviewers for the production environment.
Practice
Practice
How many problems does review find in the old workflow?
Task
12 minWrite the fixed workflow in YAML: deploy only from main, read-only default permissions, a deploy job that needs test, uses a production environment, and deploys a built image (no git pull on servers). Don't print any secret.
Your work is checked for
- Triggers only on main
- Read-only default permissions
- deploy needs test
- A production environment
- Builds or deploys an image (docker, image, registry)
- No git pull on the servers
- No echo of a secret
Check your understanding
Answer every question to check.