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