pqcheck 0.4.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.
- package/README.md +28 -0
- package/bin/pqcheck.js +557 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,13 +61,41 @@ $ npx pqcheck chase.com
|
|
|
61
61
|
|
|
62
62
|
```
|
|
63
63
|
npx pqcheck <domain> Scan and print human-readable report
|
|
64
|
+
npx pqcheck lock <domain> Generate quantapact.lock (QXM artifact for repos)
|
|
64
65
|
npx pqcheck <domain> --format json Output raw JSON for piping / scripting
|
|
66
|
+
npx pqcheck <domain> --format markdown Output GitHub-issue / Slack-ready Markdown
|
|
65
67
|
npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
|
|
66
68
|
npx pqcheck <domain> --quiet Print only the numeric score
|
|
69
|
+
npx pqcheck <domain> --watch [seconds] Continuously poll and report changes
|
|
70
|
+
npx pqcheck <domain> --webhook <url> POST results to a URL on each scan
|
|
67
71
|
npx pqcheck --help Show all options
|
|
68
72
|
npx pqcheck --version Show version
|
|
69
73
|
```
|
|
70
74
|
|
|
75
|
+
### QXM — Quantum Exposure Manifest (commit-able to your repo)
|
|
76
|
+
|
|
77
|
+
Like SBOM, `package-lock.json`, or `cargo audit` outputs — track quantum exposure as a versioned artifact in your repo. Diffs surface real changes in pull requests.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx pqcheck lock yourcompany.com
|
|
81
|
+
# Writes:
|
|
82
|
+
# quantapact.lock — stable JSON manifest
|
|
83
|
+
# quantapact-report.md — human-readable summary (renders on GitHub)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Commit both files. Re-run `npx pqcheck lock` whenever you want to refresh; the diff in the next PR shows what changed (score, findings, key-reuse window).
|
|
87
|
+
|
|
88
|
+
**In CI:**
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
- name: Refresh QXM lockfile
|
|
92
|
+
run: npx pqcheck@latest lock yourcompany.com
|
|
93
|
+
- name: Fail if score regressed
|
|
94
|
+
run: npx pqcheck@latest yourcompany.com --threshold 7
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The lockfile schema is documented at [quantapact.com/schemas/qxm/v1](https://quantapact.com/methodology) and is intended to be stable across CLI versions.
|
|
98
|
+
|
|
71
99
|
### Exit codes
|
|
72
100
|
|
|
73
101
|
| Code | Meaning |
|
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.
|
|
10
|
+
const VERSION = "0.6.0";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -34,6 +34,14 @@ async function main() {
|
|
|
34
34
|
process.exit(0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Subcommand dispatch.
|
|
38
|
+
if (args[0] === "lock") {
|
|
39
|
+
return runLockCommand(args.slice(1));
|
|
40
|
+
}
|
|
41
|
+
if (args[0] === "deps") {
|
|
42
|
+
return runDepsCommand(args.slice(1));
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
// Multi-domain support: any non-flag positional arg is a domain
|
|
38
46
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
39
47
|
const domains = positional
|
|
@@ -440,6 +448,11 @@ function normalizeDomain(raw) {
|
|
|
440
448
|
.split(":")[0];
|
|
441
449
|
}
|
|
442
450
|
|
|
451
|
+
function isValidDomain(d) {
|
|
452
|
+
if (!d || d.length < 4 || d.length > 253) return false;
|
|
453
|
+
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d);
|
|
454
|
+
}
|
|
455
|
+
|
|
443
456
|
async function safeJSON(resp) {
|
|
444
457
|
try { return await resp.json(); } catch { return null; }
|
|
445
458
|
}
|
|
@@ -452,6 +465,8 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
|
452
465
|
|
|
453
466
|
${color("bold", "Usage:")}
|
|
454
467
|
npx pqcheck <domain> Scan + print human-readable report
|
|
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
|
|
455
470
|
npx pqcheck a.com b.com c.com Multi-domain scan
|
|
456
471
|
npx pqcheck <domain> --format json Raw JSON
|
|
457
472
|
npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
|
|
@@ -489,6 +504,547 @@ ${color("violet", "https://quantapact.com")}
|
|
|
489
504
|
`);
|
|
490
505
|
}
|
|
491
506
|
|
|
507
|
+
// =============================================================================
|
|
508
|
+
// `pqcheck lock` — QXM (Quantum Exposure Manifest) generator
|
|
509
|
+
// =============================================================================
|
|
510
|
+
// Generates two files committable to a git repo:
|
|
511
|
+
// quantapact.lock — stable JSON manifest (machine-readable)
|
|
512
|
+
// quantapact-report.md — human-readable summary (renders on GitHub)
|
|
513
|
+
//
|
|
514
|
+
// Like SBOM / package-lock.json / cargo audit / snyk test outputs — devs commit
|
|
515
|
+
// these to track quantum exposure as a first-class technical concern.
|
|
516
|
+
//
|
|
517
|
+
// Usage:
|
|
518
|
+
// npx pqcheck lock <domain> Write to ./quantapact.lock + .md
|
|
519
|
+
// npx pqcheck lock <domain> -o dir/ Write into a specific directory
|
|
520
|
+
// npx pqcheck lock <domain> --stdout Print JSON to stdout (no files)
|
|
521
|
+
// npx pqcheck lock Read domain from existing
|
|
522
|
+
// quantapact.lock if present, else error
|
|
523
|
+
// =============================================================================
|
|
524
|
+
|
|
525
|
+
async function runLockCommand(args) {
|
|
526
|
+
const fs = await import("node:fs/promises");
|
|
527
|
+
const path = await import("node:path");
|
|
528
|
+
const crypto = await import("node:crypto");
|
|
529
|
+
|
|
530
|
+
const stdout = args.includes("--stdout");
|
|
531
|
+
const outIdx = args.indexOf("-o");
|
|
532
|
+
const outDir = outIdx >= 0 ? args[outIdx + 1] : ".";
|
|
533
|
+
|
|
534
|
+
// Find the domain — either positional arg, or read from existing lockfile
|
|
535
|
+
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
536
|
+
let domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
537
|
+
|
|
538
|
+
if (!domain) {
|
|
539
|
+
try {
|
|
540
|
+
const existing = await fs.readFile(path.join(outDir, "quantapact.lock"), "utf8");
|
|
541
|
+
const parsed = JSON.parse(existing);
|
|
542
|
+
domain = parsed.domain;
|
|
543
|
+
if (!stdout) {
|
|
544
|
+
console.error(color("dim", `Re-locking from existing quantapact.lock (domain: ${domain})`));
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
console.error(color("red", "error: no domain provided and no existing quantapact.lock found"));
|
|
548
|
+
console.error(color("dim", "Usage: npx pqcheck lock <domain>"));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!isValidDomain(domain)) {
|
|
554
|
+
console.error(color("red", `error: invalid domain '${domain}'`));
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!stdout) process.stderr.write(color("dim", `Scanning ${domain} for QXM lockfile...`));
|
|
559
|
+
|
|
560
|
+
let report;
|
|
561
|
+
try {
|
|
562
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
563
|
+
method: "GET",
|
|
564
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (lock)` },
|
|
565
|
+
});
|
|
566
|
+
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
567
|
+
if (!resp.ok) {
|
|
568
|
+
const errBody = await safeJSON(resp);
|
|
569
|
+
console.error(color("red", `error: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
report = await resp.json();
|
|
573
|
+
} catch (err) {
|
|
574
|
+
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
575
|
+
console.error(color("red", `error: ${err.message}`));
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const manifest = buildQxmManifest(report, crypto);
|
|
580
|
+
const json = JSON.stringify(manifest, null, 2) + "\n";
|
|
581
|
+
|
|
582
|
+
if (stdout) {
|
|
583
|
+
console.log(json);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Write both files
|
|
588
|
+
const lockPath = path.join(outDir, "quantapact.lock");
|
|
589
|
+
const mdPath = path.join(outDir, "quantapact-report.md");
|
|
590
|
+
const md = renderQxmMarkdown(manifest);
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
594
|
+
await fs.writeFile(lockPath, json);
|
|
595
|
+
await fs.writeFile(mdPath, md);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.error(color("red", `error writing files: ${err.message}`));
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
console.log("");
|
|
602
|
+
console.log(` ${color("bold", "QXM lockfile written")} for ${color("violet", domain)}:`);
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log(` ${color("green", "✓")} ${lockPath}`);
|
|
605
|
+
console.log(` ${color("green", "✓")} ${mdPath}`);
|
|
606
|
+
console.log("");
|
|
607
|
+
console.log(` ${color("dim", "Decryption Blast Radius:")} ${color("bold", manifest.score + " / 10")} (Grade ${manifest.grade}, ${manifest.scoreLabel})`);
|
|
608
|
+
console.log(` ${color("dim", "Findings:")} ${manifest.findings.length} (${manifest.findings.filter((f) => f.severity === "high" || f.severity === "critical").length} high/critical)`);
|
|
609
|
+
console.log("");
|
|
610
|
+
console.log(color("dim", " Commit these to your repo to track quantum exposure as a versioned artifact."));
|
|
611
|
+
console.log(color("dim", " Re-run `npx pqcheck lock` to refresh; diffs surface real changes in PRs."));
|
|
612
|
+
console.log("");
|
|
613
|
+
console.log(color("violet", ` → Verify online: ${API_BASE}/r/${encodeURIComponent(domain)}`));
|
|
614
|
+
console.log("");
|
|
615
|
+
process.exit(0);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function buildQxmManifest(report, crypto) {
|
|
619
|
+
// Stable hash of the underlying scan, useful for dedup + change detection in CI
|
|
620
|
+
const hashInput = JSON.stringify({
|
|
621
|
+
domain: report.domain,
|
|
622
|
+
score: report.score,
|
|
623
|
+
grade: report.grade,
|
|
624
|
+
findings: (report.findings || []).map((f) => ({ s: f.severity, t: f.title })),
|
|
625
|
+
publicSurface: report.publicSurface,
|
|
626
|
+
});
|
|
627
|
+
const evidenceHash = crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 32);
|
|
628
|
+
|
|
629
|
+
// Tessera recommendation classification (waitlist-shape; SDK not yet shipped)
|
|
630
|
+
const tesseraNeeded = (report.findings || []).some((f) =>
|
|
631
|
+
/key reused?|reused for|key persist|rsa fallback|chain weakest|hybrid pqc/i.test(f.title || ""),
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
schema: "https://quantapact.com/schemas/qxm/v1",
|
|
636
|
+
schemaVersion: 1,
|
|
637
|
+
generator: `pqcheck-cli/${VERSION}`,
|
|
638
|
+
generatedAt: report.generatedAt || new Date().toISOString(),
|
|
639
|
+
domain: report.domain,
|
|
640
|
+
reachable: !!report.reachable,
|
|
641
|
+
score: report.score,
|
|
642
|
+
grade: report.grade,
|
|
643
|
+
scoreLabel: report.scoreLabel,
|
|
644
|
+
publicSurface: report.publicSurface || null,
|
|
645
|
+
findings: (report.findings || []).map((f) => ({
|
|
646
|
+
severity: f.severity,
|
|
647
|
+
title: f.title,
|
|
648
|
+
detail: f.detail,
|
|
649
|
+
})),
|
|
650
|
+
impact: report.impact || null,
|
|
651
|
+
sectorRanking: report.sectorRanking || null,
|
|
652
|
+
components: report.components || null,
|
|
653
|
+
evidence: {
|
|
654
|
+
evidenceHash,
|
|
655
|
+
methodology: "https://quantapact.com/methodology",
|
|
656
|
+
shareableReport: `https://quantapact.com/r/${encodeURIComponent(report.domain)}`,
|
|
657
|
+
badge: `https://quantapact.com/badge/${encodeURIComponent(report.domain)}.svg`,
|
|
658
|
+
},
|
|
659
|
+
remediation: {
|
|
660
|
+
tessera: tesseraNeeded ? "join-waitlist" : "not-needed",
|
|
661
|
+
tesseraWaitlist: "https://quantapact.com/feedback?source=qxm-tessera-interest",
|
|
662
|
+
notes: tesseraNeeded
|
|
663
|
+
? "Findings include cryptographic exposure that Tessera SDK is being designed to remediate. Tessera is in development; join the waitlist to be notified when ready."
|
|
664
|
+
: "No quantum-decryption-relevant findings requiring Tessera remediation at this time.",
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderQxmMarkdown(m) {
|
|
670
|
+
const lines = [];
|
|
671
|
+
lines.push(`# Quantum Exposure Manifest — \`${m.domain}\``);
|
|
672
|
+
lines.push("");
|
|
673
|
+
lines.push(`> **Decryption Blast Radius:** ${m.score} / 10 (Grade ${m.grade}, ${m.scoreLabel})`);
|
|
674
|
+
lines.push(`> Generated by [pqcheck](https://quantapact.com) at ${m.generatedAt}`);
|
|
675
|
+
lines.push("");
|
|
676
|
+
if (!m.reachable) {
|
|
677
|
+
lines.push(`*${m.domain} was not reachable at scan time.*`);
|
|
678
|
+
lines.push("");
|
|
679
|
+
return lines.join("\n");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
lines.push("## Public-surface signals");
|
|
683
|
+
lines.push("");
|
|
684
|
+
lines.push("| Signal | Value |");
|
|
685
|
+
lines.push("|---|---|");
|
|
686
|
+
const ps = m.publicSurface || {};
|
|
687
|
+
lines.push(`| TLS version | ${ps.tlsVersion ?? "?"}${ps.cipher ? ` (${ps.cipher})` : ""} |`);
|
|
688
|
+
lines.push(`| Hybrid PQC | ${ps.hybridPQC ? "yes" : "no"} |`);
|
|
689
|
+
lines.push(`| Cert expires in | ${ps.daysUntilCertExpiry ?? "?"} days |`);
|
|
690
|
+
lines.push(`| HSTS | ${ps.hsts ? "enabled" : "not detected"} |`);
|
|
691
|
+
lines.push(`| Subdomains | ${ps.subdomainCount ?? 0}${ps.wildcardCert ? " (wildcard cert)" : ""} |`);
|
|
692
|
+
if (ps.keyReuseLongestYears) {
|
|
693
|
+
lines.push(`| **Key reuse window** | **${ps.keyReuseLongestYears} years** across ${ps.keyReuseCertsObserved ?? "?"} cert rotations |`);
|
|
694
|
+
}
|
|
695
|
+
lines.push("");
|
|
696
|
+
|
|
697
|
+
if (m.findings && m.findings.length) {
|
|
698
|
+
lines.push("## Findings");
|
|
699
|
+
lines.push("");
|
|
700
|
+
for (const f of m.findings) {
|
|
701
|
+
lines.push(`### \`[${f.severity.toUpperCase()}]\` ${f.title}`);
|
|
702
|
+
lines.push("");
|
|
703
|
+
lines.push(f.detail);
|
|
704
|
+
lines.push("");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (m.impact && m.impact.headline) {
|
|
709
|
+
lines.push("## Plain-English impact");
|
|
710
|
+
lines.push("");
|
|
711
|
+
lines.push(`> ${m.impact.headline}`);
|
|
712
|
+
lines.push("");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (m.sectorRanking && m.sectorRanking.available) {
|
|
716
|
+
lines.push("## Sector ranking");
|
|
717
|
+
lines.push("");
|
|
718
|
+
lines.push(`Among ${m.sectorRanking.sectorLabel}: **${m.sectorRanking.rank} of ${m.sectorRanking.total}** (worse than ${m.sectorRanking.betterThanCount} peers measured).`);
|
|
719
|
+
lines.push("");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
lines.push("## Remediation");
|
|
723
|
+
lines.push("");
|
|
724
|
+
lines.push(`- **Tessera SDK status for this domain:** \`${m.remediation.tessera}\``);
|
|
725
|
+
lines.push(`- ${m.remediation.notes}`);
|
|
726
|
+
lines.push(`- [Join Tessera remediation waitlist](${m.remediation.tesseraWaitlist})`);
|
|
727
|
+
lines.push("");
|
|
728
|
+
|
|
729
|
+
lines.push("## Evidence");
|
|
730
|
+
lines.push("");
|
|
731
|
+
lines.push("| | |");
|
|
732
|
+
lines.push("|---|---|");
|
|
733
|
+
lines.push(`| Methodology | ${m.evidence.methodology} |`);
|
|
734
|
+
lines.push(`| Shareable report | ${m.evidence.shareableReport} |`);
|
|
735
|
+
lines.push(`| Embeddable badge | \`${m.evidence.badge}\` |`);
|
|
736
|
+
lines.push(`| Evidence hash | \`${m.evidence.evidenceHash}\` |`);
|
|
737
|
+
lines.push("");
|
|
738
|
+
lines.push("---");
|
|
739
|
+
lines.push("");
|
|
740
|
+
lines.push("*Public-surface measurements only. Internal Blast Radius (east-west traffic, internal databases, VPN tunnels, backup pipelines) is typically 12–40× this score. Re-run `npx pqcheck lock` to refresh; commit the result to your repo to surface changes in pull requests.*");
|
|
741
|
+
lines.push("");
|
|
742
|
+
return lines.join("\n");
|
|
743
|
+
}
|
|
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
|
+
|
|
492
1048
|
main().catch((err) => {
|
|
493
1049
|
console.error(color("red", `fatal: ${err.message}`));
|
|
494
1050
|
process.exit(2);
|