pqcheck 0.7.7 → 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.
Files changed (3) hide show
  1. package/README.md +12 -2
  2. package/bin/pqcheck.js +158 -16
  3. 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.6
15
+ ## What's new in 0.7.8
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
+ **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
 
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.8";
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)) {
@@ -919,8 +955,12 @@ async function runDepsCommand(args) {
919
955
  // Group by host, dedupe
920
956
  const byHost = new Map();
921
957
  for (const r of refs) {
922
- 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 });
923
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;
924
964
  e.types.add(r.type);
925
965
  e.occurrences += 1;
926
966
  }
@@ -970,19 +1010,46 @@ async function runDepsCommand(args) {
970
1010
 
971
1011
  const summary = buildDepsSummary(results);
972
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
+
973
1032
  // Build manifest
974
1033
  const manifest = {
975
1034
  $schema: "https://quantapact.com/schemas/deps/v1",
976
- schemaVersion: "1.0",
1035
+ schemaVersion: "1.1", // bumped for SRI + baseline-diff fields
977
1036
  domain,
978
1037
  scannedAt: new Date().toISOString(),
979
1038
  tool: "pqcheck-cli",
980
1039
  toolVersion: VERSION,
981
1040
  summary,
1041
+ baseline: baselineHosts ? {
1042
+ file: baselinePath,
1043
+ newHosts,
1044
+ missingHosts,
1045
+ isFirstRun: baselineHosts.size === 0,
1046
+ } : null,
982
1047
  thirdParties: results.map((r) => ({
983
1048
  host: r.host,
984
1049
  types: r.types,
985
1050
  occurrences: r.occurrences,
1051
+ sri: { allScriptsHaveSri: !r.anyMissingSri, allHttps: r.allHttps },
1052
+ isNew: r.isNew || false,
986
1053
  scan: r.scan,
987
1054
  error: r.error,
988
1055
  })),
@@ -992,6 +1059,39 @@ async function runDepsCommand(args) {
992
1059
  },
993
1060
  };
994
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
+
995
1095
  if (json) {
996
1096
  console.log(JSON.stringify(manifest, null, 2));
997
1097
  return;
@@ -1001,18 +1101,37 @@ async function runDepsCommand(args) {
1001
1101
  console.log("");
1002
1102
  console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
1003
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
+ }
1004
1115
  console.log("");
1005
- console.log(` ${color("dim", "GRADE HOST PQC TYPES")}`);
1006
- console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─────")}`);
1116
+ console.log(` ${color("dim", "GRADE HOST PQC SRI TYPES")}`);
1117
+ console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─── ─────")}`);
1007
1118
  for (const r of results) {
1008
1119
  const gradeStr = r.scan?.grade ?? "?";
1009
1120
  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, " ");
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*") : ""));
1011
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 ");
1012
1127
  const types = r.types.join(",");
1013
- console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${color("dim", types)}`);
1128
+ console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${sriCell} ${color("dim", types)}`);
1014
1129
  }
1015
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
+ }
1016
1135
  console.log(` ${color("dim", "Each row scanned via")} ${color("violet", "/api/scan")}${color("dim", " · /methodology/browser-extension explains scoring")}`);
1017
1136
  console.log("");
1018
1137
 
@@ -1070,23 +1189,46 @@ async function fetchPageHTML(domain) {
1070
1189
 
1071
1190
  function extractThirdPartyRefs(html, targetDomain) {
1072
1191
  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 },
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 = [
1076
1219
  { type: "iframe", re: /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
1077
1220
  { type: "link", re: /<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi },
1078
1221
  { type: "img", re: /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
1079
1222
  ];
1080
- const targetRoot = registeredDomain(targetDomain);
1081
- for (const { type, re } of patterns) {
1082
- let m;
1083
- while ((m = re.exec(html)) !== null) {
1223
+ for (const { type, re } of otherPatterns) {
1224
+ let mm;
1225
+ while ((mm = re.exec(html)) !== null) {
1084
1226
  try {
1085
- const u = new URL(m[1], `https://${targetDomain}`);
1227
+ const u = new URL(mm[1], `https://${targetDomain}`);
1086
1228
  if (u.protocol !== "http:" && u.protocol !== "https:") continue;
1087
1229
  const host = u.hostname.toLowerCase();
1088
1230
  if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
1089
- out.push({ host, type });
1231
+ out.push({ host, type, sri: false, loadedOverHttps: u.protocol === "https:" });
1090
1232
  } catch { /* relative URL or malformed */ }
1091
1233
  }
1092
1234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
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",