Skip to content

Repository files navigation

sri-scan

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.

Install

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.0

The 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 passed

Usage

Scanning build output

Point 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 $?
1

The 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.com

A 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)

Scanning a live site

$ ./bin/sri-scan url https://example.com --crawl --max-pages 50 --same-origin

The 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.

Verifying that the hashes are actually correct

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 --verify

A 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.

Adopting it on an existing codebase

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.

Other formats

$ ./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
  }
}

Rules

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.

Policy file

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-31

Suppressions 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.

How it works

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.

Options

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.

Exit codes

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.

GitHub Action

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: true

Scanning 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: true

The 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.

Further reading

Licence

MIT. See LICENSE.

Maintained alongside the Subresource Integrity & supply chain hardening reference.

About

CI security gate that fails the build when scripts and stylesheets load without valid Subresource Integrity. Scans build output or a live site; SARIF, JSON and Markdown reports.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages