Failing CI on SRI Hash Drift
Permalink to "Failing CI on SRI Hash Drift"Part of CI/CD Integrity Gates, this page builds a check that re-downloads every externally hosted subresource your pages reference, recomputes its digest, compares it against the committed integrity attribute, and exits non-zero the moment the two disagree.
Quick Reference
Permalink to "Quick Reference"| Control | Value | Effect |
|---|---|---|
Exit 0 |
every digest matched | Gate passes, no output beyond the table |
Exit 1 |
at least one digest mismatched | Build fails; classified as bump or tampering |
Exit 2 |
origin unreachable after retries | Workflow warning only, build still passes |
--baseline <ref> |
e.g. origin/main |
Compares the pair against the base branch to classify drift |
| Retry policy | 4 attempts, exponential backoff | Retries transport errors and 408 425 429 500 502 503 504 |
| Request timeout | AbortSignal.timeout(15_000) |
Caps a single attempt so the job cannot hang |
| Hash algorithm | strongest token in the attribute | Mirrors the browser’s own algorithm selection |
| Schedule | cron every six hours |
Catches drift that no pull request would trigger |
The mental model
Permalink to "The mental model"An integrity attribute is a promise about bytes you do not control. When you write sha384-… next to a CDN URL you are asserting that the file at that URL, right now and forever, hashes to that value. Your build system never verifies this. It cannot: the file lives on someone else’s infrastructure, and the only party that ever checks the claim is the visitor’s browser, several weeks later, at which point a failed check is a blank page rather than a red pipeline.
Hash drift is the gap between that committed promise and reality. It has exactly two causes, and separating them is the whole point of the gate. The benign cause is a developer bumping a library version, changing the URL, and pasting a digest that was computed from the wrong file or from a cached copy. The malicious cause is a file changing under a URL that nobody edited — a compromised CDN account, a hijacked npm publish that a @latest redirect picks up, an edge transform that started injecting a tag. The first is a normal review comment. The second is an incident. A check that treats both as “build failed” trains everyone to ignore it.
The mechanics are the same ones the browser runs, and understanding them is the difference between a gate that works and one that produces false alarms. The browser fetches the resource, waits for the complete body, decodes any content encoding, hashes the result with the strongest algorithm named in the attribute, base64-encodes it, and compares that string against every token of that algorithm in the attribute. A match on any one token is a pass. Everything the checker does is a reimplementation of that sequence outside the browser, which is why a mismatch it reports is a mismatch your users would have hit. If you need to trace one back to a root cause, Debugging SRI Hash Mismatch Errors covers the console-side view of the same failure.
Canonical example: the drift checker
Permalink to "Canonical example: the drift checker"The script below is the whole gate. It takes a list of HTML files, pulls out every <script> or <link> tag that carries both an integrity attribute and an absolute URL, downloads each one, hashes it, and prints a table. With --baseline origin/main it also reads the previous revision of each file so it can say whether a mismatching pair was already in the base branch.
// scripts/check-sri-drift.mjs
// usage: node scripts/check-sri-drift.mjs [--baseline <git-ref>] <html files...>
// exit 0 = all digests matched, 1 = drift, 2 = an origin was unreachable
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
const RANK = { sha512: 3, sha384: 2, sha256: 1 };
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
const ATTEMPTS = 4;
const TIMEOUT_MS = 15_000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function extract(html) {
const found = [];
for (const [tag] of html.matchAll(/<(?:script|link)\b[^>]*>/gi)) {
const attrs = {};
for (const [, name, value] of tag.matchAll(/([a-zA-Z-]+)\s*=\s*"([^"]*)"/g)) {
attrs[name.toLowerCase()] = value;
}
const url = attrs.src ?? attrs.href;
if (!attrs.integrity || !url || !/^https?:\/\//i.test(url)) continue;
found.push({ url, integrity: attrs.integrity.trim().replace(/\s+/g, ' ') });
}
return found;
}
function strongest(integrity) {
const tokens = integrity.split(' ').map((t) => t.split('?')[0]).filter(Boolean);
let best = null;
for (const token of tokens) {
const algo = token.slice(0, token.indexOf('-')).toLowerCase();
if (RANK[algo] && (!best || RANK[algo] > RANK[best])) best = algo;
}
if (!best) return null;
return { algo: best, expected: tokens.filter((t) => t.toLowerCase().startsWith(`${best}-`)) };
}
async function fetchBytes(url) {
let lastError = null;
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
if (attempt > 1) {
await sleep(500 * 2 ** (attempt - 2) + Math.floor(Math.random() * 250));
}
let res;
try {
res = await fetch(url, {
redirect: 'follow',
headers: { 'accept-encoding': 'identity', 'user-agent': 'sri-drift-check' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
lastError = err; // DNS failure, TLS reset, socket timeout
continue;
}
if (res.ok) return Buffer.from(await res.arrayBuffer());
lastError = new Error(`HTTP ${res.status}`);
if (!RETRYABLE.has(res.status)) break; // a 404 will not fix itself
}
throw new Error(`unreachable after ${ATTEMPTS} attempts: ${lastError.message}`);
}
const argv = process.argv.slice(2);
const files = [];
let baseline = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--baseline') baseline = argv[++i];
else files.push(argv[i]);
}
const pairs = new Map();
for (const file of files) {
for (const ref of extract(await readFile(file, 'utf8'))) {
pairs.set(`${ref.url}�${ref.integrity}`, ref);
}
}
const inBaseline = new Set();
if (baseline) {
for (const file of files) {
let previous;
try {
previous = execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' });
} catch {
continue; // file is new on this branch
}
for (const ref of extract(previous)) inBaseline.add(`${ref.url}�${ref.integrity}`);
}
}
const rows = [];
let mismatches = 0;
let tampering = 0;
let unreachable = 0;
for (const [key, ref] of pairs) {
const picked = strongest(ref.integrity);
if (!picked) {
rows.push([ref.url, 'SKIP', 'no recognised algorithm in attribute']);
continue;
}
let bytes;
try {
bytes = await fetchBytes(ref.url);
} catch (err) {
unreachable++;
rows.push([ref.url, 'UNREACHABLE', err.message]);
continue;
}
const actual = `${picked.algo}-${createHash(picked.algo).update(bytes).digest('base64')}`;
if (picked.expected.includes(actual)) {
rows.push([ref.url, 'OK', actual]);
continue;
}
mismatches++;
const settled = baseline ? inBaseline.has(key) : true;
if (settled) tampering++;
rows.push([ref.url, settled ? 'TAMPERING' : 'BUMP', `computed ${actual}`]);
}
const width = rows.reduce((max, row) => Math.max(max, row[0].length), 3);
console.log(`${'URL'.padEnd(width)} ${'STATUS'.padEnd(11)} DETAIL`);
for (const [url, status, detail] of rows) {
console.log(`${url.padEnd(width)} ${status.padEnd(11)} ${detail}`);
}
if (tampering > 0) {
console.error(`\n${tampering} subresource(s) changed under a stable URL`);
process.exit(1);
}
if (mismatches > 0) {
console.error(`\n${mismatches} integrity attribute(s) do not match the file they point at`);
process.exit(1);
}
if (unreachable > 0) {
console.error(`\n${unreachable} URL(s) unreachable after ${ATTEMPTS} attempts`);
process.exit(2);
}
console.log('\nAll external subresources match their committed digests');
Point it at a page that references a pinned CDN build:
<script src="https://cdn.example.com/[email protected]/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Keeping the gate quiet
Permalink to "Keeping the gate quiet"A check that hits the public internet on every pull request will meet packet loss, TLS resets and rate limits. If those surface as build failures, the gate is dead within a fortnight because everyone learns to press retry without reading. Three rules keep it honest. Bound every attempt with a request timeout so a hung socket cannot stall the job. Retry only transport errors and the status codes that describe a temporary condition — a 404 or 403 means the URL is wrong, not busy, and repeating it wastes ninety seconds. And never, under any circumstance, retry a digest mismatch: the bytes arrived intact, they simply were not the bytes you committed, and a second download of the same wrong file proves nothing.
The workflow
Permalink to "The workflow"Two triggers matter. Pull requests catch a stale hash before it merges, and a schedule catches the case no pull request can: a file that changed on the CDN while your repository sat untouched. The schedule is the half that finds real attacks.
# .github/workflows/sri-drift.yml
name: sri-drift
on:
pull_request:
paths:
- '**/*.html'
- 'scripts/check-sri-drift.mjs'
schedule:
- cron: '23 */6 * * *'
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
drift:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Compare committed digests against live bytes
id: check
run: |
set +e
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch --no-tags origin "${{ github.base_ref }}"
BASE="--baseline origin/${{ github.base_ref }}"
fi
node scripts/check-sri-drift.mjs $BASE $(git ls-files '*.html') | tee report.txt
echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Warn on unreachable origins
if: steps.check.outputs.code == '2'
run: |
echo "::warning::an external origin was unreachable after 4 attempts"
cat report.txt
- name: Fail, and page a human on tampering
if: steps.check.outputs.code == '1'
env:
GH_TOKEN: ${{ github.token }}
run: |
if grep -q TAMPERING report.txt; then
gh issue create \
--title "SRI drift: subresource changed under a stable URL" \
--label security \
--body-file report.txt
else
echo "::error::integrity attribute does not match the file it points at"
fi
exit 1
The step that runs the checker never fails on its own. It captures the exit code with PIPESTATUS and writes it to GITHUB_OUTPUT, and the two follow-up steps branch on it. That is what lets exit 2 degrade to a warning while exit 1 still turns the job red. Swap gh issue create for a curl to your paging provider’s events endpoint if an issue is too quiet for your on-call rotation.
Reading the verdict
Permalink to "Reading the verdict"The classification rule is narrow on purpose. The checker keys on the (url, integrity) pair, not the URL alone, and asks one question: was this exact pair already in the base branch? If it was not, the pull request is introducing or editing it, which means a human just changed a version and got the digest wrong — annoying, not alarming, and fixed by recomputing the hash as described in Generating SRI Hashes with OpenSSL and shasum. If the pair is byte-identical to the base branch and the download still disagrees, nobody in your repository touched it and the remote file moved on its own. That is the signal worth waking someone for. Scheduled runs have no base branch to compare against, so every mismatch there is by definition drift on a settled URL and is always paged.
Variants
Permalink to "Variants"Check the deployed site instead of the repository
Permalink to "Check the deployed site instead of the repository"Run the same script against your production HTML rather than the files on disk. Download the page, write it to a temporary file, and pass that in. This catches an edge transform or an injected tag that never existed in the repository at all, and it is the natural companion to Detecting Changes in Third-Party Scripts, which watches script bodies that carry no integrity attribute yet.
curl -fsSL https://www.example.com/ -o /tmp/live.html
node scripts/check-sri-drift.mjs /tmp/live.html
Feed the checker from a manifest
Permalink to "Feed the checker from a manifest"If your build already emits a hash manifest, iterate the manifest instead of parsing HTML. That removes the regex from the equation and covers URLs injected at runtime, which never appear in a static file. Generating that artifact is covered separately; the drift check simply consumes it.
Run only on a schedule for third-party tags
Permalink to "Run only on a schedule for third-party tags"Marketing tags on @latest URLs change constantly and legitimately, so gating pull requests on them is pointless. Split them into a second invocation that runs only on the cron trigger and opens an issue rather than failing a build, keeping the blocking gate reserved for pinned, versioned URLs.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
A tag without
crossorigin="anonymous"is not covered. SRI on a cross-origin resource requires a CORS-enabled fetch; omit the attribute and the browser refuses the resource outright rather than validating it, so your checker will report a healthy match for a tag no user can load. Extendextract()to flag any tag with anintegrityattribute and nocrossorigin, and read How CORS and crossorigin Affect SRI before deciding what the gate should do about it. -
Encoding differences look exactly like tampering.
createHash().digest('base64')emits standard padded base64. A digest pasted from a tool that emits base64url, or one with the padding stripped, will never string-match even though the underlying bytes are identical. Normalise before comparing, or better, regenerate the attribute; Base64 Encoding Rules for SRI Hashes spells out which alphabet the specification requires. -
The regex only sees double-quoted attributes. Single quotes, unquoted values and attributes split across lines by a formatter will be missed silently, and a missed tag reports as a pass. Run the extractor over your templates once and assert the tag count matches what
grep -c integrity=finds; if your templates are irregular, swap the regex for a real HTML parser. -
Multiple digests in one attribute are an OR, not an AND. The specification tells the browser to select the strongest algorithm present and accept a match against any token of that algorithm, so
sha256-… sha384-…is validated as SHA-384 alone. A checker that compares against every token will report false drift on a perfectly valid tag. If you are unsure which algorithm to standardise on, SHA-256 vs SHA-384 vs SHA-512 for SRI compares the trade-offs. -
A green gate does not mean users are safe today. The check ran at cron time; a file can change five minutes later and stay bad until the next run. Treat the interval as your detection window, shorten it for high-value URLs, and pair it with browser-side reporting so real failures surface between runs.
Verification Steps
Permalink to "Verification Steps"1. Confirm a clean run exits zero
Permalink to "1. Confirm a clean run exits zero"node scripts/check-sri-drift.mjs dist/index.html; echo "exit: $?"
Expected output when every attribute is current:
URL STATUS DETAIL
https://cdn.example.com/[email protected]/lib.min.js OK sha384-oqVuAfXRKap7fdgc...
All external subresources match their committed digests
exit: 0
2. Force a mismatch and confirm the exit code flips
Permalink to "2. Force a mismatch and confirm the exit code flips"Corrupt one character of a committed digest, then re-run:
sed -i 's/integrity="sha384-oqVu/integrity="sha384-zzzz/' dist/index.html
node scripts/check-sri-drift.mjs dist/index.html; echo "exit: $?"
The row now reads TAMPERING (no --baseline was supplied) and the exit code is 1. Revert the edit before committing.
3. Confirm a version bump is classified as a bump
Permalink to "3. Confirm a version bump is classified as a bump"On a branch that legitimately upgrades a library and updates both the URL and the digest, run the check the way the workflow does:
node scripts/check-sri-drift.mjs --baseline origin/main $(git ls-files '*.html')
The upgraded row is labelled BUMP rather than TAMPERING, because the pair is new on this branch. If the digest was pasted correctly it will simply read OK.
4. Confirm an unreachable origin does not fail the build
Permalink to "4. Confirm an unreachable origin does not fail the build"Point one tag at a hostname that does not resolve and run the checker:
node scripts/check-sri-drift.mjs dist/index.html; echo "exit: $?"
After four attempts the row reads UNREACHABLE and the exit code is 2. In the workflow, that path emits ::warning:: and the job stays green.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Why re-download the file instead of trusting the build output?
The integrity attribute is a claim about bytes that live on someone else’s server. Your build never touches those bytes, so it cannot notice when they change. Only a fresh download from the same URL the browser will use tells you whether the claim is still true, which is exactly what a visitor’s browser is about to check.
How do I tell an intentional version bump from tampering?
Compare the URL and integrity pair against the base branch. If the pull request introduced or edited that pair, the author is bumping a version and simply pasted a stale digest, so fail the check with a recompute message. If the pair is byte-identical to the base branch and the download still mismatches, the remote file changed under a stable URL and a human must look at it.
Should a scheduled run fail the pipeline when a CDN is unreachable?
No. Separate the two outcomes with distinct exit codes. A digest mismatch is a security signal and should fail loudly, while a DNS failure, a connection reset, or a 503 after several retries is an availability problem with the check itself. Emit a workflow warning for the second case so the gate stays credible.
Does Content-Encoding change the digest the check computes?
It should not. The browser validates integrity against the response body after content encodings have been decoded, and the fetch implementation in Node decodes gzip and brotli before handing you the bytes. Requesting identity encoding removes the variable entirely and makes the digest reproducible from curl on a developer machine.
Can the same check cover assets served from my own origin?
Yes, and it is worth doing after a deploy rather than during the build. Point the checker at the deployed URLs instead of the local files and it will catch a broken CDN purge, a partial upload, or an edge worker that rewrites responses in flight. During the build itself a manifest comparison is faster and does not depend on the network.
Related
Permalink to "Related"- Generating an SRI Manifest in GitHub Actions — producing the hash artifact this check can consume instead of parsing HTML
- Verifying Deployed Assets Against a Hash Manifest — the post-deploy half of the same gate, run against live URLs
- Alerting on SRI Failures from CSP Reports — catching drift from real browsers between scheduled runs