12 — Code you didn’t write
Chapter 11 secured the path between the browser and the app. TLS proves a script came from cdn.test. It can’t show that cdn.test is still serving the same file it served yesterday. The same gap applies on the server. The notes app is a few hundred lines of Flask, and the virtual environment it runs in holds tens of thousands of lines from Flask, Werkzeug, Jinja2, psycopg and their dependencies. None of those lines were reviewed by anyone on the team, and every one of them runs with the app’s permissions.
This chapter covers that code: where it comes from, what decides which version gets installed, what runs while it is being installed, and how to check that it is the code that was intended. Every demo ran on 127.0.0.1: two package indexes served from local folders, a local npm tarball, a throwaway git repository, and headless Chrome (157.0.8103.40) loading a script from a pretend CDN. Python 3.12, pip in a fresh virtual environment each time, Node v23.6.1 and npm 10.9.2.
What the app actually runs
The app’s requirements.txt started as four lines:
flask
psycopg
pgserver
requests
Those four names install about a dozen packages, because each of them depends on others. Nobody chose itsdangerous, blinker, MarkupSafe or certifi directly, but they run in the same process and can read the same DATABASE_URL. An unpinned name also means “whatever version the index offers at install time”. Two installs a week apart can produce two different programs, and neither the code review nor the git history shows the difference.
Pinning fixes the version. Hashing fixes the bytes. pip has a mode for this. With --require-hashes, every requirement, including the indirect ones, must name an exact version and at least one hash, and pip refuses any download whose SHA-256 doesn’t match:
notes-utils==1.0.0 --hash=sha256:0e5b790b8ff7763f022090eba63fc50333715abbb231b6a4749b6ad0d0c31809
Tools such as pip-compile (from pip-tools) or uv pip compile produce a file like this from the short list, with every indirect package written out. npm writes the same kind of record by default: package-lock.json stores, for each package, where it came from and an integrity value. The demo project below got this entry:
"resolved": "file:../evil/notes-color-utils-1.0.0.tgz",
"integrity": "sha512-b9oNjocYFic/pi4w7Kf+yfMyoEIWfdhfbQJYMFjave1SkhMN2l/d7w+Cqx7/urmPkoO2JQP4MXNPWccWdHJBoA==",
npm ci installs exactly what the lockfile says and fails if package.json and the lockfile disagree. npm install may update the lockfile instead. In CI, the first behavior is the useful one.
A lockfile records what was installed the first time. It doesn’t say whether that first install was the right code. The rest of the chapter is about the ways it can go wrong before the lockfile is ever written.
A name that isn’t yours
Package names on a public index are first come, first served. Two problems follow from that.
The first is typosquatting: a package published under a name one keystroke away from a popular one, or with a hyphen where the real one has an underscore, waiting for someone to mistype an install command. The index doesn’t know which of two similar names was meant. The defense is boring: copy names from the project’s own documentation, and treat any new dependency in a pull request as something to look at, not just the version bumps.
The second problem is subtler, and the notes app’s team walked into it. They had split the text helpers into an internal package, notes-utils, served from a private index. Their install command looked like this:
pip install --index-url http://127.0.0.1:5406/simple \
--extra-index-url http://127.0.0.1:5407/simple \
notes-utils
Port 5406 stands in for the private index, holding notes-utils 1.0.0 as a wheel. Port 5407 stands in for the public one, where the team also fetches everything else. Nobody had registered notes-utils there. For this demo, the public index was given a package with the same name and version 99.0.0, published as a source archive whose setup.py writes a line to a marker file when it runs. It writes the time, the user, and the names (not the values) of environment variables ending in TOKEN, KEY or SECRET. The install ran with a fake GITHUB_TOKEN in the environment, the way a CI job often has one.
The result, from a fresh virtual environment:
### A: both indexes, unpinned
installed: 99.0.0 (public look-alike)
marker:
2026-11-10T09:15:26 user=ash cwd=/private/tmp/pip-install-0rbsbdwr/notes-utils_fe45... env vars visible=12 e.g. ['GITHUB_TOKEN']
2026-11-10T09:15:27 user=ash cwd=/private/tmp/pip-install-0rbsbdwr/notes-utils_fe45... env vars visible=12 e.g. ['GITHUB_TOKEN']
2026-11-10T09:15:27 user=ash cwd=/private/tmp/pip-install-0rbsbdwr/notes-utils_fe45... env vars visible=12 e.g. ['GITHUB_TOKEN']
pip installed the public 99.0.0, and the look-alike’s code ran three times before anything imported it: building a source archive runs setup.py once to get its build requirements, once to get its metadata, and once to build the wheel. Each time it saw the token.
This isn’t a pip bug. pip’s documentation says it treats all configured indexes as equal sources with no priority between them. It gathers every candidate for a name from all of them and picks the highest version that satisfies the requirement. Asking for notes-utils with no version means any version, and 99.0.0 beats 1.0.0. This attack is called dependency confusion. It needs only the name of an internal package, and those names leak through error messages, public repositories and job postings.
Four changes were tested, each in a fresh environment against the same two indexes:
### B: --only-binary :all:
installed: 1.0.0 (internal)
marker: none
### E: internal index only
installed: 1.0.0 (internal)
marker: none
### C: pinned with hash, both indexes
installed: 1.0.0 (internal)
marker: none
### D: version bumped to 99.0.0, hash still the internal one
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. ...
notes-utils==99.0.0 from http://127.0.0.1:5407/simple/notes-utils/notes_utils-99.0.0.tar.gz#sha256=2071...:
Expected sha256 0e5b790b8ff7763f022090eba63fc50333715abbb231b6a4749b6ad0d0c31809
Got 20710961cac73ccbeadefb7f658454ffc336892e147a424d13e65658478c624f
marker: none
Each one works for a different reason, and only some of them are real fixes.
--only-binary :all:(B) refuses source archives, so there was nosetup.pyto run and the only wheel on offer was the internal one. That worked here only because the look-alike happened to be a source archive. A look-alike published as a wheel would have been installed, and its code would run the first time the app imported it. Refusing source archives removes code that runs at install time, but it doesn’t make a package trustworthy.- One index (E) removes the ambiguity. The private index serves the internal packages and proxies the public ones, so the client only ever asks one server, and that server decides which source owns which name. Most private registry products work this way. uv’s documentation describes a related default: it stops at the first index that has a package rather than comparing versions across all of them.
- Hashes (C, D) are the strongest of the four. With a pinned version and a hash, the bytes are fixed. When the pin was changed to 99.0.0 but the hash still described the internal wheel, pip downloaded the public archive, computed its hash, and refused it before running anything. The marker file stayed empty.
- Claiming the name. Registering the internal names on the public index, or using a namespace the organization controls (npm scopes such as
@notes/utilsbelong to one account), closes the gap from the other side.
npm’s equivalent of the multi-index mistake is a scope that isn’t mapped to the private registry in .npmrc. A scope mapped to a registry is fetched only from that registry.
Code that runs during install
The marker in run A was written before any of the package was imported. npm has the same property through lifecycle scripts. A package can declare a postinstall script, and npm runs it, as the installing user, when the package is installed. A local tarball stood in for a dependency with this package.json:
{
"name": "notes-color-utils",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"postinstall": "node -e \"require('fs').appendFileSync(process.env.INIT_CWD + '/POSTINSTALL_RAN.txt', 'postinstall ran as ' + require('os').userInfo().username + ' in ' + process.cwd() + '\\n')\""
}
}
A project that depends on it, installed offline twice:
$ npm install --offline
added 1 package, and audited 2 packages in 347ms
$ cat POSTINSTALL_RAN.txt
postinstall ran as ash in .../npm/t1/node_modules/notes-color-utils
$ npm install --offline --ignore-scripts
$ cat POSTINSTALL_RAN.txt
cat: POSTINSTALL_RAN.txt: No such file or directory
Both installs reported “found 0 vulnerabilities”. npm audit compares installed versions against a database of published advisories. A package nobody has reported yet has no advisory, so audit has nothing to say about it.
--ignore-scripts (or ignore-scripts=true in .npmrc) stops lifecycle scripts for every package in the tree. The cost is that a few packages genuinely need one, usually to build a native module or download a binary, and they then fail or need their script run by hand. That is a short, reviewable list. The alternative is running every package’s install code without looking at it.
Skipping install scripts doesn’t make the package safe. Its code still runs when the app imports it, but at least that happens in the app’s runtime, where the other limits from this track apply, not on a developer laptop or a CI runner holding deploy credentials.
The script from someone else’s server
The app’s pages load one third-party script, a small widget from a CDN. The tag carries chapter 5’s nonce, so the policy trusts whatever arrives at that URL. If the CDN account is compromised, or its storage bucket is misconfigured, whatever it serves at that URL runs on every page that loads it, with access to the page’s DOM and its non-HttpOnly cookies.
Subresource Integrity puts the hash in the page. The browser fetches the file, hashes it, and runs it only if the digest matches:
<script src="https://cdn.test:5404/widget.js"
integrity="sha384-4IOwyGnUL2WLkgRXDJPMmj48NpBwSU4gKoWjYRgj2Rcr6nGJQpAackZgFDulC37O"
crossorigin="anonymous"></script>
The digest is the base64 SHA-384 of the exact file:
openssl dgst -sha384 -binary widget.js | openssl base64 -A
The test page loaded the widget three ways: with the hash and crossorigin, with the hash but no crossorigin attribute, and with both but from a URL whose server sends no CORS header. Chrome’s log for the original file:
-> GET https://cdn.test:5404/widget.js
-> GET https://cdn.test:5404/widget-nocors.js
console.log: widget v1.4.2 ran (A: hash + crossorigin)
Access to script at 'https://cdn.test:5404/widget-nocors.js' from origin 'https://notes.test:5402'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Subresource Integrity: The resource 'https://cdn.test:5404/widget.js' has an integrity attribute,
but the resource requires the request to be CORS enabled to check the integrity, and it is not.
The resource has been blocked because the integrity cannot be enforced.
Only the first version ran. The other two failed even though the file was correct, and the reason is in chapter 6. A cross-origin response is opaque to the page unless the request is made in CORS mode and the server agrees to it. The browser won’t hash a body it isn’t allowed to read, so integrity on a cross-origin script needs crossorigin="anonymous" on the tag and Access-Control-Allow-Origin from the server. Without either, the browser blocks the script rather than skipping the check.
Then one line was appended to widget.js on the CDN, a fetch that would send document.cookie to a collector address:
-> GET https://cdn.test:5404/widget.js
Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.test:5404/widget.js'
with computed SHA-384 integrity 'rLFeEq7bbJg3oESMJawGz1dBTc3TREmlGXJmo/3iMTuwj06UIBpW93KpIvkMwsB0'.
The resource has been blocked.
The script didn’t run, and no request reached the collector. The page lost its widget, which is the right failure: a missing widget is visible and harmless, while a changed one isn’t visible at all.
SRI fits files that don’t change: a versioned URL such as widget@1.4.2.js. A URL the vendor updates in place breaks the page on every update, and teams in that situation usually remove the attribute. Hosting the file on the app’s own origin removes the third party entirely and lets CSP drop the CDN from its allowlist. That trade-off usually favors self-hosting.
Who built this package?
A hash says the bytes are the same as the bytes recorded earlier. It doesn’t say where those bytes came from. A maintainer’s laptop, a stolen token, or the project’s CI could all have uploaded the same version. Provenance tries to answer that.
The pieces fit together like this. A CI system, typically GitHub Actions, gets a short-lived identity token from its own OpenID Connect provider (the same mechanism as the tokens in the JWT essay). That identity names the repository, the workflow file and the commit. The package registry is configured to trust that identity for a given package (“trusted publishing”), so no long-lived upload token exists to be stolen. At build time, Sigstore issues a short-lived signing certificate for that identity, the build signs a statement saying “this artifact, with this digest, was built from this repository at this commit by this workflow”, and the signature is recorded in a public transparency log. That log works much like the certificate transparency logs from chapter 11.
npm shows this as provenance on a package version (npm publish --provenance from a supported CI), and npm audit signatures checks the registry signatures and provenance attestations of the installed tree. PyPI supports trusted publishing and, under PEP 740, attestations that are compatible with Sigstore bundles. The SLSA framework gives names to levels of build integrity, from “there is provenance” up to “the build ran on a hardened, isolated builder”.
Provenance doesn’t say the code is good. A malicious maintainer publishing through the project’s real CI gets valid provenance. It narrows the question from “could anyone have uploaded this?” to “did this repository’s workflow build this?”, and that question can be checked automatically.
The secret that was deleted
The notes app’s first commit had its settings inline. A later commit moved them into environment variables. In a throwaway repository:
$ git log --oneline
4cd17e6 Move secrets to environment
b996fdb Add settings
$ grep -rn AKIA . --exclude-dir=.git
(nothing)
The working tree is clean. The history isn’t. A small scanner over git log -p --all, with four patterns (AWS access key IDs, GitHub tokens, private key headers, PostgreSQL URLs with a password), found:
history: b996fdb6 Postgres URL with password: postgresql://notes:hunte...
history: b996fdb6 AWS access key ID: AKIAIOSFODNN7EXAMPLE...
hits in history: 2
$ git log --oneline -S AKIAIOSFODNN7EXAMPLE
4cd17e6 Move secrets to environment
b996fdb Add settings
(AKIAIOSFODNN7EXAMPLE is the example key from AWS’s own documentation.) git log -S lists every commit that changed how many times a string appears, so it shows both the commit that added the key and the commit that removed it. Anyone with a clone, or with read access to the repository at any time since, has the key.
The scanner missed one thing. The 40-character AWS secret access key on the line below the ID has no fixed prefix, so a pattern can’t tell it from any other random-looking string. Real scanners pair it with the ID nearby, measure entropy, or ask the provider whether the credential is live. Prefixed formats such as GitHub’s ghp_ exist so that scanners can find them reliably.
Finding secrets after the fact is the worse half. The same scanner as a pre-commit hook, run against the staged diff:
$ git commit -m "Add deploy script"
staged: staged GitHub token: ghp_aBcDeFgHiJkLmNoPqRsT...
commit refused, exit 1
A local hook is advisory, because anyone can skip it with --no-verify or never install it. Server-side checks such as GitHub’s push protection reject the push itself, which is harder to get around by accident.
When a secret does reach a repository, rewriting history (git filter-repo) is cleanup, not the fix. Forks, clones, CI caches and the hosting service’s own copies may still have the old commits. The fix is to revoke the credential and issue a new one, then check the provider’s logs for its use in between. For the database URL above, that means changing the notes role’s password in PostgreSQL and every place that held the old one.
The pipeline’s own permissions
Everything above runs somewhere, and the place it usually runs is CI. A CI job checks out code, installs dependencies (running their install scripts), runs tests (importing every package) and often deploys. Whatever credentials that job holds are available to all of that code, including the GITHUB_TOKEN the look-alike package saw in run A.
The controls are plain least privilege, applied to the pipeline:
- Token scope. In GitHub Actions, a top-level
permissions:block sets what the automaticGITHUB_TOKENcan do.contents: readfor a test job, with write access granted only to the one job that needs it. The repository or organization default can also be set to read-only. - Separate jobs for separate trust. The job that installs and tests untrusted pull-request code shouldn’t be the job that holds deploy secrets. Workflows triggered by pull requests from forks don’t receive the repository’s secrets by default, and
pull_request_target, which runs with them, is the trigger to be most careful with. - Pinned actions.
uses: some/action@v3follows a tag that the action’s owner can move. Pinning to a full commit SHA makes the reference immutable, which is the lockfile idea applied to CI steps. - Short-lived credentials. OIDC federation lets a job exchange its identity token for cloud credentials that expire within the hour, scoped to that repository and branch, in place of a long-lived key stored as a secret.
None of these were tested against a real CI system for this chapter. They come from GitHub’s Actions security-hardening and token-permissions documentation, and they follow from the install-time result above: any code that runs in the job can do whatever the job is allowed to do.
The app’s journey
The notes app started in chapter 1 as a Flask app on one port that trusted every request it got. It now:
- issues a
__Host-session cookie that is Secure, HttpOnly and SameSite, and rotates it on login (chapter 2); - checks a synchronizer token and Fetch Metadata on every state change (chapter 3);
- escapes output by context, with no
|safeon user data (chapter 4), under a nonce-based CSP with Trusted Types (chapter 5); - answers cross-origin requests only from an exact list of origins (chapter 6), and refuses to be framed while sending a full set of response headers (chapter 7);
- passes every value to PostgreSQL as a parameter and never builds code from input (chapter 8);
- fetches avatar URLs only after resolving and pinning a public address (chapter 9);
- checks ownership on every object, accepts only allowlisted fields, and answers login and reset requests the same way whether or not the account exists (chapter 10);
- runs over TLS with HSTS, CAA and a clean DNS inventory (chapter 11);
- and installs hash-pinned dependencies from one index, without install scripts, from a CI job that holds only the permissions it needs (this chapter).
Most of those fixes were a few lines each. Each hole was found by doing what an attacker would do, against the app’s own copy, and watching what actually happened. The browser often behaved differently from what its documentation implied, and the fixes held only after they were tested against a real request.
The OWASP Top 10 as a map
The OWASP Top 10 is a ranking of web application risk categories, built from contributed vulnerability data and a community survey. Its 2025 edition, as listed on top10.owasp.org, maps back onto this track:
| Category | Where it came up | |
|---|---|---|
| A01 | Broken Access Control | 10 (IDOR, mass assignment, missing admin check), 3 (CSRF), 6 (CORS), 9 (SSRF), path traversal in 8 |
| A02 | Security Misconfiguration | Cookie flags in 2, CSP in 5, response headers in 7 |
| A03 | Software Supply Chain Failures | This chapter |
| A04 | Cryptographic Failures | TLS and certificates in 11, reset tokens in 10 |
| A05 | Injection | XSS in 4, SQL, command and template injection in 8 |
| A06 | Insecure Design | The guessable reset token and the trusted Host header in 10 |
| A07 | Authentication Failures | Session fixation in 2, enumeration and throttling in 10; second factors in the passkeys essay |
| A08 | Software or Data Integrity Failures | Pickle in 8, Subresource Integrity and hashes in this chapter |
| A09 | Security Logging and Alerting Failures | CSP violation reports in 5; otherwise not covered here |
| A10 | Mishandling of Exceptional Conditions | The duplicate-key 500 from the reset endpoint in 10; otherwise not covered here |
The categories are broad and overlap, and the A01 page now lists SSRF among its weaknesses. The list is useful as a coverage check, a way to ask which kinds of hole an app hasn’t been tested for. It isn’t a definition of secure. Two of the ten (logging and error handling) got little attention in this track, and a real app would need its own chapters for them.
Where this leaves things
The app now has a recorded, checkable answer for every piece of code it runs. Its own code was reviewed and attacked chapter by chapter. Its Python dependencies are pinned by hash and installed from one index, so a look-alike from a second index is refused before it can run. Its JavaScript dependencies install from a lockfile without lifecycle scripts. The one third-party browser script either matches its hash or doesn’t run. The repository history has been scanned, the leaked credentials revoked, and a hook and push protection stop new ones. The CI job that builds all of this has only the permissions it uses.
That closes the track. None of these defenses is permanent. A new dependency, a new header, a new endpoint or a new browser release can reopen any of these holes, and the way to find out is the one used throughout: attack a local copy of the app the same way, and watch what happens.