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.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/bin/pqcheck.js +180 -20
  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
 
@@ -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 limit:** ~60 requests/minute per IP. No API key required. Returns HTTP 429 if exceeded — back off and retry.
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.6";
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
- const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
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.0",
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 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*") : ""));
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
- // Patterns: <tag ... attr="..."> — non-greedy, single or double quoted
1056
- const patterns = [
1057
- { 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 = [
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 targetRoot = registeredDomain(targetDomain);
1063
- for (const { type, re } of patterns) {
1064
- let m;
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(m[1], `https://${targetDomain}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.7.6",
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",