pqcheck 0.7.6 → 0.7.8
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 +13 -3
- package/bin/pqcheck.js +180 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ 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.8
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**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
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -81,6 +81,9 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
|
|
|
81
81
|
- `-o <dir>` — Output directory for `--lock` files
|
|
82
82
|
- `--max=<N>` — Max third parties to scan (default 20)
|
|
83
83
|
- `--allowlist <file>` — Exit **3** if any third-party not in allowlist (CI vendor-risk gate)
|
|
84
|
+
- `--baseline <file>` — Compare current hosts to baseline JSON; flag `*NEW*` and surface `isNew` in JSON output
|
|
85
|
+
- `--write-baseline` — Overwrite `--baseline` file with current scan (use once to capture initial state)
|
|
86
|
+
- `--fail-on-new` — Exit **4** if any new hosts appeared since baseline (CI supply-chain change gate)
|
|
84
87
|
|
|
85
88
|
**`pqcheck lock`:**
|
|
86
89
|
- `-o <dir>` — Output directory
|
|
@@ -98,6 +101,7 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
|
|
|
98
101
|
| `1` | Usage / network / scan error |
|
|
99
102
|
| `2` | Score met or exceeded `--threshold`, or `diff` detected regression |
|
|
100
103
|
| `3` | Allowlist violation (`pqcheck deps --allowlist`) |
|
|
104
|
+
| `4` | Supply-chain change detected — new host(s) since baseline (`pqcheck deps --fail-on-new`) |
|
|
101
105
|
|
|
102
106
|
## Examples
|
|
103
107
|
|
|
@@ -120,6 +124,12 @@ npx pqcheck deps mycompany.com --lock
|
|
|
120
124
|
# Vendor-risk CI gate — fail PR if any third-party not in allowlist
|
|
121
125
|
npx pqcheck deps mycompany.com --allowlist allowed-vendors.txt
|
|
122
126
|
|
|
127
|
+
# Capture initial supply-chain baseline (run once, commit the JSON file)
|
|
128
|
+
npx pqcheck deps mycompany.com --baseline .pqcheck-baseline.json --write-baseline
|
|
129
|
+
|
|
130
|
+
# Supply-chain change gate — fail PR if any new third-party script appeared since baseline
|
|
131
|
+
npx pqcheck deps mycompany.com --baseline .pqcheck-baseline.json --fail-on-new
|
|
132
|
+
|
|
123
133
|
# Score history sparkline
|
|
124
134
|
npx pqcheck history mycompany.com
|
|
125
135
|
|
|
@@ -185,7 +195,7 @@ curl -s "https://www.quantapact.com/api/scan?domain=stripe.com" | jq '.grade, .s
|
|
|
185
195
|
|
|
186
196
|
Full API reference at [quantapact.com/api](https://quantapact.com/api).
|
|
187
197
|
|
|
188
|
-
**Rate
|
|
198
|
+
**Rate limits:** 300 scans per hour per IP, 20 `--fresh` (force-refresh) scans per hour per IP. No API key required. Returns HTTP 429 if exceeded — back off and retry, or [let us know via the feedback form](https://quantapact.com/feedback) if you need higher limits (we're prioritizing the API tier based on real demand).
|
|
189
199
|
|
|
190
200
|
## Methodology
|
|
191
201
|
|
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.8";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -107,20 +107,31 @@ async function main() {
|
|
|
107
107
|
return; // runWatch handles its own exit; in practice it runs until killed.
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// --fresh: bypass server cache, force a fresh scan. Useful when verifying
|
|
111
|
+
// a cert/key change you just deployed. Subject to a 20/hr per-IP cap on
|
|
112
|
+
// the server side.
|
|
113
|
+
const fresh = args.includes("--fresh") || args.includes("--force");
|
|
114
|
+
|
|
110
115
|
// One-shot scan(s)
|
|
111
116
|
let worstExit = 0;
|
|
112
117
|
for (const domain of domains) {
|
|
113
|
-
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1 });
|
|
118
|
+
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh });
|
|
114
119
|
if (exit > worstExit) worstExit = exit;
|
|
115
120
|
}
|
|
116
121
|
process.exit(worstExit);
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi }) {
|
|
120
|
-
if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain} ...`));
|
|
124
|
+
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh }) {
|
|
125
|
+
if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain}${fresh ? " (forcing fresh)" : ""} ...`));
|
|
121
126
|
let report;
|
|
122
127
|
try {
|
|
123
|
-
|
|
128
|
+
// --fresh appends ?force=1 to bypass the smart-cache. Use when verifying
|
|
129
|
+
// a cert/key change you just deployed — otherwise scans hit the 1h SWR
|
|
130
|
+
// cache and return up-to-1h-old data. Subject to a 20/hr per-IP cap on
|
|
131
|
+
// the server side; if exceeded, the server silently downgrades to a
|
|
132
|
+
// cached scan and returns that instead of erroring.
|
|
133
|
+
const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
|
|
134
|
+
const resp = await fetch(`${API_BASE}/api/scan${qs}`, {
|
|
124
135
|
method: "GET",
|
|
125
136
|
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (scan)` },
|
|
126
137
|
});
|
|
@@ -129,6 +140,12 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi
|
|
|
129
140
|
const errBody = await safeJSON(resp);
|
|
130
141
|
console.error(color("red", `error scanning ${domain}: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
131
142
|
if (errBody?.detail) console.error(color("dim", errBody.detail));
|
|
143
|
+
// Surface the 429 upsell hint if present — tells users how to ask for
|
|
144
|
+
// higher limits via the feedback form. Same demand signal we capture
|
|
145
|
+
// on the homepage.
|
|
146
|
+
if (resp.status === 429 && errBody?.need_more?.feedback_url) {
|
|
147
|
+
console.error(color("dim", `${errBody.need_more.message} → ${errBody.need_more.feedback_url}`));
|
|
148
|
+
}
|
|
132
149
|
return 1;
|
|
133
150
|
}
|
|
134
151
|
report = await resp.json();
|
|
@@ -547,6 +564,7 @@ ${color("bold", "Common flags:")}
|
|
|
547
564
|
-q, --quiet Print only the numeric score
|
|
548
565
|
--watch [seconds] Poll every N seconds (default 300) and report changes
|
|
549
566
|
--webhook <url> POST scan results to a URL (one-shot or each watch tick)
|
|
567
|
+
--fresh Bypass server cache, force a fresh scan (subject to 20/hr per-IP cap)
|
|
550
568
|
|
|
551
569
|
${color("bold", "Subcommand-specific:")}
|
|
552
570
|
pqcheck deps:
|
|
@@ -554,6 +572,9 @@ ${color("bold", "Subcommand-specific:")}
|
|
|
554
572
|
-o <dir> Output directory for --lock files
|
|
555
573
|
--max=<N> Max third parties to scan (default 20)
|
|
556
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)
|
|
557
578
|
pqcheck lock:
|
|
558
579
|
-o <dir> Output directory
|
|
559
580
|
--stdout Print JSON to stdout instead of writing files
|
|
@@ -566,12 +587,15 @@ ${color("bold", "Exit codes:")}
|
|
|
566
587
|
1 usage / network / scan error
|
|
567
588
|
2 score met or exceeded --threshold (or diff regression)
|
|
568
589
|
3 allowlist violation (deps --allowlist)
|
|
590
|
+
4 supply-chain change detected — new host(s) since baseline (deps --fail-on-new)
|
|
569
591
|
|
|
570
592
|
${color("bold", "Examples:")}
|
|
571
593
|
npx pqcheck chase.com
|
|
572
594
|
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
|
|
573
595
|
npx pqcheck deps stripe.com --lock
|
|
574
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")}
|
|
575
599
|
npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
|
|
576
600
|
npx pqcheck history quantapact.com
|
|
577
601
|
npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
|
|
@@ -869,6 +893,36 @@ async function runDepsCommand(args) {
|
|
|
869
893
|
}
|
|
870
894
|
}
|
|
871
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
|
+
|
|
872
926
|
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
873
927
|
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
874
928
|
if (!domain || !isValidDomain(domain)) {
|
|
@@ -901,8 +955,12 @@ async function runDepsCommand(args) {
|
|
|
901
955
|
// Group by host, dedupe
|
|
902
956
|
const byHost = new Map();
|
|
903
957
|
for (const r of refs) {
|
|
904
|
-
if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0 });
|
|
958
|
+
if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0, anyMissingSri: false, allHttps: true });
|
|
905
959
|
const e = byHost.get(r.host);
|
|
960
|
+
// SRI status — host is flagged "no SRI" if ANY reference to it lacks integrity.
|
|
961
|
+
// Only counts for script type (iframes/links/imgs don't support SRI).
|
|
962
|
+
if (r.type === "script" && !r.sri) e.anyMissingSri = true;
|
|
963
|
+
if (!r.loadedOverHttps) e.allHttps = false;
|
|
906
964
|
e.types.add(r.type);
|
|
907
965
|
e.occurrences += 1;
|
|
908
966
|
}
|
|
@@ -952,19 +1010,46 @@ async function runDepsCommand(args) {
|
|
|
952
1010
|
|
|
953
1011
|
const summary = buildDepsSummary(results);
|
|
954
1012
|
|
|
1013
|
+
// Baseline diff (v0.7.8+): compare current hosts to baseline hosts.
|
|
1014
|
+
// newHosts = hosts in current scan that weren't in the baseline.
|
|
1015
|
+
// missingHosts = hosts in baseline that aren't in the current scan.
|
|
1016
|
+
// Each result gets a `.isNew` flag for downstream rendering.
|
|
1017
|
+
let newHosts = [];
|
|
1018
|
+
let missingHosts = [];
|
|
1019
|
+
if (baselineHosts) {
|
|
1020
|
+
const currentHostSet = new Set(results.map((r) => r.host));
|
|
1021
|
+
newHosts = results.map((r) => r.host).filter((h) => !baselineHosts.has(h));
|
|
1022
|
+
missingHosts = Array.from(baselineHosts).filter((h) => !currentHostSet.has(h));
|
|
1023
|
+
// Don't paint every row as *NEW* on the first run (empty baseline) — there
|
|
1024
|
+
// is no prior state to be new relative to. The summary line still says
|
|
1025
|
+
// "first run; all hosts will be captured" so the user knows.
|
|
1026
|
+
const baselineIsFirstRun = baselineHosts.size === 0;
|
|
1027
|
+
for (const r of results) {
|
|
1028
|
+
r.isNew = baselineIsFirstRun ? false : !baselineHosts.has(r.host);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
955
1032
|
// Build manifest
|
|
956
1033
|
const manifest = {
|
|
957
1034
|
$schema: "https://quantapact.com/schemas/deps/v1",
|
|
958
|
-
schemaVersion: "1.
|
|
1035
|
+
schemaVersion: "1.1", // bumped for SRI + baseline-diff fields
|
|
959
1036
|
domain,
|
|
960
1037
|
scannedAt: new Date().toISOString(),
|
|
961
1038
|
tool: "pqcheck-cli",
|
|
962
1039
|
toolVersion: VERSION,
|
|
963
1040
|
summary,
|
|
1041
|
+
baseline: baselineHosts ? {
|
|
1042
|
+
file: baselinePath,
|
|
1043
|
+
newHosts,
|
|
1044
|
+
missingHosts,
|
|
1045
|
+
isFirstRun: baselineHosts.size === 0,
|
|
1046
|
+
} : null,
|
|
964
1047
|
thirdParties: results.map((r) => ({
|
|
965
1048
|
host: r.host,
|
|
966
1049
|
types: r.types,
|
|
967
1050
|
occurrences: r.occurrences,
|
|
1051
|
+
sri: { allScriptsHaveSri: !r.anyMissingSri, allHttps: r.allHttps },
|
|
1052
|
+
isNew: r.isNew || false,
|
|
968
1053
|
scan: r.scan,
|
|
969
1054
|
error: r.error,
|
|
970
1055
|
})),
|
|
@@ -974,6 +1059,39 @@ async function runDepsCommand(args) {
|
|
|
974
1059
|
},
|
|
975
1060
|
};
|
|
976
1061
|
|
|
1062
|
+
// --write-baseline: overwrite the baseline file with current hosts. Used
|
|
1063
|
+
// after deliberately adding a vendor (e.g., 'we added Stripe, update the
|
|
1064
|
+
// baseline so the next scan doesn't flag Stripe as new').
|
|
1065
|
+
if (writeBaseline && baselinePath) {
|
|
1066
|
+
try {
|
|
1067
|
+
const fs2 = await import("node:fs/promises");
|
|
1068
|
+
const baselinePayload = {
|
|
1069
|
+
$schema: "https://quantapact.com/schemas/deps-baseline/v1",
|
|
1070
|
+
domain,
|
|
1071
|
+
capturedAt: new Date().toISOString(),
|
|
1072
|
+
toolVersion: VERSION,
|
|
1073
|
+
thirdParties: results.map((r) => ({ host: r.host, sri: !r.anyMissingSri })),
|
|
1074
|
+
};
|
|
1075
|
+
await fs2.writeFile(baselinePath, JSON.stringify(baselinePayload, null, 2) + "\n", "utf8");
|
|
1076
|
+
if (!json) console.error(color("dim", `✓ wrote baseline to ${baselinePath} (${results.length} hosts)`));
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
console.error(color("red", `error writing --baseline: ${err.message}`));
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// --fail-on-new: exit code 4 if any new hosts appeared since the baseline.
|
|
1084
|
+
// The CI gate for supply-chain change detection.
|
|
1085
|
+
if (failOnNew && baselineHosts && newHosts.length > 0 && baselineHosts.size > 0) {
|
|
1086
|
+
if (!json) {
|
|
1087
|
+
console.error(color("red", `\nFAIL: ${newHosts.length} new third-party host${newHosts.length > 1 ? "s" : ""} appeared since baseline:`));
|
|
1088
|
+
for (const h of newHosts) console.error(color("red", ` + ${h}`));
|
|
1089
|
+
console.error(color("dim", `\n Use --write-baseline to accept these additions, or audit them as a potential supply-chain change.`));
|
|
1090
|
+
}
|
|
1091
|
+
if (json) console.log(JSON.stringify(manifest, null, 2));
|
|
1092
|
+
process.exit(4);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
977
1095
|
if (json) {
|
|
978
1096
|
console.log(JSON.stringify(manifest, null, 2));
|
|
979
1097
|
return;
|
|
@@ -983,18 +1101,37 @@ async function runDepsCommand(args) {
|
|
|
983
1101
|
console.log("");
|
|
984
1102
|
console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
|
|
985
1103
|
console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
|
|
1104
|
+
if (baselineHosts) {
|
|
1105
|
+
const baselineSize = baselineHosts.size;
|
|
1106
|
+
if (baselineSize === 0) {
|
|
1107
|
+
console.log(` ${color("dim", `Baseline: ${baselinePath} is empty — first run; all hosts will be captured.`)}`);
|
|
1108
|
+
} else {
|
|
1109
|
+
const newColor = newHosts.length > 0 ? "yellow" : "green";
|
|
1110
|
+
const newLabel = newHosts.length > 0 ? `${newHosts.length} NEW since baseline` : "no new hosts since baseline";
|
|
1111
|
+
const missingPart = missingHosts.length > 0 ? `, ${missingHosts.length} missing` : "";
|
|
1112
|
+
console.log(` ${color(newColor, newLabel)}${color("dim", `${missingPart} · baseline ${baselinePath}`)}`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
986
1115
|
console.log("");
|
|
987
|
-
console.log(` ${color("dim", "GRADE HOST PQC TYPES")}`);
|
|
988
|
-
console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─────")}`);
|
|
1116
|
+
console.log(` ${color("dim", "GRADE HOST PQC SRI TYPES")}`);
|
|
1117
|
+
console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─── ─────")}`);
|
|
989
1118
|
for (const r of results) {
|
|
990
1119
|
const gradeStr = r.scan?.grade ?? "?";
|
|
991
1120
|
const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
|
|
992
|
-
const
|
|
1121
|
+
const isNewMark = r.isNew ? color("yellow", " *NEW*") : "";
|
|
1122
|
+
const hostRaw = r.host + (r.isNew ? " *NEW*" : "");
|
|
1123
|
+
const host = hostRaw.length > 41 ? hostRaw.slice(0, 40) + "…" : (r.host.padEnd(r.isNew ? 35 : 41, " ") + (r.isNew ? color("yellow", " *NEW*") : ""));
|
|
993
1124
|
const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
|
|
1125
|
+
const hasScript = (r.types || []).includes("script");
|
|
1126
|
+
const sriCell = !hasScript ? color("dim", "n/a") : r.anyMissingSri ? color("yellow", "off") : color("green", "on ");
|
|
994
1127
|
const types = r.types.join(",");
|
|
995
|
-
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${color("dim", types)}`);
|
|
1128
|
+
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${sriCell} ${color("dim", types)}`);
|
|
996
1129
|
}
|
|
997
1130
|
console.log("");
|
|
1131
|
+
if (baselineHosts && baselineHosts.size > 0 && newHosts.length > 0) {
|
|
1132
|
+
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.`)}`);
|
|
1133
|
+
console.log("");
|
|
1134
|
+
}
|
|
998
1135
|
console.log(` ${color("dim", "Each row scanned via")} ${color("violet", "/api/scan")}${color("dim", " · /methodology/browser-extension explains scoring")}`);
|
|
999
1136
|
console.log("");
|
|
1000
1137
|
|
|
@@ -1052,23 +1189,46 @@ async function fetchPageHTML(domain) {
|
|
|
1052
1189
|
|
|
1053
1190
|
function extractThirdPartyRefs(html, targetDomain) {
|
|
1054
1191
|
const out = [];
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1192
|
+
const targetRoot = registeredDomain(targetDomain);
|
|
1193
|
+
|
|
1194
|
+
// For <script> tags specifically, capture the FULL tag so we can extract
|
|
1195
|
+
// the integrity attribute (SRI). Other tag types use simple src/href match.
|
|
1196
|
+
const scriptTagRe = /<script\b([^>]*)>/gi;
|
|
1197
|
+
let m;
|
|
1198
|
+
while ((m = scriptTagRe.exec(html)) !== null) {
|
|
1199
|
+
const attrs = m[1] || "";
|
|
1200
|
+
const srcMatch = attrs.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
|
|
1201
|
+
if (!srcMatch) continue;
|
|
1202
|
+
const integrityMatch = attrs.match(/\bintegrity\s*=\s*["']([^"']+)["']/i);
|
|
1203
|
+
try {
|
|
1204
|
+
const u = new URL(srcMatch[1], `https://${targetDomain}`);
|
|
1205
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") continue;
|
|
1206
|
+
const host = u.hostname.toLowerCase();
|
|
1207
|
+
if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
|
|
1208
|
+
out.push({
|
|
1209
|
+
host,
|
|
1210
|
+
type: "script",
|
|
1211
|
+
sri: !!(integrityMatch && integrityMatch[1].trim()),
|
|
1212
|
+
loadedOverHttps: u.protocol === "https:",
|
|
1213
|
+
});
|
|
1214
|
+
} catch { /* relative URL or malformed */ }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Non-script types: iframe / link / img — no SRI applies
|
|
1218
|
+
const otherPatterns = [
|
|
1058
1219
|
{ type: "iframe", re: /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
|
|
1059
1220
|
{ type: "link", re: /<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi },
|
|
1060
1221
|
{ type: "img", re: /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
|
|
1061
1222
|
];
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
while ((m = re.exec(html)) !== null) {
|
|
1223
|
+
for (const { type, re } of otherPatterns) {
|
|
1224
|
+
let mm;
|
|
1225
|
+
while ((mm = re.exec(html)) !== null) {
|
|
1066
1226
|
try {
|
|
1067
|
-
const u = new URL(
|
|
1227
|
+
const u = new URL(mm[1], `https://${targetDomain}`);
|
|
1068
1228
|
if (u.protocol !== "http:" && u.protocol !== "https:") continue;
|
|
1069
1229
|
const host = u.hostname.toLowerCase();
|
|
1070
1230
|
if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
|
|
1071
|
-
out.push({ host, type });
|
|
1231
|
+
out.push({ host, type, sri: false, loadedOverHttps: u.protocol === "https:" });
|
|
1072
1232
|
} catch { /* relative URL or malformed */ }
|
|
1073
1233
|
}
|
|
1074
1234
|
}
|