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.
- package/README.md +16 -2
- package/bin/pqcheck.js +289 -19
- 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.
|
|
15
|
+
## What's new in 0.7.9
|
|
16
16
|
|
|
17
|
-
|
|
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.
|
|
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
|
|
935
|
+
const fetched = await fetchPageHTML(domain);
|
|
900
936
|
if (!json) process.stderr.write("\r\x1b[K");
|
|
901
|
-
if (!
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
|
1081
|
-
|
|
1082
|
-
|
|
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(
|
|
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
|
}
|