pqcheck 0.5.0 → 0.6.0

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 (2) hide show
  1. package/bin/pqcheck.js +309 -3
  2. package/package.json +1 -1
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.5.0";
10
+ const VERSION = "0.6.0";
11
11
 
12
12
  const ANSI = {
13
13
  reset: "\x1b[0m",
@@ -34,11 +34,13 @@ async function main() {
34
34
  process.exit(0);
35
35
  }
36
36
 
37
- // Subcommand dispatch — currently only "lock" (QXM artifact generator).
38
- // Anything else is treated as the default scan command.
37
+ // Subcommand dispatch.
39
38
  if (args[0] === "lock") {
40
39
  return runLockCommand(args.slice(1));
41
40
  }
41
+ if (args[0] === "deps") {
42
+ return runDepsCommand(args.slice(1));
43
+ }
42
44
 
43
45
  // Multi-domain support: any non-flag positional arg is a domain
44
46
  const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
@@ -464,6 +466,7 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
464
466
  ${color("bold", "Usage:")}
465
467
  npx pqcheck <domain> Scan + print human-readable report
466
468
  npx pqcheck lock <domain> Generate quantapact.lock (QXM) for repo commit
469
+ npx pqcheck deps <domain> Scan third-party origins (supply-chain HNDL); --lock for committable manifest
467
470
  npx pqcheck a.com b.com c.com Multi-domain scan
468
471
  npx pqcheck <domain> --format json Raw JSON
469
472
  npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
@@ -739,6 +742,309 @@ function renderQxmMarkdown(m) {
739
742
  return lines.join("\n");
740
743
  }
741
744
 
745
+ // =============================================================================
746
+ // `pqcheck deps` — supply-chain HNDL scan for a target domain
747
+ // =============================================================================
748
+ // Fetches the public HTML of the target domain, extracts third-party origins
749
+ // referenced via <script src>, <iframe src>, <link href>, <img src>, then runs
750
+ // /api/scan against each unique third party. Outputs a sorted summary + an
751
+ // optional committable lockfile (quantapact-deps.lock).
752
+ //
753
+ // Parallel to the browser extension's Dependencies tab, exposed as a CLI for
754
+ // CI integration: gate PR builds on third-party crypto posture.
755
+ //
756
+ // Usage:
757
+ // npx pqcheck deps <domain> Scan + print summary table
758
+ // npx pqcheck deps <domain> --json JSON output (pipe to jq, etc.)
759
+ // npx pqcheck deps <domain> --lock Also write quantapact-deps.lock + .md
760
+ // npx pqcheck deps <domain> -o dir/ Output directory for --lock files
761
+ // npx pqcheck deps <domain> --max=20 Cap on third parties scanned (default 20)
762
+ // =============================================================================
763
+
764
+ async function runDepsCommand(args) {
765
+ const fs = await import("node:fs/promises");
766
+ const path = await import("node:path");
767
+
768
+ const json = args.includes("--json");
769
+ const lock = args.includes("--lock");
770
+ const outIdx = args.indexOf("-o");
771
+ const outDir = outIdx >= 0 ? args[outIdx + 1] : ".";
772
+ const maxArg = args.find((a) => a.startsWith("--max="));
773
+ const maxThirdParties = maxArg ? Math.max(1, parseInt(maxArg.slice(6), 10) || 20) : 20;
774
+
775
+ const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
776
+ const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
777
+ if (!domain || !isValidDomain(domain)) {
778
+ console.error(color("red", "error: pqcheck deps requires a valid domain"));
779
+ console.error(color("dim", "Usage: npx pqcheck deps <domain> [--json|--lock] [-o dir/] [--max=N]"));
780
+ process.exit(1);
781
+ }
782
+
783
+ if (!json) process.stderr.write(color("dim", `Fetching ${domain} HTML...`));
784
+ const html = await fetchPageHTML(domain);
785
+ if (!json) process.stderr.write("\r\x1b[K");
786
+ if (!html) {
787
+ console.error(color("red", `error: could not fetch https://${domain}/`));
788
+ process.exit(1);
789
+ }
790
+
791
+ const refs = extractThirdPartyRefs(html, domain);
792
+ if (refs.length === 0) {
793
+ if (json) {
794
+ console.log(JSON.stringify({ domain, scannedAt: new Date().toISOString(), thirdParties: [], summary: { uniqueOrigins: 0, totalReferences: 0 } }, null, 2));
795
+ } else {
796
+ console.log("");
797
+ console.log(` ${color("violet", domain)} ${color("dim", "·")} ${color("bold", "no third-party origins detected")}`);
798
+ console.log(color("dim", " (page is fully first-party, or HTML didn't load script/iframe/link refs)"));
799
+ console.log("");
800
+ }
801
+ return;
802
+ }
803
+
804
+ // Group by host, dedupe
805
+ const byHost = new Map();
806
+ for (const r of refs) {
807
+ if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0 });
808
+ const e = byHost.get(r.host);
809
+ e.types.add(r.type);
810
+ e.occurrences += 1;
811
+ }
812
+ const uniqueHosts = Array.from(byHost.values()).slice(0, maxThirdParties);
813
+
814
+ if (!json) process.stderr.write(color("dim", `Scanning ${uniqueHosts.length} third-party origins...`));
815
+
816
+ // Scan each host (parallel batches of 4 to avoid hammering the API)
817
+ const BATCH = 4;
818
+ const results = [];
819
+ for (let i = 0; i < uniqueHosts.length; i += BATCH) {
820
+ const batch = uniqueHosts.slice(i, i + BATCH);
821
+ const batchResults = await Promise.all(
822
+ batch.map(async (h) => {
823
+ try {
824
+ const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
825
+ headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (deps)` },
826
+ });
827
+ if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
828
+ const body = await r.json();
829
+ return {
830
+ ...h,
831
+ types: Array.from(h.types),
832
+ scan: {
833
+ grade: body.grade,
834
+ score: body.score,
835
+ reachable: body.reachable,
836
+ hybridPQC: body.publicSurface?.hybridPQC ?? false,
837
+ },
838
+ };
839
+ } catch (e) {
840
+ return { ...h, types: Array.from(h.types), scan: null, error: e.message };
841
+ }
842
+ })
843
+ );
844
+ results.push(...batchResults);
845
+ }
846
+ if (!json) process.stderr.write("\r\x1b[K");
847
+
848
+ // Sort: F first (worst), then D, C, B, A; unreachable/error to bottom
849
+ const gradeRank = { F: 5, D: 4, C: 3, B: 2, A: 1 };
850
+ results.sort((a, b) => {
851
+ const ar = a.scan?.grade ? gradeRank[a.scan.grade] || 0 : -1;
852
+ const br = b.scan?.grade ? gradeRank[b.scan.grade] || 0 : -1;
853
+ return br - ar;
854
+ });
855
+
856
+ const summary = buildDepsSummary(results);
857
+
858
+ // Build manifest
859
+ const manifest = {
860
+ $schema: "https://quantapact.com/schemas/deps/v1",
861
+ schemaVersion: "1.0",
862
+ domain,
863
+ scannedAt: new Date().toISOString(),
864
+ tool: "pqcheck-cli",
865
+ toolVersion: VERSION,
866
+ summary,
867
+ thirdParties: results.map((r) => ({
868
+ host: r.host,
869
+ types: r.types,
870
+ occurrences: r.occurrences,
871
+ scan: r.scan,
872
+ error: r.error,
873
+ })),
874
+ evidence: {
875
+ methodology: `${API_BASE}/methodology/browser-extension`,
876
+ reportLink: `${API_BASE}/r/${domain}`,
877
+ },
878
+ };
879
+
880
+ if (json) {
881
+ console.log(JSON.stringify(manifest, null, 2));
882
+ return;
883
+ }
884
+
885
+ // Pretty terminal table
886
+ console.log("");
887
+ console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
888
+ console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
889
+ console.log("");
890
+ console.log(` ${color("dim", "GRADE HOST PQC TYPES")}`);
891
+ console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─────")}`);
892
+ for (const r of results) {
893
+ const gradeStr = r.scan?.grade ?? "?";
894
+ const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
895
+ const host = r.host.length > 41 ? r.host.slice(0, 40) + "…" : r.host.padEnd(41, " ");
896
+ const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
897
+ const types = r.types.join(",");
898
+ console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${color("dim", types)}`);
899
+ }
900
+ console.log("");
901
+ console.log(` ${color("dim", "Each row scanned via")} ${color("violet", "/api/scan")}${color("dim", " · /methodology/browser-extension explains scoring")}`);
902
+ console.log("");
903
+
904
+ if (lock) {
905
+ const lockPath = path.join(outDir, "quantapact-deps.lock");
906
+ const mdPath = path.join(outDir, "quantapact-deps-report.md");
907
+ try {
908
+ await fs.mkdir(outDir, { recursive: true });
909
+ await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
910
+ await fs.writeFile(mdPath, depsManifestToMarkdown(manifest));
911
+ console.log(` ${color("bold", "Wrote")} ${color("violet", lockPath)} ${color("dim", "and")} ${color("violet", mdPath)}`);
912
+ console.log(` ${color("dim", "Commit these to track third-party crypto posture changes in PR diffs.")}`);
913
+ console.log("");
914
+ } catch (err) {
915
+ console.error(color("red", `error writing lockfile: ${err.message}`));
916
+ process.exit(1);
917
+ }
918
+ }
919
+ }
920
+
921
+ async function fetchPageHTML(domain) {
922
+ try {
923
+ const ctrl = new AbortController();
924
+ const t = setTimeout(() => ctrl.abort(), 8000);
925
+ const resp = await fetch(`https://${domain}/`, {
926
+ method: "GET",
927
+ redirect: "follow",
928
+ signal: ctrl.signal,
929
+ headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://quantapact.com)` },
930
+ });
931
+ clearTimeout(t);
932
+ if (!resp.ok) return null;
933
+ return await resp.text();
934
+ } catch {
935
+ return null;
936
+ }
937
+ }
938
+
939
+ function extractThirdPartyRefs(html, targetDomain) {
940
+ const out = [];
941
+ // Patterns: <tag ... attr="..."> — non-greedy, single or double quoted
942
+ const patterns = [
943
+ { type: "script", re: /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
944
+ { type: "iframe", re: /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
945
+ { type: "link", re: /<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi },
946
+ { type: "img", re: /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
947
+ ];
948
+ const targetRoot = registeredDomain(targetDomain);
949
+ for (const { type, re } of patterns) {
950
+ let m;
951
+ while ((m = re.exec(html)) !== null) {
952
+ try {
953
+ const u = new URL(m[1], `https://${targetDomain}`);
954
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
955
+ const host = u.hostname.toLowerCase();
956
+ if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
957
+ out.push({ host, type });
958
+ } catch { /* relative URL or malformed */ }
959
+ }
960
+ }
961
+ return out;
962
+ }
963
+
964
+ // Cheap registered-domain helper — covers common 2-label TLDs (co.uk, com.au, etc.)
965
+ function registeredDomain(host) {
966
+ const parts = host.split(".");
967
+ if (parts.length <= 2) return host;
968
+ const last2 = parts.slice(-2).join(".");
969
+ const doubleTLDs = new Set([
970
+ "co.uk", "co.jp", "co.nz", "co.za", "com.au", "com.br", "com.cn", "com.mx",
971
+ "com.tr", "ne.jp", "ac.uk", "gov.uk", "org.uk", "edu.au", "gov.au",
972
+ ]);
973
+ if (doubleTLDs.has(last2)) return parts.slice(-3).join(".");
974
+ return last2;
975
+ }
976
+
977
+ function buildDepsSummary(results) {
978
+ const byGrade = { A: 0, B: 0, C: 0, D: 0, F: 0, "?": 0 };
979
+ let totalRefs = 0;
980
+ let scoreSum = 0;
981
+ let scoreN = 0;
982
+ let pqcCount = 0;
983
+ let weakest = null;
984
+ for (const r of results) {
985
+ totalRefs += r.occurrences;
986
+ const g = r.scan?.grade ?? "?";
987
+ byGrade[g] = (byGrade[g] || 0) + 1;
988
+ if (typeof r.scan?.score === "number") {
989
+ scoreSum += r.scan.score;
990
+ scoreN += 1;
991
+ if (!weakest || r.scan.score > weakest.score) {
992
+ weakest = { host: r.host, grade: r.scan.grade, score: r.scan.score };
993
+ }
994
+ }
995
+ if (r.scan?.hybridPQC) pqcCount += 1;
996
+ }
997
+ return {
998
+ uniqueOrigins: results.length,
999
+ totalReferences: totalRefs,
1000
+ byGrade,
1001
+ averageScore: scoreN > 0 ? Math.round((scoreSum / scoreN) * 10) / 10 : null,
1002
+ hybridPQCCount: pqcCount,
1003
+ weakestLink: weakest,
1004
+ };
1005
+ }
1006
+
1007
+ function depsManifestToMarkdown(m) {
1008
+ const lines = [];
1009
+ lines.push(`# Supply-chain HNDL exposure: ${m.domain}`);
1010
+ lines.push("");
1011
+ lines.push(`Scanned at \`${m.scannedAt}\` by \`${m.tool}@${m.toolVersion}\`.`);
1012
+ lines.push("");
1013
+ lines.push("## Summary");
1014
+ lines.push("");
1015
+ lines.push(`- **Unique third-party origins:** ${m.summary.uniqueOrigins}`);
1016
+ lines.push(`- **Total references:** ${m.summary.totalReferences}`);
1017
+ lines.push(`- **Average HNDL score:** ${m.summary.averageScore ?? "—"} / 10`);
1018
+ lines.push(`- **Hybrid-PQC origins:** ${m.summary.hybridPQCCount} / ${m.summary.uniqueOrigins}`);
1019
+ if (m.summary.weakestLink) {
1020
+ lines.push(`- **Weakest link:** \`${m.summary.weakestLink.host}\` — grade ${m.summary.weakestLink.grade}, score ${m.summary.weakestLink.score}`);
1021
+ }
1022
+ lines.push("");
1023
+ lines.push("## Grade distribution");
1024
+ lines.push("");
1025
+ lines.push("| Grade | Count |");
1026
+ lines.push("|---|---|");
1027
+ for (const g of ["A", "B", "C", "D", "F", "?"]) {
1028
+ if ((m.summary.byGrade[g] || 0) > 0) lines.push(`| ${g} | ${m.summary.byGrade[g]} |`);
1029
+ }
1030
+ lines.push("");
1031
+ lines.push("## Third parties");
1032
+ lines.push("");
1033
+ lines.push("| Grade | Host | PQC | Types | Occurrences |");
1034
+ lines.push("|---|---|---|---|---|");
1035
+ for (const tp of m.thirdParties) {
1036
+ const grade = tp.scan?.grade ?? "?";
1037
+ const pqc = tp.scan?.hybridPQC ? "yes" : "no";
1038
+ lines.push(`| ${grade} | \`${tp.host}\` | ${pqc} | ${tp.types.join(", ")} | ${tp.occurrences} |`);
1039
+ }
1040
+ lines.push("");
1041
+ lines.push("---");
1042
+ lines.push("");
1043
+ lines.push("*Methodology: [/methodology/browser-extension](" + m.evidence.methodology + "). Re-run `npx pqcheck deps " + m.domain + " --lock` to refresh; commit the lockfile to track changes in pull requests.*");
1044
+ lines.push("");
1045
+ return lines.join("\n");
1046
+ }
1047
+
742
1048
  main().catch((err) => {
743
1049
  console.error(color("red", `fatal: ${err.message}`));
744
1050
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",