pqcheck 0.6.0 → 0.7.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/bin/pqcheck.js +402 -5
- 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.
|
|
10
|
+
const VERSION = "0.7.0";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -41,10 +41,38 @@ async function main() {
|
|
|
41
41
|
if (args[0] === "deps") {
|
|
42
42
|
return runDepsCommand(args.slice(1));
|
|
43
43
|
}
|
|
44
|
+
if (args[0] === "diff") {
|
|
45
|
+
return runDiffCommand(args.slice(1));
|
|
46
|
+
}
|
|
47
|
+
if (args[0] === "history") {
|
|
48
|
+
return runHistoryCommand(args.slice(1));
|
|
49
|
+
}
|
|
50
|
+
if (args[0] === "cert") {
|
|
51
|
+
return runCertCommand(args.slice(1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Multi-domain support: positional args are domains.
|
|
55
|
+
// --file reads additional domains from a newline-delimited file.
|
|
56
|
+
const fileFlagIdx = args.indexOf("--file");
|
|
57
|
+
let fileDomains = [];
|
|
58
|
+
if (fileFlagIdx >= 0) {
|
|
59
|
+
const filePath = args[fileFlagIdx + 1];
|
|
60
|
+
if (!filePath) {
|
|
61
|
+
console.error(color("red", "error: --file requires a path argument"));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const fs = await import("node:fs/promises");
|
|
66
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
67
|
+
fileDomains = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(color("red", `error reading --file ${filePath}: ${err.message}`));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
44
73
|
|
|
45
|
-
// Multi-domain support: any non-flag positional arg is a domain
|
|
46
74
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
47
|
-
const domains = positional
|
|
75
|
+
const domains = [...positional, ...fileDomains]
|
|
48
76
|
.map((a) => normalizeDomain(a))
|
|
49
77
|
.filter((d) => !!d);
|
|
50
78
|
|
|
@@ -140,6 +168,10 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi
|
|
|
140
168
|
printCsvRow(report);
|
|
141
169
|
} else if (format === "markdown") {
|
|
142
170
|
printMarkdown(report, multi);
|
|
171
|
+
} else if (format === "sarif") {
|
|
172
|
+
console.log(JSON.stringify(reportToSarif(report), null, 2));
|
|
173
|
+
} else if (format === "gh-action") {
|
|
174
|
+
printGitHubActionAnnotations(report);
|
|
143
175
|
} else {
|
|
144
176
|
if (multi) console.log(color("dim", `\n──── ${domain} ────`));
|
|
145
177
|
printReport(report);
|
|
@@ -228,15 +260,18 @@ function isFlagValue(args, val) {
|
|
|
228
260
|
const idx = args.indexOf(val);
|
|
229
261
|
if (idx <= 0) return false;
|
|
230
262
|
const prev = args[idx - 1];
|
|
231
|
-
return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook";
|
|
263
|
+
return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook" || prev === "--file" || prev === "-o" || prev === "--allowlist";
|
|
232
264
|
}
|
|
233
265
|
|
|
234
266
|
function parseFormat(args) {
|
|
235
267
|
if (args.includes("--json")) return "json"; // back-compat alias
|
|
268
|
+
if (args.includes("--gh-action")) return "gh-action"; // GitHub Actions annotation format
|
|
236
269
|
const i = args.indexOf("--format");
|
|
237
270
|
if (i === -1) return "text";
|
|
238
271
|
const v = (args[i + 1] || "").toLowerCase();
|
|
239
|
-
if (v === "json" || v === "csv" || v === "markdown" || v === "md"
|
|
272
|
+
if (v === "json" || v === "csv" || v === "markdown" || v === "md" || v === "sarif" || v === "gh-action") {
|
|
273
|
+
return v === "md" ? "markdown" : v;
|
|
274
|
+
}
|
|
240
275
|
return "text";
|
|
241
276
|
}
|
|
242
277
|
|
|
@@ -772,6 +807,23 @@ async function runDepsCommand(args) {
|
|
|
772
807
|
const maxArg = args.find((a) => a.startsWith("--max="));
|
|
773
808
|
const maxThirdParties = maxArg ? Math.max(1, parseInt(maxArg.slice(6), 10) || 20) : 20;
|
|
774
809
|
|
|
810
|
+
// Allowlist support: --allowlist <path> reads newline-separated host patterns.
|
|
811
|
+
// If a third-party host is NOT in the allowlist, the scan exits non-zero with code 3.
|
|
812
|
+
// Useful as a CI gate for vendor-risk teams: "fail PR if any unapproved third-party appears."
|
|
813
|
+
const allowlistIdx = args.indexOf("--allowlist");
|
|
814
|
+
let allowlist = null;
|
|
815
|
+
if (allowlistIdx >= 0) {
|
|
816
|
+
const allowlistPath = args[allowlistIdx + 1];
|
|
817
|
+
try {
|
|
818
|
+
const fs2 = await import("node:fs/promises");
|
|
819
|
+
const raw = await fs2.readFile(allowlistPath, "utf8");
|
|
820
|
+
allowlist = new Set(raw.split(/\r?\n/).map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith("#")));
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.error(color("red", `error reading --allowlist: ${err.message}`));
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
775
827
|
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
776
828
|
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
777
829
|
if (!domain || !isValidDomain(domain)) {
|
|
@@ -916,6 +968,23 @@ async function runDepsCommand(args) {
|
|
|
916
968
|
process.exit(1);
|
|
917
969
|
}
|
|
918
970
|
}
|
|
971
|
+
|
|
972
|
+
// Allowlist gate: exit non-zero if any third-party host isn't in the allowlist.
|
|
973
|
+
if (allowlist) {
|
|
974
|
+
const violations = results.filter(r => !allowlist.has(r.host));
|
|
975
|
+
if (violations.length > 0) {
|
|
976
|
+
if (!json) {
|
|
977
|
+
console.error("");
|
|
978
|
+
console.error(color("red", ` ✗ Allowlist violation: ${violations.length} third-party origin(s) not in allowlist:`));
|
|
979
|
+
for (const v of violations) console.error(` - ${v.host}`);
|
|
980
|
+
console.error("");
|
|
981
|
+
}
|
|
982
|
+
process.exit(3);
|
|
983
|
+
} else if (!json) {
|
|
984
|
+
console.log(` ${color("green", "✓")} ${color("dim", "All third-party origins on allowlist.")}`);
|
|
985
|
+
console.log("");
|
|
986
|
+
}
|
|
987
|
+
}
|
|
919
988
|
}
|
|
920
989
|
|
|
921
990
|
async function fetchPageHTML(domain) {
|
|
@@ -1045,6 +1114,334 @@ function depsManifestToMarkdown(m) {
|
|
|
1045
1114
|
return lines.join("\n");
|
|
1046
1115
|
}
|
|
1047
1116
|
|
|
1117
|
+
// =============================================================================
|
|
1118
|
+
// SARIF + GitHub Action output formats
|
|
1119
|
+
// =============================================================================
|
|
1120
|
+
|
|
1121
|
+
function reportToSarif(report) {
|
|
1122
|
+
// SARIF 2.1.0 minimal schema for security findings.
|
|
1123
|
+
// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
1124
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
1125
|
+
const sevMap = { critical: "error", high: "error", medium: "warning", low: "note" };
|
|
1126
|
+
return {
|
|
1127
|
+
$schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
|
|
1128
|
+
version: "2.1.0",
|
|
1129
|
+
runs: [{
|
|
1130
|
+
tool: {
|
|
1131
|
+
driver: {
|
|
1132
|
+
name: "pqcheck",
|
|
1133
|
+
version: VERSION,
|
|
1134
|
+
informationUri: "https://quantapact.com",
|
|
1135
|
+
rules: findings.map((f, i) => ({
|
|
1136
|
+
id: `pqcheck-${i + 1}`,
|
|
1137
|
+
name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
|
|
1138
|
+
shortDescription: { text: f.title || "finding" },
|
|
1139
|
+
fullDescription: { text: f.detail || f.title || "finding" },
|
|
1140
|
+
defaultConfiguration: { level: sevMap[f.severity] || "note" },
|
|
1141
|
+
})),
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1144
|
+
results: findings.map((f, i) => ({
|
|
1145
|
+
ruleId: `pqcheck-${i + 1}`,
|
|
1146
|
+
level: sevMap[f.severity] || "note",
|
|
1147
|
+
message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
|
|
1148
|
+
locations: [{
|
|
1149
|
+
physicalLocation: {
|
|
1150
|
+
artifactLocation: { uri: `https://${report.domain || ""}` },
|
|
1151
|
+
},
|
|
1152
|
+
}],
|
|
1153
|
+
properties: {
|
|
1154
|
+
domain: report.domain,
|
|
1155
|
+
score: report.score,
|
|
1156
|
+
grade: report.grade,
|
|
1157
|
+
severity: f.severity,
|
|
1158
|
+
},
|
|
1159
|
+
})),
|
|
1160
|
+
properties: {
|
|
1161
|
+
score: report.score,
|
|
1162
|
+
grade: report.grade,
|
|
1163
|
+
domain: report.domain,
|
|
1164
|
+
},
|
|
1165
|
+
}],
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function printGitHubActionAnnotations(report) {
|
|
1170
|
+
// GitHub Actions workflow command syntax: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
1171
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
1172
|
+
const sevMap = { critical: "error", high: "error", medium: "warning", low: "notice" };
|
|
1173
|
+
// Top-line score/grade as a notice
|
|
1174
|
+
console.log(`::notice title=Quantapact: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
|
|
1175
|
+
for (const f of findings) {
|
|
1176
|
+
const cmd = sevMap[f.severity] || "notice";
|
|
1177
|
+
const title = (f.title || "finding").replace(/[\r\n]/g, " ");
|
|
1178
|
+
const msg = (f.detail || f.title || "").replace(/[\r\n]/g, " ").replace(/::/g, ":");
|
|
1179
|
+
console.log(`::${cmd} title=${title}::${msg}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// =============================================================================
|
|
1184
|
+
// `pqcheck history` — show recent score history for a domain
|
|
1185
|
+
// =============================================================================
|
|
1186
|
+
|
|
1187
|
+
async function runHistoryCommand(args) {
|
|
1188
|
+
const json = args.includes("--json");
|
|
1189
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1190
|
+
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
1191
|
+
if (!domain || !isValidDomain(domain)) {
|
|
1192
|
+
console.error(color("red", "error: pqcheck history requires a valid domain"));
|
|
1193
|
+
console.error(color("dim", "Usage: npx pqcheck history <domain> [--json]"));
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
const days = (() => {
|
|
1197
|
+
const i = args.indexOf("--days");
|
|
1198
|
+
if (i === -1) return 90;
|
|
1199
|
+
const n = parseInt(args[i + 1] || "90", 10);
|
|
1200
|
+
return Number.isFinite(n) && n > 0 && n <= 365 ? n : 90;
|
|
1201
|
+
})();
|
|
1202
|
+
|
|
1203
|
+
let h;
|
|
1204
|
+
try {
|
|
1205
|
+
const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
|
|
1206
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (history)` },
|
|
1207
|
+
});
|
|
1208
|
+
if (!r.ok) {
|
|
1209
|
+
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
h = await r.json();
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
console.error(color("red", `error: ${err.message}`));
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (json) {
|
|
1219
|
+
console.log(JSON.stringify(h, null, 2));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const points = Array.isArray(h?.points) ? h.points : [];
|
|
1224
|
+
if (points.length === 0) {
|
|
1225
|
+
console.log(` ${color("violet", domain)} ${color("dim", "·")} no history found`);
|
|
1226
|
+
console.log(color("dim", " (need at least one previous scan; try `npx pqcheck " + domain + "` first)"));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const sorted = points.slice().reverse(); // oldest → newest
|
|
1230
|
+
const first = sorted[0].score;
|
|
1231
|
+
const last = sorted[sorted.length - 1].score;
|
|
1232
|
+
const delta = last - first;
|
|
1233
|
+
const min = Math.min(...sorted.map(p => p.score));
|
|
1234
|
+
const max = Math.max(...sorted.map(p => p.score));
|
|
1235
|
+
const trend = Math.abs(delta) < 0.1 ? "→ flat" : delta > 0 ? `↑ +${delta.toFixed(1)} (worsened)` : `↓ ${delta.toFixed(1)} (improved)`;
|
|
1236
|
+
|
|
1237
|
+
console.log("");
|
|
1238
|
+
console.log(` ${color("bold", domain)} ${color("dim", "·")} score history (${days}d, ${sorted.length} samples)`);
|
|
1239
|
+
console.log(` ${color("dim", `range ${min.toFixed(1)} – ${max.toFixed(1)} · trend ${trend}`)}`);
|
|
1240
|
+
console.log("");
|
|
1241
|
+
// Compact ASCII sparkline
|
|
1242
|
+
const range = Math.max(0.1, max - min);
|
|
1243
|
+
const ramp = "▁▂▃▄▅▆▇█";
|
|
1244
|
+
let bar = "";
|
|
1245
|
+
for (const p of sorted) {
|
|
1246
|
+
const idx = Math.min(ramp.length - 1, Math.floor(((p.score - min) / range) * (ramp.length - 1)));
|
|
1247
|
+
bar += ramp[idx];
|
|
1248
|
+
}
|
|
1249
|
+
console.log(` ${color("violet", bar)}`);
|
|
1250
|
+
console.log("");
|
|
1251
|
+
// Tail of recent samples
|
|
1252
|
+
console.log(` ${color("dim", "Recent samples (most-recent first):")}`);
|
|
1253
|
+
for (const p of points.slice(0, 8)) {
|
|
1254
|
+
const date = (p.scannedAt || p.date || "").slice(0, 10);
|
|
1255
|
+
console.log(` ${color("dim", date)} score ${color("bold", p.score?.toFixed(1) ?? "?")} grade ${p.grade ?? "?"}`);
|
|
1256
|
+
}
|
|
1257
|
+
console.log("");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// =============================================================================
|
|
1261
|
+
// `pqcheck diff` — diff two QXM lockfiles
|
|
1262
|
+
// =============================================================================
|
|
1263
|
+
|
|
1264
|
+
async function runDiffCommand(args) {
|
|
1265
|
+
const fs = await import("node:fs/promises");
|
|
1266
|
+
const json = args.includes("--json");
|
|
1267
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1268
|
+
if (positional.length !== 2) {
|
|
1269
|
+
console.error(color("red", "error: pqcheck diff requires two lockfile paths"));
|
|
1270
|
+
console.error(color("dim", "Usage: npx pqcheck diff old.lock new.lock [--json]"));
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1273
|
+
let oldLock, newLock;
|
|
1274
|
+
try {
|
|
1275
|
+
oldLock = JSON.parse(await fs.readFile(positional[0], "utf8"));
|
|
1276
|
+
newLock = JSON.parse(await fs.readFile(positional[1], "utf8"));
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error(color("red", `error reading lockfile: ${err.message}`));
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const diff = computeLockDiff(oldLock, newLock);
|
|
1283
|
+
if (json) {
|
|
1284
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
1285
|
+
process.exit(diff.regressed ? 2 : 0);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
console.log("");
|
|
1289
|
+
console.log(` ${color("bold", "Quantapact lockfile diff")}`);
|
|
1290
|
+
console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
|
|
1291
|
+
console.log("");
|
|
1292
|
+
if (diff.scoreChange !== null) {
|
|
1293
|
+
const arrow = diff.scoreChange > 0 ? color("red", "↑") : diff.scoreChange < 0 ? color("green", "↓") : color("dim", "→");
|
|
1294
|
+
const direction = diff.scoreChange > 0 ? "worsened" : diff.scoreChange < 0 ? "improved" : "unchanged";
|
|
1295
|
+
console.log(` Score: ${color("bold", diff.oldScore?.toFixed(1) ?? "?")} → ${color("bold", diff.newScore?.toFixed(1) ?? "?")} ${arrow} ${diff.scoreChange.toFixed(1)} (${direction})`);
|
|
1296
|
+
}
|
|
1297
|
+
if (diff.gradeChange) {
|
|
1298
|
+
const colored = diff.regressed ? color("red", diff.newGrade) : color("green", diff.newGrade);
|
|
1299
|
+
console.log(` Grade: ${diff.oldGrade ?? "?"} → ${colored}`);
|
|
1300
|
+
}
|
|
1301
|
+
if (diff.componentChanges?.length > 0) {
|
|
1302
|
+
console.log("");
|
|
1303
|
+
console.log(` ${color("dim", "Component changes:")}`);
|
|
1304
|
+
for (const c of diff.componentChanges) {
|
|
1305
|
+
const arrow = c.change > 0 ? color("red", "↑") : color("green", "↓");
|
|
1306
|
+
console.log(` ${c.name.padEnd(20, " ")} ${c.before?.toFixed(2) ?? "?"} → ${c.after?.toFixed(2) ?? "?"} ${arrow} ${Math.abs(c.change).toFixed(2)}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (diff.findingsAdded?.length > 0) {
|
|
1310
|
+
console.log("");
|
|
1311
|
+
console.log(` ${color("red", "+ New findings:")}`);
|
|
1312
|
+
for (const f of diff.findingsAdded) console.log(` [${f.severity}] ${f.title}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (diff.findingsResolved?.length > 0) {
|
|
1315
|
+
console.log("");
|
|
1316
|
+
console.log(` ${color("green", "- Resolved findings:")}`);
|
|
1317
|
+
for (const f of diff.findingsResolved) console.log(` [${f.severity}] ${f.title}`);
|
|
1318
|
+
}
|
|
1319
|
+
console.log("");
|
|
1320
|
+
process.exit(diff.regressed ? 2 : 0);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function computeLockDiff(oldLock, newLock) {
|
|
1324
|
+
const oldScore = typeof oldLock?.score === "number" ? oldLock.score : oldLock?.summary?.score;
|
|
1325
|
+
const newScore = typeof newLock?.score === "number" ? newLock.score : newLock?.summary?.score;
|
|
1326
|
+
const oldGrade = oldLock?.grade || oldLock?.summary?.grade;
|
|
1327
|
+
const newGrade = newLock?.grade || newLock?.summary?.grade;
|
|
1328
|
+
const scoreChange = (typeof oldScore === "number" && typeof newScore === "number") ? Math.round((newScore - oldScore) * 100) / 100 : null;
|
|
1329
|
+
const componentChanges = [];
|
|
1330
|
+
const oldComp = oldLock?.components || {};
|
|
1331
|
+
const newComp = newLock?.components || {};
|
|
1332
|
+
for (const k of Object.keys({ ...oldComp, ...newComp })) {
|
|
1333
|
+
const before = typeof oldComp[k]?.contribution === "number" ? oldComp[k].contribution : null;
|
|
1334
|
+
const after = typeof newComp[k]?.contribution === "number" ? newComp[k].contribution : null;
|
|
1335
|
+
if (before !== null && after !== null && Math.abs(after - before) >= 0.05) {
|
|
1336
|
+
componentChanges.push({ name: k, before, after, change: Math.round((after - before) * 100) / 100 });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// Findings comparison by title
|
|
1340
|
+
const oldFindings = Array.isArray(oldLock?.findings) ? oldLock.findings : [];
|
|
1341
|
+
const newFindings = Array.isArray(newLock?.findings) ? newLock.findings : [];
|
|
1342
|
+
const oldTitles = new Set(oldFindings.map(f => f.title));
|
|
1343
|
+
const newTitles = new Set(newFindings.map(f => f.title));
|
|
1344
|
+
const findingsAdded = newFindings.filter(f => !oldTitles.has(f.title));
|
|
1345
|
+
const findingsResolved = oldFindings.filter(f => !newTitles.has(f.title));
|
|
1346
|
+
const regressed = (typeof scoreChange === "number" && scoreChange > 0.1) || findingsAdded.some(f => f.severity === "high" || f.severity === "critical");
|
|
1347
|
+
return {
|
|
1348
|
+
oldScore, newScore, oldGrade, newGrade, scoreChange,
|
|
1349
|
+
gradeChange: oldGrade !== newGrade,
|
|
1350
|
+
componentChanges,
|
|
1351
|
+
findingsAdded, findingsResolved,
|
|
1352
|
+
regressed,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// =============================================================================
|
|
1357
|
+
// `pqcheck cert <pem-file>` — analyze a local cert file (offline)
|
|
1358
|
+
// =============================================================================
|
|
1359
|
+
|
|
1360
|
+
async function runCertCommand(args) {
|
|
1361
|
+
const fs = await import("node:fs/promises");
|
|
1362
|
+
const crypto = await import("node:crypto");
|
|
1363
|
+
const json = args.includes("--json");
|
|
1364
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1365
|
+
if (positional.length === 0) {
|
|
1366
|
+
console.error(color("red", "error: pqcheck cert requires a cert file path"));
|
|
1367
|
+
console.error(color("dim", "Usage: npx pqcheck cert <path-to-pem-or-crt> [--json]"));
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
let pemData;
|
|
1372
|
+
try {
|
|
1373
|
+
pemData = await fs.readFile(positional[0], "utf8");
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
console.error(color("red", `error reading cert file: ${err.message}`));
|
|
1376
|
+
process.exit(1);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
let cert;
|
|
1380
|
+
try {
|
|
1381
|
+
cert = new crypto.X509Certificate(pemData);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.error(color("red", `error parsing cert: ${err.message}`));
|
|
1384
|
+
console.error(color("dim", "Expected a PEM-encoded X.509 cert (.pem, .crt, .cer)."));
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const validFrom = new Date(cert.validFrom);
|
|
1389
|
+
const validTo = new Date(cert.validTo);
|
|
1390
|
+
const daysLeft = Math.round((validTo.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
1391
|
+
const sigAlg = (cert.publicKey?.asymmetricKeyType || "unknown").toUpperCase();
|
|
1392
|
+
const keyBits = cert.publicKey?.asymmetricKeyDetails?.modulusLength || cert.publicKey?.asymmetricKeyDetails?.namedCurve || "?";
|
|
1393
|
+
const sans = (cert.subjectAltName || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
1394
|
+
const isWildcard = sans.some(s => s.includes("*."));
|
|
1395
|
+
|
|
1396
|
+
// Quantum exposure assessment
|
|
1397
|
+
let quantumNote;
|
|
1398
|
+
if (sigAlg === "RSA" && Number(keyBits) >= 2048) {
|
|
1399
|
+
quantumNote = "RSA-" + keyBits + " — broken by Shor's algorithm once a CRQC exists";
|
|
1400
|
+
} else if (sigAlg === "EC" || sigAlg === "ECDSA") {
|
|
1401
|
+
quantumNote = "ECDSA (" + keyBits + ") — broken by Shor's algorithm once a CRQC exists";
|
|
1402
|
+
} else if (sigAlg === "ED25519") {
|
|
1403
|
+
quantumNote = "Ed25519 — broken by Shor's algorithm once a CRQC exists";
|
|
1404
|
+
} else {
|
|
1405
|
+
quantumNote = sigAlg + " — quantum exposure unknown";
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const result = {
|
|
1409
|
+
file: positional[0],
|
|
1410
|
+
subject: cert.subject,
|
|
1411
|
+
issuer: cert.issuer,
|
|
1412
|
+
serialNumber: cert.serialNumber,
|
|
1413
|
+
validFrom: validFrom.toISOString(),
|
|
1414
|
+
validTo: validTo.toISOString(),
|
|
1415
|
+
daysUntilExpiry: daysLeft,
|
|
1416
|
+
keyAlgorithm: sigAlg,
|
|
1417
|
+
keyBits,
|
|
1418
|
+
sans,
|
|
1419
|
+
isWildcard,
|
|
1420
|
+
isCA: cert.ca,
|
|
1421
|
+
quantumExposure: quantumNote,
|
|
1422
|
+
};
|
|
1423
|
+
|
|
1424
|
+
if (json) {
|
|
1425
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
console.log("");
|
|
1430
|
+
console.log(` ${color("bold", "Cert analysis")}: ${color("violet", positional[0])}`);
|
|
1431
|
+
console.log("");
|
|
1432
|
+
console.log(` Subject: ${cert.subject}`);
|
|
1433
|
+
console.log(` Issuer: ${cert.issuer}`);
|
|
1434
|
+
console.log(` Valid: ${validFrom.toISOString().slice(0, 10)} → ${validTo.toISOString().slice(0, 10)} (${daysLeft} days remaining)`);
|
|
1435
|
+
console.log(` Serial: ${cert.serialNumber}`);
|
|
1436
|
+
console.log(` Key: ${sigAlg}-${keyBits}`);
|
|
1437
|
+
console.log(` SANs (${sans.length}): ${sans.slice(0, 4).join(", ")}${sans.length > 4 ? ", ..." : ""}`);
|
|
1438
|
+
console.log(` Wildcard: ${isWildcard ? "yes" : "no"}`);
|
|
1439
|
+
console.log(` CA cert: ${cert.ca ? "yes" : "no"}`);
|
|
1440
|
+
console.log("");
|
|
1441
|
+
console.log(` ${color("yellow", "Quantum exposure:")} ${quantumNote}`);
|
|
1442
|
+
console.log("");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1048
1445
|
main().catch((err) => {
|
|
1049
1446
|
console.error(color("red", `fatal: ${err.message}`));
|
|
1050
1447
|
process.exit(2);
|