fad-checker 2.4.1 → 2.4.2
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/CHANGELOG.md +52 -0
- package/README.md +3 -2
- package/REVIEW-2026-07-02.md +190 -0
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +3 -0
- package/data/cpe-coord-map.json +1 -0
- package/fad-checker.js +36 -4
- package/lib/certs/analyze.js +305 -0
- package/lib/certs/index.js +17 -0
- package/lib/certs/scan.js +67 -0
- package/lib/certs/sniff.js +29 -0
- package/lib/codecs/go/parse.js +50 -8
- package/lib/codecs/go/registry.js +3 -1
- package/lib/codecs/go.codec.js +22 -4
- package/lib/codecs/nuget/parse.js +21 -6
- package/lib/codecs/nuget/registry.js +30 -4
- package/lib/codecs/nuget.codec.js +37 -7
- package/lib/codecs/pypi/parse.js +11 -2
- package/lib/codecs/pypi/registry.js +3 -1
- package/lib/codecs/pypi.codec.js +3 -0
- package/lib/cve-download.js +102 -11
- package/lib/cve-report.js +56 -8
- package/lib/json-export.js +4 -1
- package/lib/maven-version.js +25 -16
- package/lib/osv.js +8 -4
- package/lib/provenance.js +2 -0
- package/lib/sarif-export.js +48 -2
- package/package.json +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/certs/analyze.js — classify committed crypto material and derive findings.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes a file's bytes + name (+ a `now` for testability) and returns a flat
|
|
5
|
+
* list of items. One file can yield several items (a .pem bundling a cert chain and
|
|
6
|
+
* a private key produces one item per certificate plus one for the key).
|
|
7
|
+
*
|
|
8
|
+
* Each item is one of four kinds:
|
|
9
|
+
* - "certificate" — an X.509 cert (PEM or DER), fully parsed via the built-in
|
|
10
|
+
* crypto.X509Certificate (NO external library, works air-gapped).
|
|
11
|
+
* - "private-key" / "public-key" — key material, ALWAYS labelled with its
|
|
12
|
+
* visibility (the headline ask): PEM (PKCS#1/PKCS#8/SEC1), OpenSSH (every
|
|
13
|
+
* algorithm), PuTTY (.ppk), PGP, and one-line SSH public keys.
|
|
14
|
+
* - "keystore" — a Java keystore / PKCS#12 (content is password-protected, so we
|
|
15
|
+
* inventory + hash it rather than decrypt).
|
|
16
|
+
*
|
|
17
|
+
* Findings (item.issues[]): cert-expired, cert-expiring, cert-weak-key,
|
|
18
|
+
* cert-weak-signature, cert-self-signed, private-key-committed,
|
|
19
|
+
* public-key-committed, keystore-committed. No network, no decryption.
|
|
20
|
+
*
|
|
21
|
+
* @author: N.BRAUN
|
|
22
|
+
* @email: pp9ping@gmail.com
|
|
23
|
+
*/
|
|
24
|
+
const crypto = require("crypto");
|
|
25
|
+
|
|
26
|
+
const DAY = 86_400_000;
|
|
27
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
28
|
+
|
|
29
|
+
function worstSeverity(issues) {
|
|
30
|
+
let s = "info";
|
|
31
|
+
for (const i of issues) if ((SEV_RANK[i.severity] || 0) > SEV_RANK[s]) s = i.severity;
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- PEM block handling --------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const PEM_BLOCK_RE = /-----BEGIN ([A-Z0-9 ]+?)-----\r?\n([\s\S]*?)-----END \1-----/g;
|
|
38
|
+
|
|
39
|
+
function pemBlocks(text) {
|
|
40
|
+
const out = [];
|
|
41
|
+
let m;
|
|
42
|
+
PEM_BLOCK_RE.lastIndex = 0;
|
|
43
|
+
while ((m = PEM_BLOCK_RE.exec(text)) !== null) {
|
|
44
|
+
out.push({ label: m[1].trim(), body: m[2], full: m[0] });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- SSH public-key line + OpenSSH private-key blob ----------------------
|
|
50
|
+
|
|
51
|
+
const SSH_PUB_RE = /^(ssh-rsa|ssh-dss|ssh-ed25519|ecdsa-sha2-[a-z0-9-]+|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)\s+[A-Za-z0-9+/=]+/;
|
|
52
|
+
|
|
53
|
+
// Map an SSH key-type token to a human algorithm label.
|
|
54
|
+
function sshAlgo(type) {
|
|
55
|
+
if (type === "ssh-rsa") return "RSA";
|
|
56
|
+
if (type === "ssh-dss") return "DSA";
|
|
57
|
+
if (type === "ssh-ed25519") return "Ed25519";
|
|
58
|
+
if (type === "sk-ssh-ed25519@openssh.com") return "Ed25519-SK";
|
|
59
|
+
if (type === "sk-ecdsa-sha2-nistp256@openssh.com") return "ECDSA-SK";
|
|
60
|
+
if (type && type.startsWith("ecdsa-sha2-")) return "ECDSA (" + type.slice("ecdsa-sha2-".length) + ")";
|
|
61
|
+
return type || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse an OpenSSH private-key blob → { algorithm, encrypted }. Format:
|
|
65
|
+
// "openssh-key-v1\0" cipher kdf kdfopts uint32(numkeys) sshstring(pubkey) ...
|
|
66
|
+
function parseOpensshPrivate(body) {
|
|
67
|
+
try {
|
|
68
|
+
const buf = Buffer.from(body.replace(/[^A-Za-z0-9+/=]/g, ""), "base64");
|
|
69
|
+
const MAGIC = "openssh-key-v1\0";
|
|
70
|
+
if (buf.subarray(0, MAGIC.length).toString("latin1") !== MAGIC) return null;
|
|
71
|
+
let off = MAGIC.length;
|
|
72
|
+
const readStr = () => { const len = buf.readUInt32BE(off); off += 4; const s = buf.subarray(off, off + len); off += len; return s; };
|
|
73
|
+
const cipher = readStr().toString("latin1");
|
|
74
|
+
readStr(); // kdf name
|
|
75
|
+
readStr(); // kdf options
|
|
76
|
+
off += 4; // numKeys (uint32)
|
|
77
|
+
const pub = readStr(); // first public-key blob
|
|
78
|
+
const tlen = pub.readUInt32BE(0);
|
|
79
|
+
const keyType = pub.subarray(4, 4 + tlen).toString("latin1");
|
|
80
|
+
return { algorithm: sshAlgo(keyType), encrypted: cipher !== "none" };
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- weak signature-algorithm detection (OID byte-scan over DER) ---------
|
|
85
|
+
|
|
86
|
+
// Signature-algorithm OIDs we consider weak, as their DER value bytes.
|
|
87
|
+
const WEAK_SIG_OIDS = [
|
|
88
|
+
{ name: "MD2withRSA", bytes: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x02] },
|
|
89
|
+
{ name: "MD5withRSA", bytes: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x04] },
|
|
90
|
+
{ name: "SHA1withRSA", bytes: [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x05] },
|
|
91
|
+
{ name: "ECDSAwithSHA1", bytes: [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x01] },
|
|
92
|
+
{ name: "DSAwithSHA1", bytes: [0x2a, 0x86, 0x48, 0xce, 0x38, 0x04, 0x03] },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function detectWeakSig(raw) {
|
|
96
|
+
if (!raw || !raw.length) return null;
|
|
97
|
+
for (const oid of WEAK_SIG_OIDS) {
|
|
98
|
+
if (raw.indexOf(Buffer.from(oid.bytes)) !== -1) return oid.name;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// EC curves below the ~128-bit security floor (i.e. < 256-bit field).
|
|
104
|
+
const WEAK_CURVES = new Set(["prime192v1", "secp192r1", "secp192k1", "secp224r1", "secp224k1", "prime239v1"]);
|
|
105
|
+
|
|
106
|
+
function cnOf(dn) {
|
|
107
|
+
if (!dn) return null;
|
|
108
|
+
const m = /(?:^|\n)CN=([^\n]+)/.exec(dn);
|
|
109
|
+
return m ? m[1].trim() : dn.split("\n")[0].trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- certificate item ----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function certItem({ path, file, x509, now, expiryDays }) {
|
|
115
|
+
const issues = [];
|
|
116
|
+
const notAfterMs = Date.parse(x509.validTo);
|
|
117
|
+
const notBeforeMs = Date.parse(x509.validFrom);
|
|
118
|
+
const daysUntilExpiry = Number.isFinite(notAfterMs) ? Math.floor((notAfterMs - now) / DAY) : null;
|
|
119
|
+
|
|
120
|
+
let algorithm = null, bits = null;
|
|
121
|
+
try {
|
|
122
|
+
const pk = x509.publicKey;
|
|
123
|
+
algorithm = (pk.asymmetricKeyType || "").toUpperCase() || null;
|
|
124
|
+
const d = pk.asymmetricKeyDetails || {};
|
|
125
|
+
if (d.modulusLength) bits = d.modulusLength;
|
|
126
|
+
else if (d.namedCurve) bits = d.namedCurve;
|
|
127
|
+
if (algorithm === "RSA" && bits && bits < 2048)
|
|
128
|
+
issues.push({ type: "cert-weak-key", severity: "high", message: `RSA ${bits}-bit key (below 2048-bit minimum)` });
|
|
129
|
+
if ((algorithm === "EC" || algorithm === "ECDSA") && typeof bits === "string" && WEAK_CURVES.has(bits))
|
|
130
|
+
issues.push({ type: "cert-weak-key", severity: "high", message: `weak EC curve ${bits}` });
|
|
131
|
+
} catch { /* publicKey unreadable — leave algorithm null */ }
|
|
132
|
+
|
|
133
|
+
if (Number.isFinite(notAfterMs)) {
|
|
134
|
+
if (notAfterMs < now)
|
|
135
|
+
issues.push({ type: "cert-expired", severity: "high", message: `expired on ${x509.validTo} (${-daysUntilExpiry} days ago)` });
|
|
136
|
+
else if (daysUntilExpiry <= expiryDays)
|
|
137
|
+
issues.push({ type: "cert-expiring", severity: "medium", message: `expires in ${daysUntilExpiry} days (${x509.validTo})` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const weakSig = detectWeakSig(x509.raw);
|
|
141
|
+
if (weakSig)
|
|
142
|
+
issues.push({ type: "cert-weak-signature", severity: "high", message: `weak signature algorithm ${weakSig}` });
|
|
143
|
+
|
|
144
|
+
const selfSigned = x509.subject === x509.issuer;
|
|
145
|
+
if (selfSigned)
|
|
146
|
+
issues.push({ type: "cert-self-signed", severity: "low", message: "self-signed certificate" });
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
path, file, kind: "certificate", format: "x509",
|
|
150
|
+
algorithm, bits, keyVisibility: null,
|
|
151
|
+
subject: cnOf(x509.subject), issuer: cnOf(x509.issuer),
|
|
152
|
+
serialNumber: x509.serialNumber || null,
|
|
153
|
+
notBefore: Number.isFinite(notBeforeMs) ? new Date(notBeforeMs).toISOString() : null,
|
|
154
|
+
notAfter: Number.isFinite(notAfterMs) ? new Date(notAfterMs).toISOString() : null,
|
|
155
|
+
daysUntilExpiry, selfSigned, ca: !!x509.ca,
|
|
156
|
+
signatureAlgorithm: weakSig || null,
|
|
157
|
+
fingerprint256: x509.fingerprint256 || null,
|
|
158
|
+
issues, severity: worstSeverity(issues),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function tryX509(buf) {
|
|
163
|
+
try { return new crypto.X509Certificate(buf); } catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- key items -----------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function privateKeyItem({ path, file, format, algorithm, encrypted }) {
|
|
169
|
+
const enc = encrypted ? " (encrypted)" : " (UNENCRYPTED)";
|
|
170
|
+
const issues = [{
|
|
171
|
+
type: "private-key-committed", severity: "critical",
|
|
172
|
+
message: `committed ${algorithm || ""} private key${enc} [${format}]`.replace(/\s+/g, " ").trim(),
|
|
173
|
+
}];
|
|
174
|
+
return {
|
|
175
|
+
path, file, kind: "private-key", format, algorithm: algorithm || null,
|
|
176
|
+
keyVisibility: "private", encrypted: !!encrypted,
|
|
177
|
+
issues, severity: "critical",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function publicKeyItem({ path, file, format, algorithm, count }) {
|
|
182
|
+
const issues = [{
|
|
183
|
+
type: "public-key-committed", severity: "low",
|
|
184
|
+
message: `committed ${algorithm || ""} public key${count > 1 ? ` (${count} keys)` : ""} [${format}]`.replace(/\s+/g, " ").trim(),
|
|
185
|
+
}];
|
|
186
|
+
return {
|
|
187
|
+
path, file, kind: "public-key", format, algorithm: algorithm || null,
|
|
188
|
+
keyVisibility: "public", count: count || 1,
|
|
189
|
+
issues, severity: "low",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Classify one PEM private-key block → { algorithm, encrypted }.
|
|
194
|
+
function pemPrivateInfo(block) {
|
|
195
|
+
if (block.label === "OPENSSH PRIVATE KEY") {
|
|
196
|
+
const r = parseOpensshPrivate(block.body);
|
|
197
|
+
return { algorithm: r?.algorithm || null, encrypted: r ? r.encrypted : false, format: "openssh" };
|
|
198
|
+
}
|
|
199
|
+
const encrypted = /ENCRYPTED/.test(block.label) || /Proc-Type:\s*4,ENCRYPTED/.test(block.body);
|
|
200
|
+
let algorithm = null;
|
|
201
|
+
if (/^RSA /.test(block.label)) algorithm = "RSA";
|
|
202
|
+
else if (/^EC /.test(block.label)) algorithm = "EC";
|
|
203
|
+
else if (/^DSA /.test(block.label)) algorithm = "DSA";
|
|
204
|
+
if (!encrypted && !algorithm) {
|
|
205
|
+
try {
|
|
206
|
+
const k = crypto.createPrivateKey({ key: block.full });
|
|
207
|
+
algorithm = (k.asymmetricKeyType || "").toUpperCase() || null;
|
|
208
|
+
} catch { /* unparseable → leave null */ }
|
|
209
|
+
}
|
|
210
|
+
return { algorithm, encrypted, format: "pem" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function pemPublicAlgo(block) {
|
|
214
|
+
try {
|
|
215
|
+
const k = crypto.createPublicKey({ key: block.full });
|
|
216
|
+
return (k.asymmetricKeyType || "").toUpperCase() || null;
|
|
217
|
+
} catch { return /^RSA /.test(block.label) ? "RSA" : null; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- top-level dispatch --------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Analyze one candidate file. Returns an array of items (possibly empty).
|
|
224
|
+
* @param {{name, path, buf, now?, expiryDays?}} args
|
|
225
|
+
*/
|
|
226
|
+
function analyzeBuffer({ name, path, buf, now = Date.now(), expiryDays = 90 } = {}) {
|
|
227
|
+
if (!buf || !buf.length) return [];
|
|
228
|
+
const items = [];
|
|
229
|
+
// latin1 keeps binary bytes 1:1 while still letting us substring-match ASCII headers.
|
|
230
|
+
const text = buf.subarray(0, Math.min(buf.length, 8 * 1024 * 1024)).toString("latin1");
|
|
231
|
+
|
|
232
|
+
// 1. PEM container — may hold certs AND keys; emit one item per block of interest.
|
|
233
|
+
if (text.includes("-----BEGIN ")) {
|
|
234
|
+
for (const block of pemBlocks(text)) {
|
|
235
|
+
const L = block.label;
|
|
236
|
+
if (/CERTIFICATE$/.test(L) && L !== "CERTIFICATE REQUEST" && L !== "NEW CERTIFICATE REQUEST") {
|
|
237
|
+
const x = tryX509(block.full);
|
|
238
|
+
if (x) items.push(certItem({ path, file: name, x509: x, now, expiryDays }));
|
|
239
|
+
} else if (/PRIVATE KEY$/.test(L)) {
|
|
240
|
+
const info = pemPrivateInfo(block);
|
|
241
|
+
items.push(privateKeyItem({ path, file: name, ...info }));
|
|
242
|
+
} else if (L === "PGP PRIVATE KEY BLOCK") {
|
|
243
|
+
items.push(privateKeyItem({ path, file: name, format: "pgp", algorithm: "PGP", encrypted: true }));
|
|
244
|
+
} else if (/PUBLIC KEY$/.test(L)) {
|
|
245
|
+
items.push(publicKeyItem({ path, file: name, format: "pem", algorithm: pemPublicAlgo(block) }));
|
|
246
|
+
} else if (L === "PGP PUBLIC KEY BLOCK") {
|
|
247
|
+
items.push(publicKeyItem({ path, file: name, format: "pgp", algorithm: "PGP" }));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (items.length) return items;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 2. PuTTY private key (.ppk).
|
|
254
|
+
if (/^PuTTY-User-Key-File-\d+:\s*(\S+)/m.test(text)) {
|
|
255
|
+
const algo = /^PuTTY-User-Key-File-\d+:\s*(\S+)/m.exec(text)[1];
|
|
256
|
+
const encrypted = !/^Encryption:\s*none/m.test(text);
|
|
257
|
+
items.push(privateKeyItem({ path, file: name, format: "putty", algorithm: sshAlgo(algo) || algo, encrypted }));
|
|
258
|
+
return items;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 3. One-line SSH public keys (authorized_keys / known_hosts / *.pub).
|
|
262
|
+
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean).filter(l => !l.startsWith("#"));
|
|
263
|
+
const pubLines = lines.filter(l => {
|
|
264
|
+
// known_hosts lines are "<host> <type> <b64>"; strip a leading host token.
|
|
265
|
+
const t = l.replace(/^\S+\s+/, "");
|
|
266
|
+
return SSH_PUB_RE.test(l) || SSH_PUB_RE.test(t);
|
|
267
|
+
});
|
|
268
|
+
if (pubLines.length) {
|
|
269
|
+
const first = pubLines[0];
|
|
270
|
+
const m = SSH_PUB_RE.exec(first) || SSH_PUB_RE.exec(first.replace(/^\S+\s+/, ""));
|
|
271
|
+
items.push(publicKeyItem({ path, file: name, format: "ssh", algorithm: sshAlgo(m ? m[1] : null), count: pubLines.length }));
|
|
272
|
+
return items;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 4. Binary: DER certificate, else Java keystore / PKCS#12.
|
|
276
|
+
const x = tryX509(buf);
|
|
277
|
+
if (x) { items.push(certItem({ path, file: name, x509: x, now, expiryDays })); return items; }
|
|
278
|
+
|
|
279
|
+
const ks = keystoreFormat(buf, name);
|
|
280
|
+
if (ks) {
|
|
281
|
+
items.push({
|
|
282
|
+
path, file: name, kind: "keystore", format: ks, algorithm: null, keyVisibility: null,
|
|
283
|
+
issues: [{ type: "keystore-committed", severity: "medium", message: `committed ${ks.toUpperCase()} keystore (encrypted contents not inspected)` }],
|
|
284
|
+
severity: "medium",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return items;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Java keystore magic + PKCS#12 by extension (DER SEQUENCE is ambiguous by magic).
|
|
291
|
+
function keystoreFormat(buf, name) {
|
|
292
|
+
if (buf && buf.length >= 4) {
|
|
293
|
+
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfe && buf[3] === 0xed) return "jks";
|
|
294
|
+
if (buf[0] === 0xce && buf[1] === 0xce && buf[2] === 0xce && buf[3] === 0xce) return "jceks";
|
|
295
|
+
}
|
|
296
|
+
if (/\.(p12|pfx)$/i.test(name)) return "pkcs12";
|
|
297
|
+
if (/\.(jks|keystore)$/i.test(name)) return "jks";
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
analyzeBuffer, worstSeverity,
|
|
303
|
+
// exported for unit tests:
|
|
304
|
+
pemBlocks, detectWeakSig, sshAlgo, parseOpensshPrivate, keystoreFormat, SSH_PUB_RE,
|
|
305
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/certs/index.js — public entrypoint for the certificate / key-material scanner.
|
|
3
|
+
*
|
|
4
|
+
* Not a codec (crypto material has no version/registry/CVE/EOL, so it doesn't fit
|
|
5
|
+
* the codec contract). It's a standalone scanner, wired into runReportFlow like the
|
|
6
|
+
* native-binary scan: discovers committed certificates, private/public keys (PEM,
|
|
7
|
+
* OpenSSH every algorithm, PuTTY, PGP, SSH one-liners) and Java/PKCS#12 keystores,
|
|
8
|
+
* then derives crypto-hygiene findings. 100% offline.
|
|
9
|
+
*
|
|
10
|
+
* @author: N.BRAUN
|
|
11
|
+
* @email: pp9ping@gmail.com
|
|
12
|
+
*/
|
|
13
|
+
const { scanCertificates } = require("./scan");
|
|
14
|
+
const { analyzeBuffer, worstSeverity } = require("./analyze");
|
|
15
|
+
const { isCandidate } = require("./sniff");
|
|
16
|
+
|
|
17
|
+
module.exports = { scanCertificates, analyzeBuffer, worstSeverity, isCandidate };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/certs/scan.js — walk a source tree for committed crypto material.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the shared prune policy (lib/path-filter) so the cert scan honours the
|
|
5
|
+
* same --exclude-path / --no-default-excludes rules as every other walker. Each
|
|
6
|
+
* candidate file (sniff.js) is read, hashed (sha256), and handed to analyze.js;
|
|
7
|
+
* the resulting items are returned flat. Pure I/O wrapper — no network, ever.
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
11
|
+
*/
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const crypto = require("crypto");
|
|
15
|
+
const { isCandidate } = require("./sniff");
|
|
16
|
+
const { analyzeBuffer } = require("./analyze");
|
|
17
|
+
|
|
18
|
+
// Same default-skip set the other file walkers use.
|
|
19
|
+
const SKIP = new Set([
|
|
20
|
+
".git", ".idea", ".vscode", "node_modules", "dist", "build", "out",
|
|
21
|
+
"target", "vendor", "testdata", ".svn", ".hg", ".gradle", ".cache",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const MAX_FILE = 8 * 1024 * 1024; // crypto material is small; skip anything huge
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Walk `dir`, return a flat array of cert/key/keystore items (analyze.js shape),
|
|
28
|
+
* each stamped with `sha256` of the file.
|
|
29
|
+
* @param {string} dir
|
|
30
|
+
* @param {{srcRoot?, excludePath?, defaultExcludes?, now?, expiryDays?, onProgress?}} opts
|
|
31
|
+
*/
|
|
32
|
+
function scanCertificates(dir, opts = {}) {
|
|
33
|
+
if (!dir) return [];
|
|
34
|
+
const { makeDirFilter } = require("../path-filter");
|
|
35
|
+
const skipDir = makeDirFilter({
|
|
36
|
+
srcRoot: opts.srcRoot || dir, defaultSkip: SKIP,
|
|
37
|
+
excludePath: opts.excludePath, useDefaults: opts.defaultExcludes !== false,
|
|
38
|
+
});
|
|
39
|
+
const now = opts.now || Date.now();
|
|
40
|
+
const expiryDays = opts.expiryDays != null ? opts.expiryDays : 90;
|
|
41
|
+
const out = [];
|
|
42
|
+
const stack = [dir];
|
|
43
|
+
while (stack.length) {
|
|
44
|
+
const cur = stack.pop();
|
|
45
|
+
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
const fp = path.join(cur, e.name);
|
|
48
|
+
if (e.isDirectory()) { if (!skipDir(fp, e.name)) stack.push(fp); continue; }
|
|
49
|
+
if (!e.isFile()) continue;
|
|
50
|
+
if (!isCandidate(e.name)) continue;
|
|
51
|
+
let st; try { st = fs.statSync(fp); } catch { continue; }
|
|
52
|
+
if (st.size > MAX_FILE || st.size === 0) continue;
|
|
53
|
+
let buf; try { buf = fs.readFileSync(fp); } catch { continue; }
|
|
54
|
+
if (opts.onProgress) opts.onProgress(fp);
|
|
55
|
+
const items = analyzeBuffer({ name: e.name, path: fp, buf, now, expiryDays });
|
|
56
|
+
if (!items.length) continue;
|
|
57
|
+
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
|
|
58
|
+
for (const it of items) { it.sha256 = sha256; out.push(it); }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Worst severity first, then path — matches the report's priority-led ordering.
|
|
62
|
+
const RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
63
|
+
out.sort((a, b) => (RANK[b.severity] || 0) - (RANK[a.severity] || 0) || String(a.path).localeCompare(String(b.path)));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scanCertificates, SKIP };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/certs/sniff.js — cheap candidacy gate for the certificate / key-material scanner.
|
|
3
|
+
*
|
|
4
|
+
* Decides which files are worth opening, by extension OR conventional basename
|
|
5
|
+
* (SSH key files like `id_rsa` / `authorized_keys` / `known_hosts` carry no
|
|
6
|
+
* extension). The actual classification (cert vs private vs public vs keystore)
|
|
7
|
+
* is content-based and lives in analyze.js — this module only narrows the walk.
|
|
8
|
+
*
|
|
9
|
+
* @author: N.BRAUN
|
|
10
|
+
* @email: pp9ping@gmail.com
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Extensions worth opening: X.509 (pem/crt/cer/der), key material (key/pub),
|
|
14
|
+
// keystores (p12/pfx/jks/keystore), PuTTY (ppk), PGP (asc/gpg/pgp).
|
|
15
|
+
const EXT_RE = /\.(pem|crt|cer|der|key|pub|p12|pfx|jks|keystore|ppk|asc|gpg|pgp)$/i;
|
|
16
|
+
|
|
17
|
+
// Conventional SSH key files that carry NO extension. Covers every algorithm
|
|
18
|
+
// (rsa/dsa/ecdsa/ed25519 + FIDO `_sk` variants) plus the agent/host files.
|
|
19
|
+
const NAME_SET = new Set([
|
|
20
|
+
"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "id_ecdsa_sk", "id_ed25519_sk",
|
|
21
|
+
"authorized_keys", "authorized_keys2", "known_hosts",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** True if `name` is worth opening for cert/key analysis. */
|
|
25
|
+
function isCandidate(name) {
|
|
26
|
+
return EXT_RE.test(name) || NAME_SET.has(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { isCandidate, EXT_RE, NAME_SET };
|
package/lib/codecs/go/parse.js
CHANGED
|
@@ -13,33 +13,75 @@ const { compareMavenVersions } = require("../../maven-version");
|
|
|
13
13
|
|
|
14
14
|
function stripV(v) { return String(v || "").replace(/^v/, ""); }
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// "old [vX] => new vY" (module replace) or "old [vX] => ../dir" (directory
|
|
17
|
+
// replace — per spec a replacement WITHOUT a version must be a directory path).
|
|
18
|
+
function parseReplaceLine(s) {
|
|
19
|
+
const m = s.match(/^(\S+)(?:\s+(v\S+))?\s+=>\s+(\S+)(?:\s+(v\S+))?$/);
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
return { oldName: m[1], oldVer: m[2] ? stripV(m[2]) : null, newName: m[3], newVer: m[4] ? stripV(m[4]) : null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse go.mod → { module, goVersion, deps, dropped }.
|
|
26
|
+
* `replace` directives are APPLIED: the effective build uses the replacement
|
|
27
|
+
* module/version, so scanning the raw require line would miss a downgrade
|
|
28
|
+
* (false negative) or flag an already-redirected module (false positive).
|
|
29
|
+
* Directory replaces have no scannable version — the dep is dropped and
|
|
30
|
+
* surfaced in `dropped` so the codec can emit a chapter-0 warning.
|
|
31
|
+
*/
|
|
17
32
|
function parseGoMod(text) {
|
|
18
|
-
const out = { module: null, deps: [] };
|
|
33
|
+
const out = { module: null, goVersion: null, deps: [], dropped: [] };
|
|
19
34
|
const lines = String(text || "").split(/\r?\n/);
|
|
20
35
|
let inRequire = false;
|
|
21
|
-
|
|
36
|
+
let inReplace = false;
|
|
37
|
+
const replaces = [];
|
|
22
38
|
const addReq = (name, ver, indirect) => {
|
|
23
39
|
if (!name || !ver) return;
|
|
24
|
-
const key = `${name}@${ver}`;
|
|
25
|
-
if (seen.has(key)) return;
|
|
26
|
-
seen.add(key);
|
|
27
40
|
out.deps.push({ name, version: stripV(ver), scope: indirect ? "transitive" : "compile", isDev: false });
|
|
28
41
|
};
|
|
29
42
|
for (let raw of lines) {
|
|
30
43
|
const noComment = raw.split("//")[0].trim();
|
|
31
44
|
const indirect = /\/\/\s*indirect/.test(raw);
|
|
32
45
|
if (noComment.startsWith("module ")) { out.module = noComment.slice(7).trim(); continue; }
|
|
46
|
+
if (noComment.startsWith("go ")) { out.goVersion = noComment.slice(3).trim(); continue; }
|
|
33
47
|
if (noComment === "require (") { inRequire = true; continue; }
|
|
34
|
-
if (
|
|
48
|
+
if (noComment === "replace (") { inReplace = true; continue; }
|
|
49
|
+
if ((inRequire || inReplace) && noComment === ")") { inRequire = inReplace = false; continue; }
|
|
50
|
+
if (inReplace) {
|
|
51
|
+
const r = parseReplaceLine(noComment);
|
|
52
|
+
if (r) replaces.push(r);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
35
55
|
if (inRequire) {
|
|
36
56
|
const m = noComment.match(/^(\S+)\s+(\S+)/);
|
|
37
57
|
if (m) addReq(m[1], m[2], indirect);
|
|
38
58
|
continue;
|
|
39
59
|
}
|
|
40
60
|
const single = noComment.match(/^require\s+(\S+)\s+(\S+)/);
|
|
41
|
-
if (single) addReq(single[1], single[2], indirect);
|
|
61
|
+
if (single) { addReq(single[1], single[2], indirect); continue; }
|
|
62
|
+
const rep = noComment.startsWith("replace ") ? parseReplaceLine(noComment.slice(8).trim()) : null;
|
|
63
|
+
if (rep) replaces.push(rep);
|
|
42
64
|
}
|
|
65
|
+
// Apply replaces: versioned old matches that exact version only; versionless
|
|
66
|
+
// old matches every version of the module.
|
|
67
|
+
if (replaces.length) {
|
|
68
|
+
const kept = [];
|
|
69
|
+
for (const dep of out.deps) {
|
|
70
|
+
const r = replaces.find(x => x.oldName === dep.name && (!x.oldVer || x.oldVer === dep.version));
|
|
71
|
+
if (!r) { kept.push(dep); continue; }
|
|
72
|
+
if (r.newVer) kept.push({ ...dep, name: r.newName, version: r.newVer, replaced: true });
|
|
73
|
+
else out.dropped.push({ name: dep.name, version: dep.version, path: r.newName });
|
|
74
|
+
}
|
|
75
|
+
out.deps = kept;
|
|
76
|
+
}
|
|
77
|
+
// Dedupe by name@version (a replace can converge two requires onto one coord).
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
out.deps = out.deps.filter(d => {
|
|
80
|
+
const k = `${d.name}@${d.version}`;
|
|
81
|
+
if (seen.has(k)) return false;
|
|
82
|
+
seen.add(k);
|
|
83
|
+
return true;
|
|
84
|
+
});
|
|
43
85
|
return out;
|
|
44
86
|
}
|
|
45
87
|
|
|
@@ -74,7 +74,9 @@ async function checkGoRegistryDeps(deps, opts = {}) {
|
|
|
74
74
|
if (verbose) process.stdout.write(` outdated: ${t.name} ${t.version} → ${ex.latest}\n`);
|
|
75
75
|
}
|
|
76
76
|
})));
|
|
77
|
-
|
|
77
|
+
// Offline reads must not re-stamp freshness — a stale cache would then look
|
|
78
|
+
// fresh to the next ONLINE run and skip its refetch.
|
|
79
|
+
if (!offline) cache.meta = { fetchedAt: Date.now() };
|
|
78
80
|
saveCache(cache);
|
|
79
81
|
return result;
|
|
80
82
|
}
|
package/lib/codecs/go.codec.js
CHANGED
|
@@ -50,19 +50,37 @@ module.exports = {
|
|
|
50
50
|
out.set(coordKeyFor("go", "", d.name), makeDepRecord({ ecosystem: "go", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: false }));
|
|
51
51
|
};
|
|
52
52
|
for (const g of findGoDirs(dir, dirFilter(dir, opts))) {
|
|
53
|
+
let modHadDeps = false;
|
|
54
|
+
let goMinor = null;
|
|
53
55
|
if (g.names.has("go.mod")) {
|
|
54
56
|
const fp = path.join(g.dir, "go.mod");
|
|
55
57
|
parsedManifests.push(fp);
|
|
56
58
|
try {
|
|
57
59
|
const r = G.parseGoModFile(fp);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
const gm = /^1\.(\d+)/.exec(r.goVersion || "");
|
|
61
|
+
goMinor = gm ? Number(gm[1]) : null;
|
|
62
|
+
for (const d of r.dropped) {
|
|
63
|
+
warnings.push({ type: "local-replace", manifestPath: fp, message: `${d.name}@${d.version} is replaced by local path ${d.path} — replacement version unknown, not scanned` });
|
|
64
|
+
}
|
|
65
|
+
if (r.deps.length) { for (const d of r.deps) add(d, fp); modHadDeps = true; }
|
|
66
|
+
// else: go.mod with no require list (rare / very old) → fall through to go.sum.
|
|
60
67
|
} catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.mod parse failed: ${e.message}` }); }
|
|
61
68
|
}
|
|
62
|
-
|
|
69
|
+
// go.mod lists the FULL pruned graph only from `go 1.17` on. For older
|
|
70
|
+
// (or unversioned) modules it lists direct deps only, so merge the
|
|
71
|
+
// go.sum graph for anything go.mod didn't select — skipping it silently
|
|
72
|
+
// dropped every transitive dep of a pre-1.17 module.
|
|
73
|
+
const needSum = !modHadDeps || goMinor == null || goMinor < 17;
|
|
74
|
+
if (needSum && g.names.has("go.sum")) {
|
|
63
75
|
const fp = path.join(g.dir, "go.sum");
|
|
64
76
|
parsedManifests.push(fp);
|
|
65
|
-
try {
|
|
77
|
+
try {
|
|
78
|
+
const r = G.parseGoSumFile(fp);
|
|
79
|
+
for (const d of r.deps) {
|
|
80
|
+
if (out.has(coordKeyFor("go", "", d.name))) continue; // go.mod's selected version wins
|
|
81
|
+
add(d, fp);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
66
84
|
catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `go.sum parse failed: ${e.message}` }); }
|
|
67
85
|
}
|
|
68
86
|
}
|
|
@@ -20,6 +20,15 @@ const xml2js = require("xml2js");
|
|
|
20
20
|
// range "[1.0,2.0)", and wildcard/comma/bracket specifiers.
|
|
21
21
|
function isConcrete(v) { const s = String(v || ""); return /^\d/.test(s) && /^[\w.+-]+$/.test(s); }
|
|
22
22
|
|
|
23
|
+
// "[1.2.3]" is NuGet's EXACT-version pin (a single-version range, common in
|
|
24
|
+
// csproj) — unwrap it to the inner concrete version instead of rejecting it.
|
|
25
|
+
function concreteVersion(v) {
|
|
26
|
+
let s = String(v || "").trim();
|
|
27
|
+
const exact = /^\[\s*([^,\[\]]+)\s*\]$/.exec(s);
|
|
28
|
+
if (exact) s = exact[1].trim();
|
|
29
|
+
return isConcrete(s) ? s : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
async function parsePackagesLockJson(filePath) {
|
|
24
33
|
const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
25
34
|
const deps = [];
|
|
@@ -56,9 +65,15 @@ async function parseCsproj(filePath, cpm = {}) {
|
|
|
56
65
|
for (const ig of xml.Project?.ItemGroup || []) {
|
|
57
66
|
for (const pr of ig.PackageReference || []) {
|
|
58
67
|
const name = pr.$?.Include; if (!name) continue;
|
|
59
|
-
// Version:
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
// Version: VersionOverride (CPM per-project override) wins, then the
|
|
69
|
+
// attribute, then a child element, then the Directory.Packages.props pin.
|
|
70
|
+
const raw = pr.$?.VersionOverride
|
|
71
|
+
|| (Array.isArray(pr.VersionOverride) ? pr.VersionOverride[0] : null)
|
|
72
|
+
|| pr.$?.Version
|
|
73
|
+
|| (Array.isArray(pr.Version) ? pr.Version[0] : null)
|
|
74
|
+
|| cpm[name.toLowerCase()] || null;
|
|
75
|
+
const version = concreteVersion(raw);
|
|
76
|
+
if (version) deps.push({ name, version, scope: "prod", isDev: false });
|
|
62
77
|
else skipped++;
|
|
63
78
|
}
|
|
64
79
|
}
|
|
@@ -69,10 +84,10 @@ async function parsePackagesConfig(filePath) {
|
|
|
69
84
|
const xml = await xml2js.parseStringPromise(fs.readFileSync(filePath, "utf8"));
|
|
70
85
|
const deps = [];
|
|
71
86
|
for (const p of xml.packages?.package || []) {
|
|
72
|
-
const name = p.$?.id; const version = p.$?.version;
|
|
73
|
-
if (name && version
|
|
87
|
+
const name = p.$?.id; const version = concreteVersion(p.$?.version);
|
|
88
|
+
if (name && version) deps.push({ name, version, scope: "prod", isDev: false });
|
|
74
89
|
}
|
|
75
90
|
return { manifestPath: filePath, manifestType: "packages.config", deps };
|
|
76
91
|
}
|
|
77
92
|
|
|
78
|
-
module.exports = { isConcrete, parsePackagesLockJson, parseDirectoryPackagesProps, parseCsproj, parsePackagesConfig };
|
|
93
|
+
module.exports = { isConcrete, concreteVersion, parsePackagesLockJson, parseDirectoryPackagesProps, parseCsproj, parsePackagesConfig };
|