
A JavaScript file on your origin is a public map of endpoints, tokens, and third-party hosts.
Bug bounty hunters watch those files because a new bundle can expose an admin route or a forgotten key. You should watch them for the same reason, plus supply-chain: a compromised package changes the file without a feature commit.
The usual mistake is only monitoring uptime and never hashing the shipped JS.
This page is what to watch, the npm incidents that changed popular files overnight, and the difference between a bounty recon trick and a deploy check.
chalk@5.6.1 appeared on npm on 8 September 2025. chalk issue 656 and the BleepingComputer story dated that day. sindresorhus wrote that the bad versions “were available for approximately 2 hours.” A first-party bundle that silently grew a new <script src> the same week is the other miss. Neither is a reason to teach hunters how to crawl you.
The control is an inventory you authored, a hash you compare, a lockfile you refuse to rewrite on the build agent, and a header that refuses a script you did not name. Keep the vulnerable versus malicious dependencies page next to this one for a hostile tarball. Read XSS for a sink in the page. Read Helmet for the header pack.
The files you ship are the watch list
Hunters watch your JavaScript because it is a public map of your app: routes, feature flags, third-party pixels, leftover admin hosts. You should watch it first, for the same reason, as the owner. The list is short if you write it down.
- Every
<script src>on the documents you serve. - Every worker and import the bundler emits next to those files.
- Every package name in
package-lock.jsonthat will run in the browser or at install time. - Every CDN host you still allow in CSP.
That list is the inventory. A URL a crawler finds that is not on it is either a forgotten asset or a host you do not own. Treat both as a release incident. Do not start from someone else’s wordlist. Start from the HTML your origin returned this morning.
FIRST build emits /assets/app.3f2c.js CI records sha384 deploy only if the live file matches LOCK npm ci from committed package-lock.json no floating npm install on the agent CDN vendor.js + integrity=sha384-... browser refuses a swapped tarball
Obfuscation is not on that diagram. A minifier shortens names. It does not stop a swapped CDN file, a hijacked chalk tarball, or an API key you compiled in. Skip Closure Compiler as a security control. Keep it as a size tool if you already use it.
Hash first-party bundles on every release
Your own /assets/app.<hash>.js is the file an attacker most wants to replace: a dirty build agent, a writable object store, a forgotten leftover on a second CDN. Content-addressed names help only if the HTML that points at them is also the HTML you just built. A long-lived app.js with no hash in the name is the miss.
writeAssetManifest is the named helper the build runs. It lists each file the document will request and the sha384 of the bytes you are about to upload.
const { createHash } = require("node:crypto");
const { readdirSync, readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
function sriFor(buf) {
const digest = createHash("sha384").update(buf).digest("base64");
return `sha384-${digest}`;
}
function writeAssetManifest(dir, outFile) {
const entries = {};
for (const name of readdirSync(dir)) {
if (!name.endsWith(".js") && !name.endsWith(".css")) continue;
entries[`/assets/${name}`] = sriFor(readFileSync(join(dir, name)));
}
writeFileSync(outFile, JSON.stringify(entries, null, 2));
return entries;
}
Commit the manifest next to the release, or store it as a build artifact the deploy job must fetch. The next build compares. A changed hash on a file whose source did not change is a stop. A new path that the HTML now names must appear in the manifest before deploy. A path that left the HTML must leave the object store so a hunter’s old URL 404s.
Watch production the same way. Fetch the live document, collect script[src] on your origin, fetch each file, hash it, compare to the release manifest. That is JSmon’s job, pointed at files you already own, not at a third-party target. A Telegram bot is optional. A failing deploy job is the control.
const { readFileSync } = require("node:fs");
async function assertLiveMatchesManifest(origin, manifestFile) {
const expected = JSON.parse(readFileSync(manifestFile, "utf8"));
const html = await fetch(origin).then((res) => res.text());
const srcs = [...html.matchAll(/<script[^>]+src="([^"]+)"/g)].map((m) => m[1]);
for (const src of srcs) {
const url = new URL(src, origin);
if (url.origin !== new URL(origin).origin) continue;
const buf = Buffer.from(await fetch(url).then((res) => res.arrayBuffer()));
const seen = sriFor(buf);
if (expected[url.pathname] !== seen) {
throw new Error(`hash drift ${url.pathname}`);
}
}
}
assertLiveMatchesManifest skips cross-origin scripts. Those are the SRI section. It fails closed when a first-party path is missing from the manifest. Wire it to a five-minute cron if you want, or to the post-deploy hook. Do not wait for an external reporter to notice app.js changed at 02:00.
Pin the lockfile, then npm ci
A floating npm install on the agent rewrites the lockfile to whatever the registry just published. That is how a hijacked maintainer becomes your runtime. npm ci installs the tree you already reviewed and fails if package.json and the lockfile disagree. Commit package-lock.json. In CI, ignore scripts until a package earns them.
npm ci --ignore-scripts
# Expect: exit 0 against the committed lockfile
# Expect: non-zero if package.json drifted
Review an unexpected version bump the way you review a pull request that touches auth. The 8 September 2025 window lasted about two hours. A CVE scanner stayed quiet. The longer split between a known advisory and a fresh malicious publish is the dependencies page. This page’s job is the install line and the review.
The same pin applies to a lockfile you generate for pnpm or yarn. The command changes. The rule does not: the agent does not resolve a range. It materializes a tree you stored.
SRI on every script you do not compile
Subresource Integrity is a browser check. You name the digest. The browser hashes the response. A mismatch refuses to run the script. First-party hashed filenames already cover your origin. SRI is for the script you still load from a host you do not deploy: an analytics snippet, a payment.js, a font runtime you have not vendored yet.
function scriptTag(src, buf) {
const integrity = sriFor(buf);
return `<script src="${src}" integrity="${integrity}" crossorigin="anonymous"></script>`;
}
scriptTag uses the same sriFor helper. Download the vendor file in CI, hash the bytes you got, write the tag. When the vendor publishes a new file, the hash changes, the tag update is a pull request, and you read the diff. A silent swap on the CDN then fails in the browser instead of running.
crossorigin="anonymous" is required for SRI on a cross-origin URL. Without it, Chrome will not apply the digest. Do not add a host to CSP unless the tag also carries integrity. A bare <script src="https://cdn.example/vendor.js"> is an unsigned update channel.
Prefer vendoring. Copy the file into /assets, hash it with the rest of the manifest, and drop the extra origin from CSP. SRI is the leftover when a contract still forces a remote URL.
CSP is the belt around both
A hash list and SRI stop swapped bytes. CSP stops a script tag that should never have been in the document: an XSS sink, an injected pixel, a forgotten inline bootstrap. Helmet 8 on Express is the header pack this site already documented. The policy you want for a page that serves first-party files plus a nonce is narrow.
const { randomBytes } = require("node:crypto");
const helmet = require("helmet");
function assignCspNonce(req, res, next) {
res.locals.cspNonce = randomBytes(32).toString("hex");
res.set("Cache-Control", "no-store");
next();
}
function nonceSrc(req, res) {
return `'nonce-${res.locals.cspNonce}'`;
}
app.use(assignCspNonce);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", nonceSrc],
styleSrc: ["'self'", nonceSrc],
objectSrc: ["'none'"],
baseUri: ["'self'"],
connectSrc: ["'self'"],
},
},
}),
);
Mint cspNonce on the response, never at module load. Do not sit a nonce next to 'unsafe-inline'. Do not list a CDN host you have not hashed. connect-src 'self' is the starting point. Add an API origin you own when the browser must call it. The Helmet page is the longer header argument. This page’s job is to point the policy at the inventory.
Keep minted secrets out of the compile
The fix is not a hunter toolkit. The fix is to keep those values off the compile. A VITE_ or NEXT_PUBLIC_ prefix is a public value. A private API key, a session secret, or a cloud token does not belong in src/.
const NEEDLES = [
"AKIA",
"BEGIN PRIVATE KEY",
"sk_live_",
"xoxb-",
"SECRET_KEY",
];
function assertBundleHasNoSecrets(buf) {
const text = buf.toString("utf8");
const hits = NEEDLES.filter((n) => text.includes(n));
if (hits.length) {
throw new Error(`bundle leaked ${hits.join(",")}`);
}
}
assertBundleHasNoSecrets runs on the same bytes writeAssetManifest hashed. Expand NEEDLES with prefixes you actually issue. A false positive on a docs page that mentions SECRET_KEY is cheaper than a live token in app.js. Source maps you upload to a crash reporter are a second copy of the same tree. Restrict who can read them. Do not put them on the public origin next to the bundle.
Prove the hash, the pin, and the header
You are not walking an exploit. You are proving the live document names only files in the manifest, the install line is npm ci, and the CSP on your origin carries a nonce that changes per response.
# lockfile is the tree you reviewed
npm ci --ignore-scripts
# Expect: exit 0
# live first-party files match the release manifest
node scripts/assert-live.mjs https://app.example./dist/manifest.json
# Expect: no hash drift
# two responses disagree on the nonce
curl -sS -D - -o /tmp/h1.html "https://app.example/" | sed -n "s/.*nonce-\\([a-f0-9]*\\).*/\\1/p"
curl -sS -D - -o /tmp/h2.html "https://app.example/" | sed -n "s/.*nonce-\\([a-f0-9]*\\).*/\\1/p"
# Expect: two different hex strings
Grep the hatches this page named:
rg -n "npm install$|integrity=|unsafe-inline|cachedNonce|<script src=\\\"https?:" \
--glob '!node_modules' --glob '!dist'
A bare remote <script src> that lacks integrity needs a human. So does npm install in a CI file, and unsafe-inline sitting next to a nonce. A hit on integrity= is the path you want.
Questions we keep getting
Does minifying the bundle hide it from a hunter?
No. Anyone can fetch app.js. Minify for size. Do not treat shorter names as a lock. Hash the bytes, pin the lockfile, and keep secrets off the compile.
Do I need SRI on first-party hashed filenames?
A content-addressed name plus a manifest check is the lock on your origin. SRI on that same tag is extra if the HTML is not cached separately from the file. Put SRI on every cross-origin script you still load.
Is a CSP enough if I never hash the files?
No. CSP names who may run. It does not notice that /assets/app.js changed at 02:00. The manifest does. Use both.



