pqcheck 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/bin/pqcheck.js +289 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,9 +12,13 @@ The same scanner that powers [quantapact.com](https://quantapact.com), the brows
12
12
 
13
13
  ---
14
14
 
15
- ## What's new in 0.7.6
15
+ ## What's new in 0.7.9
16
16
 
17
- User-Agent now consistently tags the subcommand on every request (`pqcheck-cli/0.7.6 (scan)`, `(lock)`, `(deps)`, `(history)`, `(watch)`). Lets the server aggregate adoption by subcommand. No new data collected the subcommand token rides inside the User-Agent header that has always been logged anonymously. See [privacy](https://quantapact.com/privacy) and [CHANGELOG.md](./CHANGELOG.md).
17
+ **CSP verdict + vendor labels on `pqcheck deps`.** The supply-chain table now shows a friendly vendor label (`New Relic · errors` / `Cloudflare · cdn` / `Adobe Fonts · fonts`) per host instead of raw `bam.nr-data.net`-style hostnames, plus a one-line site-wide CSP verdict above the table (`✗ No CSP enforcement` / `⚠ CSP is permissive` / `✓ Strict CSP enforced`). Same data shape ships on `/r/<domain>` and in the browser extension cross-surface parity for the supply-chain story. See [CHANGELOG.md](./CHANGELOG.md).
18
+
19
+ ## What's new in 0.7.8
20
+
21
+ **Supply-chain change detection in CI** — `pqcheck deps <domain> --baseline file.json` compares the current third-party host list to a stored baseline. New hosts since the last accepted state are flagged `*NEW*` in the pretty table and `"isNew": true` in JSON. Add `--fail-on-new` to exit `4` if anything new appeared — the Polyfill.io-style CI gate that fails PRs introducing third-party scripts until you deliberately accept them with `--write-baseline`. Each row also shows an `SRI` column (on/off/n/a) so you can see which scripts allow silent vendor-side content swaps. See [CHANGELOG.md](./CHANGELOG.md).
18
22
 
19
23
  ---
20
24
 
@@ -81,6 +85,9 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
81
85
  - `-o <dir>` — Output directory for `--lock` files
82
86
  - `--max=<N>` — Max third parties to scan (default 20)
83
87
  - `--allowlist <file>` — Exit **3** if any third-party not in allowlist (CI vendor-risk gate)
88
+ - `--baseline <file>` — Compare current hosts to baseline JSON; flag `*NEW*` and surface `isNew` in JSON output
89
+ - `--write-baseline` — Overwrite `--baseline` file with current scan (use once to capture initial state)
90
+ - `--fail-on-new` — Exit **4** if any new hosts appeared since baseline (CI supply-chain change gate)
84
91
 
85
92
  **`pqcheck lock`:**
86
93
  - `-o <dir>` — Output directory
@@ -98,6 +105,7 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
98
105
  | `1` | Usage / network / scan error |
99
106
  | `2` | Score met or exceeded `--threshold`, or `diff` detected regression |
100
107
  | `3` | Allowlist violation (`pqcheck deps --allowlist`) |
108
+ | `4` | Supply-chain change detected — new host(s) since baseline (`pqcheck deps --fail-on-new`) |
101
109
 
102
110
  ## Examples
103
111
 
@@ -120,6 +128,12 @@ npx pqcheck deps mycompany.com --lock
120
128
  # Vendor-risk CI gate — fail PR if any third-party not in allowlist
121
129
  npx pqcheck deps mycompany.com --allowlist allowed-vendors.txt
122
130
 
131
+ # Capture initial supply-chain baseline (run once, commit the JSON file)
132
+ npx pqcheck deps mycompany.com --baseline .pqcheck-baseline.json --write-baseline
133
+
134
+ # Supply-chain change gate — fail PR if any new third-party script appeared since baseline
135
+ npx pqcheck deps mycompany.com --baseline .pqcheck-baseline.json --fail-on-new
136
+
123
137
  # Score history sparkline
124
138
  npx pqcheck history mycompany.com
125
139
 
package/bin/pqcheck.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // =============================================================================
8
8
 
9
9
  const API_BASE = process.env.PQCHECK_API_BASE || "https://quantapact.com";
10
- const VERSION = "0.7.7";
10
+ const VERSION = "0.7.9";
11
11
 
12
12
  const ANSI = {
13
13
  reset: "\x1b[0m",
@@ -572,6 +572,9 @@ ${color("bold", "Subcommand-specific:")}
572
572
  -o <dir> Output directory for --lock files
573
573
  --max=<N> Max third parties to scan (default 20)
574
574
  --allowlist <file> Exit 3 if any third-party not in allowlist (CI gate)
575
+ --baseline <file> Compare current hosts to baseline JSON; mark new ones
576
+ --write-baseline Overwrite the --baseline file with current scan
577
+ --fail-on-new Exit 4 if any NEW host since baseline (Polyfill.io-style supply-chain CI gate)
575
578
  pqcheck lock:
576
579
  -o <dir> Output directory
577
580
  --stdout Print JSON to stdout instead of writing files
@@ -584,12 +587,15 @@ ${color("bold", "Exit codes:")}
584
587
  1 usage / network / scan error
585
588
  2 score met or exceeded --threshold (or diff regression)
586
589
  3 allowlist violation (deps --allowlist)
590
+ 4 supply-chain change detected — new host(s) since baseline (deps --fail-on-new)
587
591
 
588
592
  ${color("bold", "Examples:")}
589
593
  npx pqcheck chase.com
590
594
  npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
591
595
  npx pqcheck deps stripe.com --lock
592
596
  npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
597
+ npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
598
+ npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --fail-on-new ${color("dim", "# fail PR on new third party")}
593
599
  npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
594
600
  npx pqcheck history quantapact.com
595
601
  npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
@@ -887,6 +893,36 @@ async function runDepsCommand(args) {
887
893
  }
888
894
  }
889
895
 
896
+ // Baseline support (v0.7.8+): --baseline <path> compares this scan's third
897
+ // parties to a stored baseline JSON file. New hosts get a NEW flag in
898
+ // output. With --fail-on-new, the CLI exits with code 4 if any new hosts
899
+ // appeared — turning the scan into a Polyfill.io-style supply-chain change
900
+ // detector for CI pipelines. --write-baseline overwrites the baseline file
901
+ // with the current scan's hosts (use after deliberately adding a vendor).
902
+ const baselineIdx = args.indexOf("--baseline");
903
+ const baselinePath = baselineIdx >= 0 ? args[baselineIdx + 1] : null;
904
+ const writeBaseline = args.includes("--write-baseline");
905
+ const failOnNew = args.includes("--fail-on-new");
906
+ let baselineHosts = null;
907
+ if (baselinePath) {
908
+ try {
909
+ const fs2 = await import("node:fs/promises");
910
+ const raw = await fs2.readFile(baselinePath, "utf8");
911
+ const parsed = JSON.parse(raw);
912
+ baselineHosts = new Set((parsed.thirdParties || parsed.hosts || []).map((h) =>
913
+ typeof h === "string" ? h.toLowerCase() : (h.host || "").toLowerCase()
914
+ ).filter(Boolean));
915
+ } catch (err) {
916
+ // File missing is OK on first run — treat as empty baseline (everything is "new")
917
+ if (err && err.code === "ENOENT") {
918
+ baselineHosts = new Set();
919
+ } else {
920
+ console.error(color("red", `error reading --baseline: ${err.message}`));
921
+ process.exit(1);
922
+ }
923
+ }
924
+ }
925
+
890
926
  const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
891
927
  const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
892
928
  if (!domain || !isValidDomain(domain)) {
@@ -896,12 +932,15 @@ async function runDepsCommand(args) {
896
932
  }
897
933
 
898
934
  if (!json) process.stderr.write(color("dim", `Fetching ${domain} HTML...`));
899
- const html = await fetchPageHTML(domain);
935
+ const fetched = await fetchPageHTML(domain);
900
936
  if (!json) process.stderr.write("\r\x1b[K");
901
- if (!html) {
937
+ if (!fetched) {
902
938
  console.error(color("red", `error: could not fetch https://${domain}/`));
903
939
  process.exit(1);
904
940
  }
941
+ const { html, headerCsp } = fetched;
942
+ const metaCsp = extractMetaCsp(html);
943
+ const cspVerdict = classifyCsp(headerCsp, metaCsp);
905
944
 
906
945
  const refs = extractThirdPartyRefs(html, domain);
907
946
  if (refs.length === 0) {
@@ -919,8 +958,12 @@ async function runDepsCommand(args) {
919
958
  // Group by host, dedupe
920
959
  const byHost = new Map();
921
960
  for (const r of refs) {
922
- if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0 });
961
+ if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0, anyMissingSri: false, allHttps: true });
923
962
  const e = byHost.get(r.host);
963
+ // SRI status — host is flagged "no SRI" if ANY reference to it lacks integrity.
964
+ // Only counts for script type (iframes/links/imgs don't support SRI).
965
+ if (r.type === "script" && !r.sri) e.anyMissingSri = true;
966
+ if (!r.loadedOverHttps) e.allHttps = false;
924
967
  e.types.add(r.type);
925
968
  e.occurrences += 1;
926
969
  }
@@ -970,19 +1013,51 @@ async function runDepsCommand(args) {
970
1013
 
971
1014
  const summary = buildDepsSummary(results);
972
1015
 
1016
+ // Baseline diff (v0.7.8+): compare current hosts to baseline hosts.
1017
+ // newHosts = hosts in current scan that weren't in the baseline.
1018
+ // missingHosts = hosts in baseline that aren't in the current scan.
1019
+ // Each result gets a `.isNew` flag for downstream rendering.
1020
+ let newHosts = [];
1021
+ let missingHosts = [];
1022
+ if (baselineHosts) {
1023
+ const currentHostSet = new Set(results.map((r) => r.host));
1024
+ newHosts = results.map((r) => r.host).filter((h) => !baselineHosts.has(h));
1025
+ missingHosts = Array.from(baselineHosts).filter((h) => !currentHostSet.has(h));
1026
+ // Don't paint every row as *NEW* on the first run (empty baseline) — there
1027
+ // is no prior state to be new relative to. The summary line still says
1028
+ // "first run; all hosts will be captured" so the user knows.
1029
+ const baselineIsFirstRun = baselineHosts.size === 0;
1030
+ for (const r of results) {
1031
+ r.isNew = baselineIsFirstRun ? false : !baselineHosts.has(r.host);
1032
+ }
1033
+ }
1034
+
973
1035
  // Build manifest
974
1036
  const manifest = {
975
1037
  $schema: "https://quantapact.com/schemas/deps/v1",
976
- schemaVersion: "1.0",
1038
+ schemaVersion: "1.2", // bumped for CSP + vendor classification fields
977
1039
  domain,
978
1040
  scannedAt: new Date().toISOString(),
979
1041
  tool: "pqcheck-cli",
980
1042
  toolVersion: VERSION,
981
1043
  summary,
1044
+ csp: {
1045
+ quality: cspVerdict.quality, // "absent" | "weak" | "strict"
1046
+ source: cspVerdict.source, // "header" | "meta" | null
1047
+ },
1048
+ baseline: baselineHosts ? {
1049
+ file: baselinePath,
1050
+ newHosts,
1051
+ missingHosts,
1052
+ isFirstRun: baselineHosts.size === 0,
1053
+ } : null,
982
1054
  thirdParties: results.map((r) => ({
983
1055
  host: r.host,
984
1056
  types: r.types,
985
1057
  occurrences: r.occurrences,
1058
+ sri: { allScriptsHaveSri: !r.anyMissingSri, allHttps: r.allHttps },
1059
+ vendor: classifyVendor(r.host), // { name, category } or null
1060
+ isNew: r.isNew || false,
986
1061
  scan: r.scan,
987
1062
  error: r.error,
988
1063
  })),
@@ -992,6 +1067,39 @@ async function runDepsCommand(args) {
992
1067
  },
993
1068
  };
994
1069
 
1070
+ // --write-baseline: overwrite the baseline file with current hosts. Used
1071
+ // after deliberately adding a vendor (e.g., 'we added Stripe, update the
1072
+ // baseline so the next scan doesn't flag Stripe as new').
1073
+ if (writeBaseline && baselinePath) {
1074
+ try {
1075
+ const fs2 = await import("node:fs/promises");
1076
+ const baselinePayload = {
1077
+ $schema: "https://quantapact.com/schemas/deps-baseline/v1",
1078
+ domain,
1079
+ capturedAt: new Date().toISOString(),
1080
+ toolVersion: VERSION,
1081
+ thirdParties: results.map((r) => ({ host: r.host, sri: !r.anyMissingSri })),
1082
+ };
1083
+ await fs2.writeFile(baselinePath, JSON.stringify(baselinePayload, null, 2) + "\n", "utf8");
1084
+ if (!json) console.error(color("dim", `✓ wrote baseline to ${baselinePath} (${results.length} hosts)`));
1085
+ } catch (err) {
1086
+ console.error(color("red", `error writing --baseline: ${err.message}`));
1087
+ process.exit(1);
1088
+ }
1089
+ }
1090
+
1091
+ // --fail-on-new: exit code 4 if any new hosts appeared since the baseline.
1092
+ // The CI gate for supply-chain change detection.
1093
+ if (failOnNew && baselineHosts && newHosts.length > 0 && baselineHosts.size > 0) {
1094
+ if (!json) {
1095
+ console.error(color("red", `\nFAIL: ${newHosts.length} new third-party host${newHosts.length > 1 ? "s" : ""} appeared since baseline:`));
1096
+ for (const h of newHosts) console.error(color("red", ` + ${h}`));
1097
+ console.error(color("dim", `\n Use --write-baseline to accept these additions, or audit them as a potential supply-chain change.`));
1098
+ }
1099
+ if (json) console.log(JSON.stringify(manifest, null, 2));
1100
+ process.exit(4);
1101
+ }
1102
+
995
1103
  if (json) {
996
1104
  console.log(JSON.stringify(manifest, null, 2));
997
1105
  return;
@@ -1001,18 +1109,51 @@ async function runDepsCommand(args) {
1001
1109
  console.log("");
1002
1110
  console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
1003
1111
  console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
1112
+ // CSP-quality summary line — single line, site-wide. Mirrors the banner the
1113
+ // extension shows on the Supply Chain tab.
1114
+ if (cspVerdict.quality === "absent") {
1115
+ console.log(` ${color("red", "✗ No CSP enforcement")} ${color("dim", "— vendor swaps go undetected by the browser")}`);
1116
+ } else if (cspVerdict.quality === "weak") {
1117
+ console.log(` ${color("yellow", "⚠ CSP is permissive")} ${color("dim", `— uses unsafe-inline / wildcards / data: (${cspVerdict.source})`)}`);
1118
+ } else if (cspVerdict.quality === "strict") {
1119
+ console.log(` ${color("green", "✓ Strict CSP enforced")} ${color("dim", `(${cspVerdict.source})`)}`);
1120
+ }
1121
+ if (baselineHosts) {
1122
+ const baselineSize = baselineHosts.size;
1123
+ if (baselineSize === 0) {
1124
+ console.log(` ${color("dim", `Baseline: ${baselinePath} is empty — first run; all hosts will be captured.`)}`);
1125
+ } else {
1126
+ const newColor = newHosts.length > 0 ? "yellow" : "green";
1127
+ const newLabel = newHosts.length > 0 ? `${newHosts.length} NEW since baseline` : "no new hosts since baseline";
1128
+ const missingPart = missingHosts.length > 0 ? `, ${missingHosts.length} missing` : "";
1129
+ console.log(` ${color(newColor, newLabel)}${color("dim", `${missingPart} · baseline ${baselinePath}`)}`);
1130
+ }
1131
+ }
1004
1132
  console.log("");
1005
- console.log(` ${color("dim", "GRADE HOST PQC TYPES")}`);
1006
- console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─────")}`);
1133
+ console.log(` ${color("dim", "GRADE HOST VENDOR (CATEGORY) PQC SRI TYPES")}`);
1134
+ console.log(` ${color("dim", "───── ───────────────────────────────────────── ────────────────────── ─── ─── ─────")}`);
1007
1135
  for (const r of results) {
1008
1136
  const gradeStr = r.scan?.grade ?? "?";
1009
1137
  const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
1010
- const host = r.host.length > 41 ? r.host.slice(0, 40) + "…" : r.host.padEnd(41, " ");
1138
+ const hostRaw = r.host + (r.isNew ? " *NEW*" : "");
1139
+ const host = hostRaw.length > 41 ? hostRaw.slice(0, 40) + "…" : (r.host.padEnd(r.isNew ? 35 : 41, " ") + (r.isNew ? color("yellow", " *NEW*") : ""));
1011
1140
  const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
1141
+ const hasScript = (r.types || []).includes("script");
1142
+ const sriCell = !hasScript ? color("dim", "n/a") : r.anyMissingSri ? color("yellow", "off") : color("green", "on ");
1012
1143
  const types = r.types.join(",");
1013
- console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${color("dim", types)}`);
1144
+ // Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
1145
+ // for comprehension. Padded to 22 chars so the table columns stay aligned.
1146
+ const vendor = classifyVendor(r.host);
1147
+ const vendorStrRaw = vendor ? `${vendor.name} (${vendor.category})` : "—";
1148
+ const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
1149
+ const vendorColored = vendor ? color("dim", vendorTruncated) : color("dim", vendorTruncated);
1150
+ console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
1014
1151
  }
1015
1152
  console.log("");
1153
+ if (baselineHosts && baselineHosts.size > 0 && newHosts.length > 0) {
1154
+ console.log(` ${color("yellow", "⚠")} ${color("dim", `${newHosts.length} new third-party host${newHosts.length > 1 ? "s" : ""} not in baseline. Audit, then run with --write-baseline to accept.`)}`);
1155
+ console.log("");
1156
+ }
1016
1157
  console.log(` ${color("dim", "Each row scanned via")} ${color("violet", "/api/scan")}${color("dim", " · /methodology/browser-extension explains scoring")}`);
1017
1158
  console.log("");
1018
1159
 
@@ -1062,31 +1203,160 @@ async function fetchPageHTML(domain) {
1062
1203
  });
1063
1204
  clearTimeout(t);
1064
1205
  if (!resp.ok) return null;
1065
- return await resp.text();
1206
+ const html = await resp.text();
1207
+ // Also capture the CSP header so the deps view can show the site-wide
1208
+ // enforcement level. Report-only header is ignored (not enforced).
1209
+ const headerCsp = resp.headers.get("content-security-policy") || "";
1210
+ return { html, headerCsp };
1066
1211
  } catch {
1067
1212
  return null;
1068
1213
  }
1069
1214
  }
1070
1215
 
1216
+ // Scrape <meta http-equiv="Content-Security-Policy" content="..."> from HTML
1217
+ // as a fallback when the response header is absent.
1218
+ function extractMetaCsp(html) {
1219
+ const re = /<meta[^>]*\bhttp-equiv\s*=\s*["']?content-security-policy["']?[^>]*\bcontent\s*=\s*["']([^"']+)["']/i;
1220
+ const m = html.match(re);
1221
+ return m ? m[1] : "";
1222
+ }
1223
+
1224
+ // Site-level CSP-quality classifier. Mirrors lib/serviceCatalog.ts and the
1225
+ // extension's classifyCsp(). Three buckets: absent / weak / strict.
1226
+ function classifyCsp(headerCsp, metaCsp) {
1227
+ const policy = ((headerCsp || "").trim() || (metaCsp || "").trim()).toLowerCase();
1228
+ if (!policy) return { quality: "absent", source: null, raw: "" };
1229
+ const weakSignals = ["'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", " * ", " *;", "data:", "blob:"];
1230
+ const weak = weakSignals.some((sig) => policy.includes(sig));
1231
+ return {
1232
+ quality: weak ? "weak" : "strict",
1233
+ source: (headerCsp || "").trim() ? "header" : "meta",
1234
+ raw: policy,
1235
+ };
1236
+ }
1237
+
1238
+ // Compact vendor catalog — covers ~50 most common third-party origins so the
1239
+ // pretty table can show "(New Relic · errors)" instead of just "bam.nr-data.net".
1240
+ // Mirror of lib/serviceCatalog.ts + extension/popup.js SERVICE_CATALOG; keep
1241
+ // the three in sync when adding vendors.
1242
+ const SERVICE_CATALOG = {
1243
+ "googletagmanager.com": { name: "Google Tag Manager", category: "analytics" },
1244
+ "google-analytics.com": { name: "Google Analytics", category: "analytics" },
1245
+ "googleadservices.com": { name: "Google Ads", category: "ads" },
1246
+ "doubleclick.net": { name: "Google DoubleClick", category: "ads" },
1247
+ "googleapis.com": { name: "Google APIs", category: "cdn" },
1248
+ "gstatic.com": { name: "Google Static", category: "cdn" },
1249
+ "recaptcha.net": { name: "Google reCAPTCHA", category: "captcha" },
1250
+ "youtube.com": { name: "YouTube", category: "social" },
1251
+ "cloudflare.com": { name: "Cloudflare", category: "cdn" },
1252
+ "cdnjs.cloudflare.com": { name: "Cloudflare cdnjs", category: "cdn" },
1253
+ "cloudflareinsights.com": { name: "Cloudflare Web Analytics", category: "analytics" },
1254
+ "challenges.cloudflare.com": { name: "Cloudflare Turnstile", category: "captcha" },
1255
+ "stripe.com": { name: "Stripe", category: "payments" },
1256
+ "js.stripe.com": { name: "Stripe Checkout", category: "payments" },
1257
+ "amazonaws.com": { name: "AWS S3", category: "cdn" },
1258
+ "cloudfront.net": { name: "AWS CloudFront", category: "cdn" },
1259
+ "clarity.ms": { name: "Microsoft Clarity", category: "analytics" },
1260
+ "azureedge.net": { name: "Azure CDN", category: "cdn" },
1261
+ "facebook.com": { name: "Facebook", category: "social" },
1262
+ "facebook.net": { name: "Facebook Pixel", category: "ads" },
1263
+ "fbcdn.net": { name: "Facebook CDN", category: "cdn" },
1264
+ "connect.facebook.net": { name: "Facebook Pixel", category: "ads" },
1265
+ "twitter.com": { name: "Twitter (X)", category: "social" },
1266
+ "linkedin.com": { name: "LinkedIn", category: "social" },
1267
+ "snap.licdn.com": { name: "LinkedIn Tracking", category: "ads" },
1268
+ "use.typekit.net": { name: "Adobe Fonts", category: "fonts" },
1269
+ "typekit.net": { name: "Adobe Fonts", category: "fonts" },
1270
+ "paypal.com": { name: "PayPal", category: "payments" },
1271
+ "auth0.com": { name: "Auth0", category: "auth" },
1272
+ "okta.com": { name: "Okta", category: "auth" },
1273
+ "intercomcdn.com": { name: "Intercom", category: "support" },
1274
+ "zendesk.com": { name: "Zendesk", category: "support" },
1275
+ "sentry.io": { name: "Sentry", category: "errors" },
1276
+ "browser.sentry-cdn.com": { name: "Sentry", category: "errors" },
1277
+ "js-agent.newrelic.com": { name: "New Relic", category: "errors" },
1278
+ "bam.nr-data.net": { name: "New Relic", category: "errors" },
1279
+ "datadoghq.com": { name: "Datadog", category: "errors" },
1280
+ "browser-intake-datadoghq.com": { name: "Datadog RUM", category: "errors" },
1281
+ "mailchimp.com": { name: "Mailchimp", category: "analytics" },
1282
+ "hubspot.com": { name: "HubSpot", category: "analytics" },
1283
+ "js.hubspot.com": { name: "HubSpot Tracking", category: "analytics" },
1284
+ "segment.com": { name: "Segment", category: "analytics" },
1285
+ "amplitude.com": { name: "Amplitude", category: "analytics" },
1286
+ "mxpnl.com": { name: "Mixpanel", category: "analytics" },
1287
+ "hotjar.com": { name: "Hotjar", category: "analytics" },
1288
+ "plausible.io": { name: "Plausible Analytics", category: "analytics" },
1289
+ "js.hcaptcha.com": { name: "hCaptcha", category: "captcha" },
1290
+ "cdn.jsdelivr.net": { name: "jsDelivr CDN", category: "cdn" },
1291
+ "unpkg.com": { name: "unpkg CDN", category: "cdn" },
1292
+ "code.jquery.com": { name: "jQuery CDN", category: "cdn" },
1293
+ "akamaihd.net": { name: "Akamai CDN", category: "cdn" },
1294
+ "akamaized.net": { name: "Akamai CDN", category: "cdn" },
1295
+ "fastly.net": { name: "Fastly CDN", category: "cdn" },
1296
+ "bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
1297
+ "maxcdn.bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
1298
+ "cookielaw.org": { name: "OneTrust Cookie Consent", category: "consent" },
1299
+ "cookiebot.com": { name: "Cookiebot", category: "consent" },
1300
+ "vimeo.com": { name: "Vimeo", category: "social" },
1301
+ "shopify.com": { name: "Shopify", category: "ecommerce" },
1302
+ "cdn.shopify.com": { name: "Shopify CDN", category: "ecommerce" },
1303
+ "tiktok.com": { name: "TikTok", category: "social" },
1304
+ };
1305
+
1306
+ function classifyVendor(host) {
1307
+ if (!host) return null;
1308
+ const lower = host.toLowerCase();
1309
+ if (SERVICE_CATALOG[lower]) return SERVICE_CATALOG[lower];
1310
+ for (const pattern of Object.keys(SERVICE_CATALOG)) {
1311
+ if (lower === pattern || lower.endsWith("." + pattern)) {
1312
+ return SERVICE_CATALOG[pattern];
1313
+ }
1314
+ }
1315
+ return null;
1316
+ }
1317
+
1071
1318
  function extractThirdPartyRefs(html, targetDomain) {
1072
1319
  const out = [];
1073
- // Patterns: <tag ... attr="..."> — non-greedy, single or double quoted
1074
- const patterns = [
1075
- { type: "script", re: /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
1320
+ const targetRoot = registeredDomain(targetDomain);
1321
+
1322
+ // For <script> tags specifically, capture the FULL tag so we can extract
1323
+ // the integrity attribute (SRI). Other tag types use simple src/href match.
1324
+ const scriptTagRe = /<script\b([^>]*)>/gi;
1325
+ let m;
1326
+ while ((m = scriptTagRe.exec(html)) !== null) {
1327
+ const attrs = m[1] || "";
1328
+ const srcMatch = attrs.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
1329
+ if (!srcMatch) continue;
1330
+ const integrityMatch = attrs.match(/\bintegrity\s*=\s*["']([^"']+)["']/i);
1331
+ try {
1332
+ const u = new URL(srcMatch[1], `https://${targetDomain}`);
1333
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
1334
+ const host = u.hostname.toLowerCase();
1335
+ if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
1336
+ out.push({
1337
+ host,
1338
+ type: "script",
1339
+ sri: !!(integrityMatch && integrityMatch[1].trim()),
1340
+ loadedOverHttps: u.protocol === "https:",
1341
+ });
1342
+ } catch { /* relative URL or malformed */ }
1343
+ }
1344
+
1345
+ // Non-script types: iframe / link / img — no SRI applies
1346
+ const otherPatterns = [
1076
1347
  { type: "iframe", re: /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
1077
1348
  { type: "link", re: /<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi },
1078
1349
  { type: "img", re: /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
1079
1350
  ];
1080
- const targetRoot = registeredDomain(targetDomain);
1081
- for (const { type, re } of patterns) {
1082
- let m;
1083
- while ((m = re.exec(html)) !== null) {
1351
+ for (const { type, re } of otherPatterns) {
1352
+ let mm;
1353
+ while ((mm = re.exec(html)) !== null) {
1084
1354
  try {
1085
- const u = new URL(m[1], `https://${targetDomain}`);
1355
+ const u = new URL(mm[1], `https://${targetDomain}`);
1086
1356
  if (u.protocol !== "http:" && u.protocol !== "https:") continue;
1087
1357
  const host = u.hostname.toLowerCase();
1088
1358
  if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
1089
- out.push({ host, type });
1359
+ out.push({ host, type, sri: false, loadedOverHttps: u.protocol === "https:" });
1090
1360
  } catch { /* relative URL or malformed */ }
1091
1361
  }
1092
1362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Decryption Blast Radius scanner — find out how much of your data unlocks when quantum decryption arrives.",
5
5
  "keywords": [
6
6
  "post-quantum",