Visual gallery
Unsafe vs safe, side by side
The mistakes that keep showing up in incident reports, each drawn and written next to a safer way to do the same job. Short enough to paste into a pull request comment or a team chat. For the reasoning behind each one, follow the links into the full guides.
How to read the gallery
Every pair has an Unsafe side, marked with a cross and a dashed border, and a Safer side, marked with a check and a solid border. The words and shapes carry the meaning, the red and green only repeat it. "Safer" is deliberate: each fix removes a specific risk, not every risk.
Code
Most application bugs are data crossing into something that interprets it. Injection is covered in depth in secure coding; here are three more that reach production often.
Reading a file the user names
@app.get("/files")
def download():
name = request.args["name"]
return send_file(os.path.join("/srv/reports", name))
# ?name=../../etc/passwd
# ?name=/etc/passwd (join drops the base for absolute paths)Path traversal. os.path.join is not a security check.
from flask import send_from_directory
@app.get("/files")
@login_required
def download():
name = request.args.get("name", "")
# refuses any path that resolves outside the folder
return send_from_directory("/srv/reports", name)Use the framework helper that joins safely, and still check the user may see that file.
Fetching a URL for the user
app.get("/preview", async (req, res) => {
const r = await fetch(req.query.url);
res.send(await r.text());
});
// ?url=http://169.254.169.254/latest/meta-data/iam/
// ?url=http://localhost:8080/adminServer side request forgery, now part of A01:2025. The server reaches places the user cannot, such as cloud metadata and internal admin pages.
const ALLOWED = new Set(["images.example.com", "cdn.partner.example"]);
app.get("/preview", async (req, res) => {
let u;
try { u = new URL(String(req.query.url)); } catch { return res.sendStatus(400); }
if (u.protocol !== "https:" || !ALLOWED.has(u.hostname)) return res.sendStatus(400);
const r = await fetch(u, { redirect: "error", signal: AbortSignal.timeout(5000) });
res.type("text/plain").send((await r.text()).slice(0, 100_000));
});An allow list of hosts, HTTPS only, no redirects, a timeout and a size cap. Block metadata addresses at the network too.
Reading data a client sent
import pickle
@app.post("/import")
def import_order():
order = pickle.loads(request.get_data()) # runs code on load
save(order)
return "", 204Insecure deserialization. Unpickling attacker data is remote code execution. The same goes for Java native serialization and .NET BinaryFormatter.
from pydantic import BaseModel, Field
class Order(BaseModel):
sku: str = Field(pattern=r"^[A-Z0-9-]{3,32}$")
qty: int = Field(ge=1, le=1000)
@app.post("/import")
def import_order():
order = Order.model_validate_json(request.get_data())
save(order)
return "", 204A data only format like JSON, parsed into a strict schema. Nothing in the input can run.
Dependencies
The package you install runs with your permissions, in your build and on your laptop. See dependency hygiene and the supply chain attack timeline.
Declaring dependencies
{
"dependencies": {
"acme-billing-utils": "^1.0.0",
"express": "*",
"left-pad": "latest"
}
}An unscoped internal name that anyone can publish on the public registry (dependency confusion), and ranges that accept any future release.
{
"dependencies": {
"@acme/billing-utils": "1.4.2",
"express": "5.1.0"
}
}Internal packages under a scope you own, served from your registry, exact versions for the app, and the lockfile committed. One dependency fewer, too.
Pointing the client at a registry
# a real, publish capable token, committed to git
//registry.npmjs.org/:_authToken=npm_Qx81kTz7...
# no registry line: installs come straight from the internetA publish capable token in the repository, and no gate between a new release and the build.
registry=https://packages.example.com/
//packages.example.com/:_authToken=${NPM_TOKEN}
# where your packages allow it
ignore-scripts=trueThe token comes from the environment, installs go through a registry firewall, and install scripts do not run by default.
A ForgeRepo™ instance at packages.example.com would hold new versions for a cooling off period,
scan them, keep your internal names from ever
resolving publicly, and let you pull a bad release from every consumer at once.
Pipelines
Build systems run untrusted code with trusted credentials. The full treatment, per platform, is in CI/CD pipeline security.
A pull request workflow
on: pull_request_target
permissions: write-all
jobs:
ci:
runs-on: self-hosted
steps:
- uses: actions/checkout@v7
with: { ref: "${{ github.event.pull_request.head.sha }}" }
- uses: some-org/lint-action@main
- run: npm install && npm testFork code, a write token, secrets, a persistent machine and an action that changes with every push to its main branch.
on: pull_request
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: { persist-credentials: false }
- uses: some-org/lint-action@5c7c2a1f0b9e4d3a8f6e2b1c0d9e8f7a6b5c4d3e # v2.3.1
- run: npm ci && npm testNo secrets for untrusted code, a read only token, a fresh hosted runner, pinned actions and lockfile installs. (The lint action SHA is an illustration.)
A Jenkins pipeline
node { // any executor, controller included
checkout scm
sh "npm install"
sh "curl -sL https://get.example.dev | sh"
sh "deploy --token ${env.DEPLOY_TOKEN}" // Groovy interpolation
}Runs where the credentials live, pipes the internet into a shell, and leaks the token into the command line.
pipeline {
agent { kubernetes { yamlFile 'ci/pod.yaml' } } // fresh pod per build
stages {
stage('Build') { steps { sh 'npm ci' } }
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([string(credentialsId: 'deploy', variable: 'T')]) {
sh 'DEPLOY_TOKEN="$T" deploy' // single quotes: shell reads it
}
}
}
}
}An ephemeral agent, a lockfile install, deploys only from main, and a secret handed to the tool through its environment, never through Groovy or the command line.
Secrets
Secrets leak through code, logs, images and chat. What you do in the first hour after a leak matters more than how you clean the history.
After a key was committed
git rm --cached .env
git commit -m "remove secrets"
git push
# the key is still in history, in every clone and fork,
# possibly already scraped, and still validBots scan public pushes for keys within minutes. Removing the file does not un-leak it.
# 1. revoke the key at the provider now: treat it as public
# 2. issue a new key straight into the secret manager
# 3. check the provider's logs for use of the old key
# 4. then tidy up and prevent a repeat
echo ".env" >> .gitignore
git rm --cached .env && git commit -m "stop tracking .env"
# turn on secret scanning for pushes and pull requestsRotate first, investigate second, clean up third. Rewriting history is optional once the key is dead.
Kubernetes secrets in the repository
apiVersion: v1
kind: Secret
metadata: { name: db }
data:
password: U3VtbWVyMjAyNiE= # base64 is encoding, not encryptionAnyone who can read the repository can decode it in one command.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: db }
spec:
secretStoreRef: { name: vault, kind: ClusterSecretStore }
target: { name: db }
data:
- secretKey: password
remoteRef: { key: prod/db, property: password }The repository holds a reference; the value lives in Vault or a cloud secret manager and is synced into the cluster.
Containers
A container is only as trustworthy as its base image, and only as contained as the privileges you give it.
The Dockerfile
FROM node:latest
WORKDIR /app
# copies .git, .env and everything else
COPY . .
RUN npm install
EXPOSE 3000
# runs as root, with dev dependencies
CMD npm startA base that changes under you, secrets copied in, loose installs, a big attack surface, and root.
# syntax=docker/dockerfile:1
FROM node:24-slim@sha256:<digest> AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim@sha256:<digest>
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]Pinned by digest, lockfile install, a .dockerignore for .git and .env, only runtime files in the final stage, and a non root user.
Running it
docker run -d --privileged \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 app:latestPrivileged plus the Docker socket is root on the host, and the port listens on every interface.
docker run -d --read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
--user 10001:10001 --memory 512m --pids-limit 200 \
-p 127.0.0.1:8080:8080 \
registry.example.com/app@sha256:4d1f...Read only, no capabilities, no privilege escalation, resource limits, and only reachable through the local reverse proxy.
Further reading: OWASP Docker Security Cheat Sheet and Docker's building best practices.
Infrastructure
Infrastructure as code is code: review it, scan it, and give it safe defaults. Misconfiguration is A02 in the OWASP Top 10:2025.
Admin access to a server
resource "aws_security_group_rule" "ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.app.id
}SSH open to the world, on a machine that probably also allows the old metadata service.
# no ingress rule for 22 at all: admins use SSM Session Manager
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.small"
subnet_id = aws_subnet.private.id
iam_instance_profile = aws_iam_instance_profile.ssm.name
metadata_options {
http_tokens = "required" # IMDSv2 only
}
}Private subnet, no open port, sessions through IAM with an audit trail, and IMDSv2 so an SSRF cannot simply read credentials.
A storage bucket
resource "aws_s3_bucket_acl" "reports" {
bucket = aws_s3_bucket.reports.id
acl = "public-read" # "so the partner can download it"
}Every object, current and future, readable by anyone with the bucket name.
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# share single files with presigned URLs that expirePublic access blocked at the bucket, and at the account level too. Sharing is per file and time limited.
A Kubernetes workload
spec:
containers:
- name: app
image: example/app:latest
securityContext:
privileged: trueA floating image with full access to the node.
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: registry.example.com/app@sha256:4d1f...
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
limits: { memory: 512Mi }Meets the Kubernetes "restricted" Pod Security Standard. Enforce it per namespace so the unsafe version is refused.
Many of these patterns can be caught automatically before merge. A free SAST and code review tool such as Git Code Review reads code and history for them, and OpenSSF Scorecard checks repository and workflow settings.
The dependency row, handled
ForgeRepo™ puts a checked, recorded gate between every developer, every pipeline and the public registries. Free, MIT licensed, one container.