A CI security gate that fails the build when scripts and stylesheets load without valid Subresource Integrity.
Every <script src> and <link rel="stylesheet"> pointing at a third-party origin is an
open invitation to execute whatever bytes that origin returns today. When a CDN account is
compromised, a package is hijacked, or a domain quietly changes hands, the page that loads it
runs the attacker's code with full access to the DOM, cookies and session. Subresource
Integrity closes that hole by pinning a cryptographic hash of the exact bytes the browser is
allowed to run — but only where somebody remembered to add it, spelled the attribute
correctly, and did not pair it with a URL that floats to a new version next week.
sri-scan is the check that catches the ones nobody remembered. It walks your build output or
crawls the deployed site, reports every subresource that is unpinned, mispinned or pinned to a
moving target, and exits non-zero so the pipeline stops. It is deliberately the auditor, not
the fixer: its job is to find the gaps and tell you precisely why each one matters.
There are no runtime dependencies. The whole scanner is Python standard library, so it drops into any CI image that already has Python 3.11 or newer.
Not published to PyPI. Clone it and run it.
$ git clone https://github.com/subresource-integrity/sri-scan.git
$ cd sri-scan
$ ./bin/sri-scan --version
sri-scan 1.0.0The bin/sri-scan shim puts the repository root on sys.path, so it works from a bare clone
with nothing installed. If you prefer, python3 -m sri_scan is equivalent, and
pip install -e . gives you an sri-scan entry point on PATH. Vendoring the sri_scan/
package directly into your own repository also works — it imports nothing outside the standard
library.
Only pytest is needed to run the test suite:
$ python3 -m pip install -r requirements-dev.txt
$ python3 -m pytest
154 passedPoint it at the directory your build wrote.
$ ./bin/sri-scan dir examples/broken-dist
sri-scan dir examples/broken-dist
index.html
6: error SRI001 <link rel=stylesheet> loads https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css from another origin with no integrity attribute
why: The browser will execute whatever bytes the third-party origin returns. A compromised CDN, an expired domain or a hijacked account silently becomes code execution on your page.
13: error SRI001 <script> loads https://unpkg.com/htmx.org@latest/dist/htmx.min.js from another origin with no integrity attribute
14: error SRI002 https://cdn.example.net/widget/3.2.1/widget.js has integrity but no crossorigin attribute; add crossorigin="anonymous"
why: Without a crossorigin attribute the response is opaque, so the browser cannot read the bytes to hash them. The integrity check can never pass and the resource is blocked outright - this usually ships as a broken page.
16: error SRI004 integrity value on https://cdn.example.net/tracker/1.0.0/t.js is malformed: digest decodes to 24 bytes but sha384 produces 48; the value may be a hex digest that was base64-encoded, or the wrong algorithm label
why: An integrity attribute the browser cannot parse is not a weaker check, it is no check at all for that expression - and if no expression parses, the resource is blocked. Hex digests pasted in place of base64 are the usual cause.
6: warning SRI006 https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css is not pinned: '5' is a major/minor range that resolves to the newest match
why: A floating tag or version range lets the CDN serve different bytes tomorrow. Any integrity hash pinned against it will break on the next upstream release, and without one you are auto-updating third-party code into production.
7: warning SRI003 https://fonts.example.net/css/fira.css is pinned with sha256; policy requires at least sha384
why: Browsers enforce only the strongest algorithm present in the attribute, so a weak expression sets the real strength of the check. sha384 is the recommended default.
13: warning SRI006 https://unpkg.com/htmx.org@latest/dist/htmx.min.js is not pinned: pinned to the floating dist-tag 'latest'
19: info SRI007 <script> loads /assets/app.js with no integrity attribute
why: Integrity on first-party assets is defence in depth: it catches a compromised build artefact or object store, and it is a prerequisite for enforcing require-sri-for in CSP.
4 error 3 warning 1 info (1 document(s), 6 subresource(s))
FAIL: 4 finding(s) at or above error
Reference: https://www.subresource-integrity.com/core-sri-fundamentals-browser-security-boundaries/
$ echo $?
1The why line prints once per rule per run, not once per finding, so a page with forty
unpinned tags stays readable.
Tell it what origin the output will be served from and absolute URLs pointing back at your own site stop counting as cross-origin:
$ ./bin/sri-scan dir dist/ --base-url https://example.comA correctly pinned build is quiet:
$ ./bin/sri-scan dir examples/clean-dist
sri-scan dir examples/clean-dist
No findings.
0 error 0 warning 0 info (1 document(s), 3 subresource(s))
PASS (threshold: error)$ ./bin/sri-scan url https://example.com --crawl --max-pages 50 --same-originThe crawler follows same-origin anchors breadth-first, obeys robots.txt unless you pass
--ignore-robots, skips anything that is obviously an asset rather than a page, and holds
itself to --rate-limit requests per second across all workers.
Static analysis cannot tell you whether sha384-oqVuAf… is the hash of the file the CDN serves
today. --verify fetches each pinned asset once, hashes it with the strongest algorithm in the
attribute, and raises SRI005 on a mismatch with the value you should have used:
$ ./bin/sri-scan url https://example.com --verifyA hash that no longer matches means one of two things, and both are worth knowing: the asset was republished under the same URL, or your build shipped a stale hash and the resource is already blocked in production. Assets that cannot be fetched are reported as scan warnings rather than findings, so a flaky CDN does not fail your build.
Record what is already there, then fail only on regressions:
$ ./bin/sri-scan dir examples/broken-dist --write-baseline .sri-scan-baseline.json
sri-scan: wrote 8 finding(s) to .sri-scan-baseline.json. Future runs will fail only on new findings.
$ ./bin/sri-scan dir examples/broken-dist --baseline .sri-scan-baseline.json
sri-scan dir examples/broken-dist
baseline: .sri-scan-baseline.json
No findings.
0 error 0 warning 0 info (1 document(s), 6 subresource(s))
8 finding(s) suppressed by baseline
PASS (threshold: error)Baseline fingerprints are rule + document + URL, deliberately excluding the line number, so
reformatting a file does not resurrect suppressed findings — but moving the same bad tag onto a
new page does surface it.
$ ./bin/sri-scan dir dist/ --format sarif -o sri-scan.sarif
$ ./bin/sri-scan dir dist/ --format json | jq '.summary'
$ ./bin/sri-scan dir dist/ --format markdown >> "$GITHUB_STEP_SUMMARY"--format json gives you a stable integration surface:
{
"error": 4,
"warning": 3,
"info": 1,
"total": 8,
"failing": 4,
"baselined": 0,
"by_rule": {
"SRI001": 2,
"SRI002": 1,
"SRI003": 1,
"SRI004": 1,
"SRI006": 2,
"SRI007": 1
}
}| ID | Default | What it catches |
|---|---|---|
SRI001 |
error | A cross-origin <script src> or <link rel=stylesheet> with no integrity attribute at all. |
SRI002 |
error | integrity present but crossorigin missing (or invalid) on a cross-origin subresource. The response is opaque, so the check can never pass and the resource is blocked. |
SRI003 |
warning | The strongest algorithm in the attribute is weaker than the policy minimum. Defaults to requiring sha384. |
SRI004 |
error | Malformed integrity: bad prefix, an algorithm outside sha256/sha384/sha512, invalid base64, or a digest whose decoded length does not match the stated algorithm. |
SRI005 |
error | The pinned hash does not match the bytes actually served. Requires --verify. |
SRI006 |
warning | An unpinned package-CDN URL: a @latest dist-tag, a bare package name, a major/minor prefix like @4, or a range like @^4.4.0. |
SRI007 |
info | A same-origin subresource with no integrity. Informational by default; raise it once your first-party assets are covered. |
Rule IDs are permanent. They never get renumbered or reused, so a suppression written today still means the same thing after an upgrade.
Drop a .sri-scan.toml in your repository root. The scanner discovers it by walking up from
the scan target, so a monorepo can keep one at the top or override it per package.
TOML rather than YAML on purpose: tomllib is standard library from Python 3.11, so the
scanner stays dependency-free; and a security policy is exactly the wrong place for
significant whitespace and implicit type coercion, where off and no and sha256 need to
mean precisely what they say.
min_algorithm = "sha384"
fail_on = "error"
allowed_origins = [
"https://assets.example.com",
"*.cdn.example.com",
]
exclude = ["vendor/**", "**/email-templates/*.html"]
[severity]
SRI003 = "error" # weak algorithm: treat as a hard failure
SRI006 = "error" # unpinned CDN URL: treat as a hard failure
SRI007 = "off" # first-party coverage not tracked yet
[[ignore]]
rule = "SRI001"
url = "https://legacy-widget.example.net/*"
reason = "Vendor ships no stable hashes; removal tracked in PLAT-4471"
expires = 2026-12-31Suppressions can carry an expires date. Once it passes, the ignore stops applying and the
scan reports that it expired, so a temporary exception cannot quietly become permanent policy:
Scan warnings
- ignore rule for SRI001 https://legacy-widget.example.net/* expired on 2026-12-31 and is no longer suppressing findings
A full annotated example lives in examples/sri-scan.example.toml.
Parsing. HTML is read with html.parser, not a DOM library. Build output and
server-rendered templates are routinely not well-formed, and a stream parser keeps returning
elements from markup that would make a tree builder either throw or silently reparent things.
<base href> is tracked so relative URLs resolve the way the browser will resolve them, and
rel="preload" with as="font" is skipped because SRI does not apply there.
Origin comparison. Cross-origin means scheme, host and port, with default ports normalised
so https://example.com and https://example.com:443 compare equal, and protocol-relative
URLs inheriting the document's scheme. In directory mode with no --base-url, any absolute
http(s) URL is treated as cross-origin: the safe default, and --base-url is how you refine it.
Integrity validation. An integrity attribute is a list of <algorithm>-<base64 of the raw binary digest> expressions, and browsers enforce only the strongest algorithm present. So does
sri-scan. Base64 is decoded strictly, which is what makes a typo detectable at all, and the
decoded length is checked against the digest size for the stated algorithm — this is what
catches the single most common mistake, a hex digest that was base64-encoded and looks
plausible until a browser rejects it. --verify re-derives the correct value and prints it, so
the fix is a copy-paste.
Pinning analysis. SRI006 only judges hosts whose path conventions it actually models
(jsDelivr, unpkg, cdnjs, esm.sh, Skypack, JSPM). An exact 1.2.3 is pinned; @latest, a bare
package name, @4, @^4.4.0 and cdnjs's latest alias are not. On an unmodelled host it stays
silent rather than guessing, because a content-addressed path and a mutable one are
indistinguishable without knowing the host's rules.
Crawling. Breadth-first with a bounded worker pool and a shared rate limiter, so
--max-pages 200 behaves like a courteous visitor rather than a load test. robots.txt is
fetched once per origin and cached; a 404 or a network blip fails open, because "no rules
stated" is not "crawl nothing". Redirects are followed and the post-redirect URL is what gets
used for relative resolution and the same-origin test.
Verification caching. Every distinct asset URL is fetched exactly once, however many pages reference it, so one shared jQuery tag across fifty pages costs one request.
SARIF. The SARIF 2.1.0 output carries partialFingerprints so GitHub tracks a finding
across commits instead of re-opening it on every reformat, and properties.security-severity
so results land in the security alerts view with a sensible severity badge rather than as plain
notes.
Both subcommands share every option below except where marked.
| Flag | Default | Description |
|---|---|---|
--policy FILE |
discovered | Policy file to use. |
--no-policy |
off | Ignore any discovered policy file; use built-in defaults. |
--min-algorithm {sha256,sha384,sha512} |
sha384 |
Minimum acceptable algorithm. Overrides the policy file. |
--fail-on {error,warning,info} |
error |
Lowest severity that fails the build. |
--format {terminal,json,sarif,markdown} |
terminal |
Output format. |
-o, --output FILE |
stdout | Write the report to a file. Disables colour. |
--color {auto,always,never} |
auto |
Colourise terminal output. NO_COLOR and FORCE_COLOR are honoured. |
--no-links |
off | Omit the reference link from the report footer. |
--baseline FILE |
none | Suppress recorded findings; fail only on new ones. |
--write-baseline [FILE] |
.sri-scan-baseline.json |
Record current findings and exit 0. |
--verify |
off | Fetch each pinned asset and check the hash matches (SRI005). |
--timeout SECONDS |
15 |
Per-request timeout. |
--concurrency N |
4 |
Parallel requests. |
--rate-limit PER_SECOND |
5 |
Maximum requests per second across all workers. |
--version |
Print the version. |
Directory mode only:
| Flag | Default | Description |
|---|---|---|
path |
required | Directory or single file to scan. |
--base-url URL |
none | Origin the output is served from. Without it, every absolute URL counts as cross-origin. |
--ext .EXT |
none | Scan an additional file extension. Repeatable. |
URL mode only:
| Flag | Default | Description |
|---|---|---|
url |
required | Page to scan. |
--crawl |
off | Follow links from the start page. |
--max-pages N |
25 |
Page cap when crawling. |
--same-origin / --any-origin |
same-origin | Restrict the crawl to the start URL's origin. |
--ignore-robots |
off | Crawl pages robots.txt disallows. |
HTML, XHTML, Vue, Svelte, Jinja, Nunjucks, Handlebars, EJS, ERB, Liquid, Twig and PHP files are
scanned by default. .git, node_modules, .venv, __pycache__ and similar directories are
never descended into.
| Code | Meaning |
|---|---|
0 |
Clean, or nothing at or above the failure threshold. Also returned by --write-baseline. |
1 |
Findings at or above the failure threshold. |
2 |
Usage error, unreadable policy or baseline, or an unrecoverable runtime failure such as a start URL that could not be fetched. |
The repository root is a composite action. Findings upload as SARIF and render as inline annotations on the pull request diff.
name: SRI gate
on: [push, pull_request]
permissions:
contents: read
security-events: write # required for the SARIF upload
jobs:
sri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Scan for missing or invalid SRI
uses: subresource-integrity/sri-scan@v1
with:
path: dist
base-url: https://example.com
fail-on: error
format: sarif
report-path: sri-scan.sarif
upload-sarif: trueScanning the deployed site on a schedule catches assets that were republished upstream after
your last build — the exact scenario SRI005 exists for:
- uses: subresource-integrity/sri-scan@v1
with:
url: https://example.com
crawl: true
max-pages: 50
verify: trueThe action writes a Markdown summary to $GITHUB_STEP_SUMMARY by default and exposes the
counts as step outputs:
- id: sri
uses: subresource-integrity/sri-scan@v1
with:
path: dist
- if: steps.sri.outputs.errors != '0'
run: echo "::notice::${{ steps.sri.outputs.errors }} SRI errors, ${{ steps.sri.outputs.warnings }} warnings"| Input | Default | Description |
|---|---|---|
path |
Build directory to scan. One of path or url is required. |
|
url |
Live URL to scan. | |
crawl |
false |
Follow same-origin links (url mode). |
max-pages |
25 |
Page cap when crawling. |
ignore-robots |
false |
Crawl pages robots.txt disallows. |
base-url |
Origin the build output is served from (dir mode). | |
policy |
discovered | Path to a policy file. |
min-algorithm |
policy | Minimum acceptable hash algorithm. |
fail-on |
error |
Lowest severity that fails the job. |
verify |
false |
Fetch each pinned asset and check the hash. |
baseline |
Baseline file of accepted findings. | |
format |
sarif |
Format written to report-path. |
report-path |
sri-scan-report.sarif |
Where to write the report. |
upload-sarif |
false |
Upload to code scanning. Needs security-events: write. |
job-summary |
true |
Write Markdown to $GITHUB_STEP_SUMMARY. |
no-links |
false |
Omit the reference link from report footers. |
python-version |
3.12 |
Python to run the scanner with. 3.11 or newer. |
Outputs: exit-code, errors, warnings, infos, total, report-path.
A complete working workflow is in
.github/workflows/sri-scan.yml.
- Subresource Integrity and browser security boundaries — how browsers enforce integrity, what happens on a failure, and where SRI does and does not apply.
- SHA-256 vs SHA-384 vs SHA-512 for SRI — why
SRI003defaults to demandingsha384, and what the strongest-algorithm-wins rule means in practice. - How to calculate SHA-256 vs SHA-384 hashes for SRI — generating the base64 of the raw digest correctly, and why hex digests are the usual cause of
SRI004. - Debugging SRI hash mismatch errors — reading the console message a browser emits, and the opaque-response trap behind
SRI002. - Configuring SRI for jsDelivr and unpkg — the URL forms these CDNs accept and which of them are safe to pin against, which is the basis of
SRI006. - Mapping CDN origins to SRI policies — deciding which origins belong in
allowed_originsand which should never be. - Combining require-sri-for with CSP — enforcing integrity at the browser rather than only in CI, once
SRI007is clean. - Scoring third-party script risk — prioritising which of the findings from a first baseline run to burn down first.
MIT. See LICENSE.
Maintained alongside the Subresource Integrity & supply chain hardening reference.