nsauditor-ai 0.2.56 → 0.2.57
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 +41 -40
- package/cli.mjs +171 -147
- package/mcp_server.mjs +64 -37
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -2
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -31
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/executive_report.mjs +34 -7
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +4 -2
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_md.mjs +49 -125
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +173 -18
- package/utils/scan_delta_view.mjs +3 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import tls from "node:tls";
|
|
25
25
|
import { isIP } from "node:net";
|
|
26
|
+
import { X509Certificate } from "node:crypto";
|
|
26
27
|
|
|
27
28
|
// ── Severity ─────────────────────────────────────────────────────────────────
|
|
28
29
|
|
|
@@ -84,22 +85,60 @@ const WEAK_CIPHER_FRAGMENTS = [
|
|
|
84
85
|
];
|
|
85
86
|
|
|
86
87
|
const DEPRECATED_PROTOCOLS = new Set(["SSLv2", "SSLv3", "TLSv1", "TLSv1.1"]);
|
|
87
|
-
const WEAK_SIG_ALGORITHMS = /sha1WithRSA|md5WithRSA|md2WithRSA|sha1-with-rsa|dsaWithSHA1/i;
|
|
88
|
+
const WEAK_SIG_ALGORITHMS = /sha1WithRSA|md5WithRSA|md2WithRSA|sha1-with-rsa|dsaWithSHA1|ecdsa-with-SHA1/i;
|
|
89
|
+
|
|
90
|
+
// ⚠️ THE SIGNATURE ALGORITHM IS NOT ON getPeerCertificate() (1.3.0 build 5, Gate 3-A F-3b). Node's peer certificate
|
|
91
|
+
// carries subject, issuer, modulus, bits, raw … and no signature algorithm (measured on Node 24.12), so the leaf and
|
|
92
|
+
// chain checks below read "unknown" and could not fire from e96c8f9 (2026-04-08) on. It is in the DER: X509Certificate
|
|
93
|
+
// reads it on Node 24 (measured v24.12.0), and the getter does NOT EXIST on Node 20 (v20.19.6, CE's engines floor);
|
|
94
|
+
// Node 22 is not measured. Where it cannot be read, the algorithm is NOT ASSESSED — never "unknown" read as a pass.
|
|
95
|
+
export function signatureAlgorithmOf(peerCert) {
|
|
96
|
+
if (!peerCert?.raw) return null;
|
|
97
|
+
try {
|
|
98
|
+
const alg = new X509Certificate(peerCert.raw).signatureAlgorithm;
|
|
99
|
+
return typeof alg === "string" && alg ? alg : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// The reason is a claim, so it names the cause it measured: the runtime sentence only when the getter does not EXIST
|
|
105
|
+
// (checked when the strength is decided, not at load); any other null — no DER on the peer object, a parse failure —
|
|
106
|
+
// says the algorithm could not be read, so an operator is not sent to upgrade Node for a certificate it cannot parse.
|
|
107
|
+
export function signatureStrengthOf(sigAlg) {
|
|
108
|
+
if (sigAlg) return "assessed";
|
|
109
|
+
return "signatureAlgorithm" in X509Certificate.prototype
|
|
110
|
+
? "not assessed — the certificate's signature algorithm could not be read"
|
|
111
|
+
: "not assessed — this Node runtime does not report certificate signature algorithms (Node 24 does; Node 20 does not)";
|
|
112
|
+
}
|
|
113
|
+
// A certificate that issued itself: its own signature is verified by no client (an anchor, or a leaf that is its own).
|
|
114
|
+
const selfIssued = (c) => !!c?.issuerCertificate && c.issuerCertificate.fingerprint256 === c.fingerprint256;
|
|
88
115
|
|
|
89
116
|
// ── Hostname Validation ──────────────────────────────────────────────────────
|
|
90
117
|
// Checks CN and SANs against the target hostname, handling wildcards.
|
|
91
118
|
|
|
92
119
|
function validateHostname(cert, hostname) {
|
|
93
120
|
if (isIP(hostname)) {
|
|
94
|
-
// For IP connections, check IP SANs
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
// For IP connections, check IP SANs — as ADDRESSES, never as strings: Node prints an IPv6 SAN expanded
|
|
122
|
+
// ("0:0:0:0:0:0:0:1") where the target may be written compressed ("::1").
|
|
123
|
+
const target = canonicalAddress(hostname);
|
|
124
|
+
return extractSANs(cert, "IP").some((san) => canonicalAddress(san) === target);
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
const names = getAllNames(cert);
|
|
100
128
|
return names.some((name) => matchesHostname(name, hostname));
|
|
101
129
|
}
|
|
102
130
|
|
|
131
|
+
// The canonical text of an IP literal (IPv6 compressed, lower-case), or null for anything that is not one.
|
|
132
|
+
function canonicalAddress(addr) {
|
|
133
|
+
const family = isIP(String(addr));
|
|
134
|
+
if (!family) return null;
|
|
135
|
+
try {
|
|
136
|
+
return new URL(`http://${family === 6 ? `[${addr}]` : addr}/`).hostname;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
function getAllNames(cert) {
|
|
104
143
|
const names = [];
|
|
105
144
|
|
|
@@ -117,11 +156,16 @@ function getAllNames(cert) {
|
|
|
117
156
|
|
|
118
157
|
function extractSANs(cert, type) {
|
|
119
158
|
if (!cert.subjectaltname) return [];
|
|
159
|
+
// 0.2.57: Node prints an IP SAN as "IP Address:<addr>" (measured — "DNS:a.example, IP Address:127.0.0.1"), so an
|
|
160
|
+
// "IP:" filter alone never found one and every IP target read as a mismatch. Both spellings are read.
|
|
161
|
+
const prefixes = type === "IP" ? ["IP Address:", "IP:"] : [`${type}:`];
|
|
120
162
|
return cert.subjectaltname
|
|
121
163
|
.split(",")
|
|
122
164
|
.map((s) => s.trim())
|
|
123
|
-
.
|
|
124
|
-
|
|
165
|
+
.flatMap((s) => {
|
|
166
|
+
const p = prefixes.find((x) => s.startsWith(x));
|
|
167
|
+
return p ? [s.slice(p.length)] : [];
|
|
168
|
+
});
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
function matchesHostname(pattern, hostname) {
|
|
@@ -170,7 +214,7 @@ function analyzeChain(cert, now) {
|
|
|
170
214
|
validTo: current.valid_to,
|
|
171
215
|
expired: now > validTo,
|
|
172
216
|
notYetValid: now < validFrom,
|
|
173
|
-
signatureAlgorithm: current
|
|
217
|
+
signatureAlgorithm: signatureAlgorithmOf(current) ?? "not reported",
|
|
174
218
|
};
|
|
175
219
|
|
|
176
220
|
chain.push(entry);
|
|
@@ -193,8 +237,10 @@ function analyzeChain(cert, now) {
|
|
|
193
237
|
});
|
|
194
238
|
}
|
|
195
239
|
|
|
196
|
-
// Weak sig in chain
|
|
197
|
-
|
|
240
|
+
// Weak sig in chain — on an ISSUED certificate only. A self-signed terminal entry is the trust anchor, whose own
|
|
241
|
+
// signature no client verifies (the architect seat's ruling, F-3b), so a SHA-1 root above SHA-256 certificates is
|
|
242
|
+
// recorded and not graded.
|
|
243
|
+
if (depth > 0 && !selfIssued(current) && WEAK_SIG_ALGORITHMS.test(entry.signatureAlgorithm)) {
|
|
198
244
|
issues.push({
|
|
199
245
|
severity: SEVERITY.MEDIUM,
|
|
200
246
|
check: "chain_weak_signature",
|
|
@@ -220,17 +266,27 @@ function analyzeChain(cert, now) {
|
|
|
220
266
|
|
|
221
267
|
// ── Key Strength Analysis ────────────────────────────────────────────────────
|
|
222
268
|
|
|
223
|
-
|
|
269
|
+
// ⚠️ THE TYPE COMES FROM THE FIELDS NODE ACTUALLY SETS (1.3.0 build 5, Gate 3-A F-3). Node's getPeerCertificate() gives
|
|
270
|
+
// `pubkey` as a raw Buffer with no `.type`; an RSA key carries `modulus` and `exponent`, an EC key a named curve
|
|
271
|
+
// (`asn1Curve` / `nistCurve`), and both carry `bits` (measured on Node 24.12). Until build 5 this read `cert.pubkey?.type`,
|
|
272
|
+
// so the type was always "unknown" and neither branch below could run on a real server — a 1024-bit RSA key raised
|
|
273
|
+
// nothing. A key of neither type (Ed25519 carries none of these fields) is NOT ASSESSED, said on certAudit, never silent.
|
|
274
|
+
export function analyzeKeyStrength(cert, config) {
|
|
224
275
|
const issues = [];
|
|
225
|
-
const keyType = cert.
|
|
226
|
-
const keyBits = cert.bits
|
|
276
|
+
const keyType = cert.modulus ? "RSA" : (cert.asn1Curve || cert.nistCurve) ? "EC" : "unknown";
|
|
277
|
+
const keyBits = Number.isFinite(cert.bits) && cert.bits > 0 ? cert.bits : null;
|
|
227
278
|
|
|
228
279
|
const result = {
|
|
229
280
|
type: keyType,
|
|
230
281
|
bits: keyBits,
|
|
282
|
+
strength: keyType === "unknown"
|
|
283
|
+
? "not assessed — the key is neither RSA (no modulus) nor EC (no named curve), so its size is not graded"
|
|
284
|
+
: keyBits === null
|
|
285
|
+
? `not assessed — the ${keyType} key reported no size`
|
|
286
|
+
: "assessed",
|
|
231
287
|
};
|
|
232
288
|
|
|
233
|
-
if (keyType === "RSA"
|
|
289
|
+
if (keyType === "RSA") {
|
|
234
290
|
if (keyBits && keyBits < config.minRsaBits) {
|
|
235
291
|
issues.push({
|
|
236
292
|
severity: keyBits < 1024 ? SEVERITY.CRITICAL : SEVERITY.HIGH,
|
|
@@ -238,7 +294,7 @@ function analyzeKeyStrength(cert, config) {
|
|
|
238
294
|
detail: `RSA key is ${keyBits} bits (minimum recommended: ${config.minRsaBits})`,
|
|
239
295
|
});
|
|
240
296
|
}
|
|
241
|
-
} else if (keyType === "EC"
|
|
297
|
+
} else if (keyType === "EC") {
|
|
242
298
|
if (keyBits && keyBits < config.minEcBits) {
|
|
243
299
|
issues.push({
|
|
244
300
|
severity: SEVERITY.HIGH,
|
|
@@ -390,23 +446,47 @@ async function auditPort(host, port, config) {
|
|
|
390
446
|
cert.subject?.O === cert.issuer?.O &&
|
|
391
447
|
cert.fingerprint256 === cert.issuerCertificate?.fingerprint256;
|
|
392
448
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
detail: "Certificate is self-signed — not trusted by clients",
|
|
398
|
-
});
|
|
399
|
-
}
|
|
449
|
+
const selfSignedIssue = isSelfSigned
|
|
450
|
+
? { severity: SEVERITY.HIGH, check: "self_signed", detail: "Certificate is self-signed — not trusted by clients" }
|
|
451
|
+
: null;
|
|
452
|
+
if (selfSignedIssue) issues.push(selfSignedIssue);
|
|
400
453
|
|
|
401
454
|
// ── Hostname Mismatch ──────────────────────────────────────────────────
|
|
402
455
|
const hostnameValid = validateHostname(cert, host);
|
|
403
456
|
if (!hostnameValid) {
|
|
404
457
|
const certNames = getAllNames(cert).join(", ");
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
458
|
+
// 0.2.57: graded by the target's form. Scanned by ADDRESS, a certificate that names DNS names only cannot match —
|
|
459
|
+
// a true finding (a client connecting by address sees the mismatch), kept, but LOW: it is expected wherever the
|
|
460
|
+
// service is reached by name. LOW, never INFO — the grading table drops `info`, which would hide it from every
|
|
461
|
+
// reader. HIGH stays HIGH for a DNS-name target the certificate does not name, a certificate naming a DIFFERENT
|
|
462
|
+
// address, and one naming no SAN at all.
|
|
463
|
+
const dnsSans = extractSANs(cert, "DNS");
|
|
464
|
+
const ipSans = extractSANs(cert, "IP");
|
|
465
|
+
const byAddress = isIP(host) !== 0 && dnsSans.length > 0 && ipSans.length === 0;
|
|
466
|
+
// A certificate with NO subjectAltName names no host a modern (RFC 6125) client checks — the CN is ignored — so
|
|
467
|
+
// "does not match certificate names: <CN>" told the reader a usable name was present and merely differed. HIGH,
|
|
468
|
+
// with that said (0.2.57, before this text had any prior identity in a delta).
|
|
469
|
+
const noSan = dnsSans.length === 0 && ipSans.length === 0;
|
|
470
|
+
issues.push(noSan
|
|
471
|
+
? {
|
|
472
|
+
severity: SEVERITY.HIGH,
|
|
473
|
+
check: "hostname_mismatch",
|
|
474
|
+
detail: `Hostname "${host}": the certificate carries no subjectAltName — modern clients reject it for any name `
|
|
475
|
+
+ (cert.subject?.CN ? `(CN ${cert.subject.CN} is not checked)` : "(and it carries no CN either)"),
|
|
476
|
+
}
|
|
477
|
+
: byAddress
|
|
478
|
+
? {
|
|
479
|
+
severity: SEVERITY.LOW,
|
|
480
|
+
check: "hostname_mismatch",
|
|
481
|
+
detail: `Hostname "${host}" does not match certificate names: ${certNames} — the certificate names DNS names only `
|
|
482
|
+
+ `(${dnsSans.join(", ")}); a client connecting by address sees a name mismatch, expected where the service is `
|
|
483
|
+
+ "reached by name",
|
|
484
|
+
}
|
|
485
|
+
: {
|
|
486
|
+
severity: SEVERITY.HIGH,
|
|
487
|
+
check: "hostname_mismatch",
|
|
488
|
+
detail: `Hostname "${host}" does not match certificate names: ${certNames}`,
|
|
489
|
+
});
|
|
410
490
|
}
|
|
411
491
|
|
|
412
492
|
// ── Wildcard Sprawl ────────────────────────────────────────────────────
|
|
@@ -421,13 +501,22 @@ async function auditPort(host, port, config) {
|
|
|
421
501
|
}
|
|
422
502
|
|
|
423
503
|
// ── Signature Algorithm ────────────────────────────────────────────────
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
504
|
+
// Read from the DER (signatureAlgorithmOf, above). A weak algorithm on a SELF-SIGNED leaf is not graded — the leaf is
|
|
505
|
+
// its own anchor, so no client verifies that signature (option B, ruled) — and the self_signed finding says which
|
|
506
|
+
// algorithm and why, so the skip is visible; the algorithm is recorded either way.
|
|
507
|
+
const sigAlg = signatureAlgorithmOf(cert);
|
|
508
|
+
const sigStrength = signatureStrengthOf(sigAlg);
|
|
509
|
+
if (sigAlg && WEAK_SIG_ALGORITHMS.test(sigAlg)) {
|
|
510
|
+
if (selfSignedIssue) {
|
|
511
|
+
selfSignedIssue.detail += `; signed with ${sigAlg} — not graded on a self-signed certificate, whose signature no `
|
|
512
|
+
+ "client verifies (pin it by fingerprint)";
|
|
513
|
+
} else {
|
|
514
|
+
issues.push({
|
|
515
|
+
severity: SEVERITY.HIGH,
|
|
516
|
+
check: "weak_signature",
|
|
517
|
+
detail: `Weak signature algorithm: ${sigAlg}`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
431
520
|
}
|
|
432
521
|
|
|
433
522
|
// ── Key Strength ───────────────────────────────────────────────────────
|
|
@@ -447,8 +536,10 @@ async function auditPort(host, port, config) {
|
|
|
447
536
|
});
|
|
448
537
|
}
|
|
449
538
|
|
|
450
|
-
// Forward secrecy check
|
|
451
|
-
|
|
539
|
+
// Forward secrecy check. 0.2.57: TLS 1.3 by the PROTOCOL — every TLS 1.3 suite is ephemeral by construction, and
|
|
540
|
+
// its name (TLS_AES_256_GCM_SHA384) carries neither ECDHE nor DHE, so the name test called every TLS 1.3 server
|
|
541
|
+
// "no forward secrecy". Below TLS 1.3, the cipher name as before.
|
|
542
|
+
const hasForwardSecrecy = protocol === "TLSv1.3" || /ECDHE|DHE/i.test(cipher.name);
|
|
452
543
|
if (!hasForwardSecrecy) {
|
|
453
544
|
issues.push({
|
|
454
545
|
severity: SEVERITY.MEDIUM,
|
|
@@ -479,11 +570,26 @@ async function auditPort(host, port, config) {
|
|
|
479
570
|
issues.push(...chainAnalysis.issues);
|
|
480
571
|
|
|
481
572
|
// ── Node.js authorization check (CA trust store validation) ────────────
|
|
482
|
-
|
|
573
|
+
// 0.2.57: NOT on a name mismatch alone. With a chain the CA store VERIFIED and only the name wrong, Node reports
|
|
574
|
+
// authorized=false with authorizationError ERR_TLS_CERT_ALTNAME_INVALID (measured: the same CA-signed certificate
|
|
575
|
+
// reads authorized=true once its IP SAN matches the target), and this said the store distrusted a chain it trusted.
|
|
576
|
+
// hostname_mismatch carries that fact. Keyed on the EXACT code: a chain that fails reports its own code, so an
|
|
577
|
+
// untrusted chain with a wrong name keeps this finding beside the mismatch.
|
|
578
|
+
//
|
|
579
|
+
// 1.3.0 build 5 (the architect seat's ruling): Node reports UNSPECIFIED for a verify error outside its named table —
|
|
580
|
+
// measured for a 1024-bit leaf key and for a SHA-1 signature under a CA the store DOES hold, both refused by the
|
|
581
|
+
// verifier's policy. "Not trusted by system CA store" would name a cause that is false there, so UNSPECIFIED states the
|
|
582
|
+
// fact and no cause; where this certificate carries a graded weak key or signature, it points at that grade (a pointer,
|
|
583
|
+
// not a causal claim — the refusal's cause is not measured here). Named codes keep their wording.
|
|
584
|
+
if (!probe.authorized && !isSelfSigned && probe.authError !== "ERR_TLS_CERT_ALTNAME_INVALID") {
|
|
585
|
+
const weakGrade = issues.some((i) => ["weak_rsa_key", "weak_ec_key", "weak_signature", "chain_weak_signature"].includes(i.check));
|
|
483
586
|
issues.push({
|
|
484
587
|
severity: SEVERITY.MEDIUM,
|
|
485
588
|
check: "ca_not_trusted",
|
|
486
|
-
detail:
|
|
589
|
+
detail: probe.authError === "UNSPECIFIED"
|
|
590
|
+
? "Certificate chain refused by this runtime's verifier — Node reports no named reason (UNSPECIFIED)"
|
|
591
|
+
+ (weakGrade ? "; the graded weak key / signature on this certificate is the actionable finding" : "")
|
|
592
|
+
: `Certificate not trusted by system CA store: ${probe.authError || "unknown reason"}`,
|
|
487
593
|
});
|
|
488
594
|
}
|
|
489
595
|
|
|
@@ -512,9 +618,11 @@ async function auditPort(host, port, config) {
|
|
|
512
618
|
selfSigned: isSelfSigned,
|
|
513
619
|
hostnameValid,
|
|
514
620
|
names: allNames,
|
|
515
|
-
signatureAlgorithm: sigAlg,
|
|
621
|
+
signatureAlgorithm: sigAlg ?? "not reported",
|
|
622
|
+
signatureStrength: sigStrength,
|
|
516
623
|
keyType: keyAnalysis.keyInfo.type,
|
|
517
624
|
keyBits: keyAnalysis.keyInfo.bits,
|
|
625
|
+
keyStrength: keyAnalysis.keyInfo.strength,
|
|
518
626
|
fingerprint256: cert.fingerprint256,
|
|
519
627
|
serialNumber: cert.serialNumber,
|
|
520
628
|
},
|
|
@@ -698,19 +806,24 @@ export default {
|
|
|
698
806
|
status = "valid";
|
|
699
807
|
}
|
|
700
808
|
|
|
701
|
-
//
|
|
809
|
+
// Actionable issues (skip PASS and INFO), each with its OWN severity and check — 1.3.0 (s1): the shared
|
|
810
|
+
// service-flag table grades one finding per issue, and a detail string alone left only the port's roll-up.
|
|
702
811
|
const actionableIssues = pr.issues
|
|
703
812
|
.filter((i) => i.severity !== SEVERITY.PASS && i.severity !== SEVERITY.INFO)
|
|
704
|
-
.map((i) => i.detail);
|
|
705
|
-
|
|
813
|
+
.map((i) => ({ severity: i.severity, check: i.check, detail: i.detail }));
|
|
814
|
+
// The roll-up is DERIVED from the issues carried here, never copied from the port result: a second copy of a datum
|
|
815
|
+
// is a copy that can disagree. Null when nothing is actionable.
|
|
816
|
+
const rollUp = [SEVERITY.CRITICAL, SEVERITY.HIGH, SEVERITY.MEDIUM, SEVERITY.LOW]
|
|
817
|
+
.find((sev) => actionableIssues.some((i) => i.severity === sev)) ?? null;
|
|
818
|
+
|
|
819
|
+
// 1.3.0: the audit travels under `certAudit`, never in identity. This record wrote program "TLS" and the
|
|
820
|
+
// NEGOTIATED protocol as the service version, and once the concluder reached it, that version would have
|
|
821
|
+
// filled the blank on the TLS scanner's authoritative record.
|
|
706
822
|
items.push({
|
|
707
823
|
port: pr.port,
|
|
708
824
|
protocol: "tcp",
|
|
709
825
|
service: pr.service,
|
|
710
|
-
program: "TLS",
|
|
711
|
-
version: pr.negotiation.protocol,
|
|
712
826
|
status: "open",
|
|
713
|
-
severity: pr.severity,
|
|
714
827
|
info: [
|
|
715
828
|
status,
|
|
716
829
|
`${pr.certificate.daysToExpiry}d remaining`,
|
|
@@ -719,18 +832,25 @@ export default {
|
|
|
719
832
|
pr.certificate.selfSigned ? "self-signed" : null,
|
|
720
833
|
pr.certificate.hostnameValid ? null : "hostname-mismatch",
|
|
721
834
|
].filter(Boolean).join(" | "),
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
835
|
+
certAudit: {
|
|
836
|
+
severity: rollUp,
|
|
837
|
+
certStatus: status,
|
|
838
|
+
negotiatedProtocol: pr.negotiation.protocol,
|
|
839
|
+
issues: actionableIssues,
|
|
840
|
+
details: {
|
|
841
|
+
subject: pr.certificate.subject,
|
|
842
|
+
issuer: pr.certificate.issuer,
|
|
843
|
+
names: pr.certificate.names,
|
|
844
|
+
validFrom: pr.certificate.validFrom,
|
|
845
|
+
validTo: pr.certificate.validTo,
|
|
846
|
+
signatureAlgorithm: pr.certificate.signatureAlgorithm,
|
|
847
|
+
signatureStrength: pr.certificate.signatureStrength,
|
|
848
|
+
keyType: pr.certificate.keyType,
|
|
849
|
+
keyBits: pr.certificate.keyBits,
|
|
850
|
+
keyStrength: pr.certificate.keyStrength,
|
|
851
|
+
chainDepth: pr.chain.depth,
|
|
852
|
+
authorized: pr.authorized,
|
|
853
|
+
},
|
|
734
854
|
},
|
|
735
855
|
// ZDE: fingerprints and serial numbers stay in-process.
|
|
736
856
|
// Conclude emits only classifications and metadata.
|
|
@@ -590,58 +590,37 @@ export default {
|
|
|
590
590
|
},
|
|
591
591
|
|
|
592
592
|
// ── Conclude: NSAuditor report items ────────────────────────────────────
|
|
593
|
+
// 1.3.0: ONE record on 8080, its findings under `tribeHealth`. The per-finding records shared the summary's
|
|
594
|
+
// protocol:port key and were dropped by the merge once the concluder reached this adapter — the CRITICAL among them.
|
|
593
595
|
conclude({ result }) {
|
|
594
596
|
if (!result.up) {
|
|
597
|
+
// The API probe failed on a port the scan found open: that says nothing about the port's liveness.
|
|
595
598
|
return [{
|
|
596
599
|
port: 8080,
|
|
597
600
|
protocol: "tcp",
|
|
598
601
|
service: "tribe-v2",
|
|
599
|
-
status: "down",
|
|
600
|
-
severity: SEVERITY.INFO,
|
|
601
602
|
info: result.error,
|
|
603
|
+
tribeHealth: { state: "down", severity: SEVERITY.INFO, error: result.error ?? null, findings: [] },
|
|
602
604
|
source: "tribe-health",
|
|
603
605
|
}];
|
|
604
606
|
}
|
|
605
607
|
|
|
606
|
-
|
|
608
|
+
// Individual findings grouped by category; only actionable ones (skip PASS and INFO in conclude).
|
|
609
|
+
// ZDE: no response bodies or header values in conclude output — classifications and issue descriptions only.
|
|
610
|
+
const findings = Object.entries(result.findings)
|
|
611
|
+
.flatMap(([category, issues]) => issues.map((i) => ({ ...i, category })))
|
|
612
|
+
.filter((f) => f.severity !== SEVERITY.PASS && f.severity !== SEVERITY.INFO)
|
|
613
|
+
.map((f) => ({ severity: f.severity, category: f.category, check: f.check, detail: f.detail }));
|
|
607
614
|
|
|
608
|
-
|
|
609
|
-
items.push({
|
|
615
|
+
return [{
|
|
610
616
|
port: 8080,
|
|
611
617
|
protocol: "tcp",
|
|
612
618
|
service: "tribe-v2",
|
|
613
619
|
status: "open",
|
|
614
|
-
severity: result.overallSeverity,
|
|
615
620
|
info: `API probe complete: ${result.summary.critical} critical, ${result.summary.high} high, ${result.summary.medium} medium findings`,
|
|
616
|
-
serverInfo: result.serverInfo,
|
|
621
|
+
tribeHealth: { state: "up", severity: result.overallSeverity, summary: result.summary, serverInfo: result.serverInfo, findings },
|
|
617
622
|
source: "tribe-health",
|
|
618
623
|
authoritative: true,
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// Individual findings grouped by category
|
|
622
|
-
const allFindings = Object.entries(result.findings).flatMap(
|
|
623
|
-
([category, issues]) => issues.map((i) => ({ ...i, category }))
|
|
624
|
-
);
|
|
625
|
-
|
|
626
|
-
// Only surface actionable items (skip PASS and INFO in conclude)
|
|
627
|
-
for (const f of allFindings) {
|
|
628
|
-
if (f.severity === SEVERITY.PASS || f.severity === SEVERITY.INFO) continue;
|
|
629
|
-
|
|
630
|
-
items.push({
|
|
631
|
-
port: 8080,
|
|
632
|
-
protocol: "tcp",
|
|
633
|
-
service: "tribe-v2",
|
|
634
|
-
severity: f.severity,
|
|
635
|
-
status: "action_required",
|
|
636
|
-
category: f.category,
|
|
637
|
-
check: f.check,
|
|
638
|
-
info: f.detail,
|
|
639
|
-
// ZDE: no response bodies or header values in conclude output.
|
|
640
|
-
// Only classifications and issue descriptions.
|
|
641
|
-
source: "tribe-health",
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
return items;
|
|
624
|
+
}];
|
|
646
625
|
},
|
|
647
626
|
};
|
|
@@ -842,6 +842,13 @@ export default {
|
|
|
842
842
|
|
|
843
843
|
// ── Main Execution ──────────────────────────────────────────────────────
|
|
844
844
|
async run(host, port, opts = {}) {
|
|
845
|
+
// 0.2.57: an IP LITERAL is DECLINED, with its reason on the plugin's status. Queried as a name it answers NXDOMAIN to
|
|
846
|
+
// every record type, and each absence became a finding (missing SPF / DMARC / NS at HIGH) on every default scan of
|
|
847
|
+
// an address. Only the literal is declined: a hostname that resolves to an address — localhost too — is a domain.
|
|
848
|
+
if (net.isIP(String(host)) !== 0) {
|
|
849
|
+
return { up: false, skipped: true,
|
|
850
|
+
reason: `DNS posture needs a domain name — ${host} is an IP address, which has no SPF, DMARC or NS records to audit` };
|
|
851
|
+
}
|
|
845
852
|
const config = loadConfig(opts);
|
|
846
853
|
const startTime = Date.now();
|
|
847
854
|
|
|
@@ -944,20 +951,29 @@ export default {
|
|
|
944
951
|
},
|
|
945
952
|
|
|
946
953
|
// ── Conclude ────────────────────────────────────────────────────────────
|
|
954
|
+
// 1.3.0: ONE record, its findings under `dnsSecurity`, ATTACH-ONLY. This audits a domain's DNS posture and runs on
|
|
955
|
+
// every scan, so it is not a port on the scanned host: it lands on a 53/udp record a port-level probe found, and
|
|
956
|
+
// otherwise becomes evidence. Its per-finding records carried NO port (they keyed to `dns:NaN`), and the summary
|
|
957
|
+
// wrote "DNS-Audit" / "v2" into identity and its verdict into the liveness field.
|
|
947
958
|
conclude({ result, host }) {
|
|
948
959
|
if (!result.findings) return [];
|
|
949
960
|
|
|
950
|
-
const
|
|
961
|
+
const findings = Object.entries(result.findings)
|
|
962
|
+
.flatMap(([category, issues]) => issues.map((i) => ({ ...i, category })))
|
|
963
|
+
.filter((f) => f.severity !== SEVERITY.PASS && f.severity !== SEVERITY.INFO)
|
|
964
|
+
.map((f) => ({ severity: f.severity, category: f.category, check: f.check, detail: f.detail }));
|
|
951
965
|
|
|
952
|
-
|
|
953
|
-
items.push({
|
|
966
|
+
return [{
|
|
954
967
|
port: 53,
|
|
955
968
|
protocol: "udp",
|
|
956
969
|
service: "dns-security",
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
970
|
+
attachOnly: true,
|
|
971
|
+
dnsSecurity: {
|
|
972
|
+
status: result.summary.actionable > 0 ? "action_required" : "hardened",
|
|
973
|
+
severity: result.overallSeverity,
|
|
974
|
+
actionable: result.summary.actionable,
|
|
975
|
+
findings,
|
|
976
|
+
},
|
|
961
977
|
info: [
|
|
962
978
|
`${result.summary.actionable} actionable findings`,
|
|
963
979
|
result.details.spfRecord ? "SPF present" : "SPF missing",
|
|
@@ -969,29 +985,7 @@ export default {
|
|
|
969
985
|
].join(" | "),
|
|
970
986
|
source: "dns-sec-auditor",
|
|
971
987
|
authoritative: false,
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// Individual actionable findings
|
|
975
|
-
const allFindings = Object.entries(result.findings).flatMap(
|
|
976
|
-
([category, issues]) => issues.map((i) => ({ ...i, category }))
|
|
977
|
-
);
|
|
978
|
-
|
|
979
|
-
for (const f of allFindings) {
|
|
980
|
-
if (f.severity === SEVERITY.PASS || f.severity === SEVERITY.INFO) continue;
|
|
981
|
-
|
|
982
|
-
items.push({
|
|
983
|
-
protocol: "dns",
|
|
984
|
-
service: "dns-security",
|
|
985
|
-
severity: f.severity,
|
|
986
|
-
status: "action_required",
|
|
987
|
-
category: f.category,
|
|
988
|
-
check: f.check,
|
|
989
|
-
info: f.detail,
|
|
990
|
-
source: "dns-sec-auditor",
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
return items;
|
|
988
|
+
}];
|
|
995
989
|
},
|
|
996
990
|
|
|
997
991
|
authoritativePorts: new Set(),
|
package/plugins/dns_scanner.mjs
CHANGED
|
@@ -202,11 +202,14 @@ function sendAxfrQuery(host, domain, port=53, timeoutMs=TIMEOUT){
|
|
|
202
202
|
let done = false;
|
|
203
203
|
const records = [];
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
// `outcome` is what was MEASURED (1.3.0 (s3)): 'allowed' (records came back), 'refused' (an error RCODE, or TCP/53
|
|
206
|
+
// refused the connection — no transfer can be had), or 'no-answer' (a timeout, a TCP error, a parse error, a close with
|
|
207
|
+
// nothing received) — nothing was measured, and that is never "denied".
|
|
208
|
+
const finish = (ok, info, outcome = ok ? 'allowed' : 'no-answer') => {
|
|
206
209
|
if (done) return;
|
|
207
210
|
done = true;
|
|
208
211
|
try { s.destroy(); } catch {}
|
|
209
|
-
resolve({ ok, info, records });
|
|
212
|
+
resolve({ ok, info, outcome, records });
|
|
210
213
|
};
|
|
211
214
|
|
|
212
215
|
const t = setTimeout(() => finish(false, 'AXFR timeout'), timeoutMs);
|
|
@@ -224,7 +227,7 @@ function sendAxfrQuery(host, domain, port=53, timeoutMs=TIMEOUT){
|
|
|
224
227
|
const rr = parseResponse(msg);
|
|
225
228
|
if (rr.rcode !== 0) {
|
|
226
229
|
clearTimeout(t);
|
|
227
|
-
return finish(false, `AXFR refused (rcode=${rr.rcode})
|
|
230
|
+
return finish(false, `AXFR refused (rcode=${rr.rcode})`, 'refused');
|
|
228
231
|
}
|
|
229
232
|
records.push(...rr.answers);
|
|
230
233
|
if (records.length > MAX_AXFR_RECORDS) {
|
|
@@ -237,7 +240,7 @@ function sendAxfrQuery(host, domain, port=53, timeoutMs=TIMEOUT){
|
|
|
237
240
|
}
|
|
238
241
|
}
|
|
239
242
|
});
|
|
240
|
-
s.on('error', err => { clearTimeout(t); finish(false, String(err?.code || 'TCP error')); });
|
|
243
|
+
s.on('error', err => { clearTimeout(t); finish(false, String(err?.code || 'TCP error'), err?.code === 'ECONNREFUSED' ? 'refused' : 'no-answer'); });
|
|
241
244
|
s.on('close', () => {
|
|
242
245
|
clearTimeout(t);
|
|
243
246
|
if (records.length > 0) finish(true, `AXFR success: ${records.length} records`);
|
|
@@ -325,24 +328,28 @@ export default {
|
|
|
325
328
|
data.push(entry);
|
|
326
329
|
}
|
|
327
330
|
|
|
328
|
-
// 4) AXFR zone transfer (opt-in via env)
|
|
331
|
+
// 4) AXFR zone transfer (opt-in via env). 1.3.0 (s3): `axfrTested` records whether it ran and MEASURED — true, or the
|
|
332
|
+
// reason it did not ('opt-in-off', 'no-domain', 'no-answer'); `axfrAllowed` is null unless a measurement was made.
|
|
329
333
|
const checkAxfr = /^(1|true|yes|on)$/i.test(String(process.env.DNS_CHECK_AXFR || ''));
|
|
330
334
|
const axfrDomain = String(process.env.DNS_AXFR_DOMAIN || opts.axfrDomain || '');
|
|
331
335
|
let axfrAllowed = null;
|
|
336
|
+
let axfrTested = !checkAxfr ? 'opt-in-off' : !axfrDomain ? 'no-domain' : 'no-answer';
|
|
332
337
|
|
|
333
338
|
if (checkAxfr && axfrDomain) {
|
|
334
339
|
const axfrRes = await sendAxfrQuery(host, axfrDomain, targetPort, timeoutMs);
|
|
335
|
-
axfrAllowed = axfrRes.
|
|
340
|
+
if (axfrRes.outcome !== 'no-answer') { axfrAllowed = axfrRes.outcome === 'allowed'; axfrTested = true; }
|
|
336
341
|
data.push({
|
|
337
342
|
probe_protocol: 'tcp',
|
|
338
343
|
probe_port: targetPort,
|
|
339
344
|
probe_service: 'dns',
|
|
340
|
-
probe_info: axfrRes.
|
|
345
|
+
probe_info: axfrRes.outcome === 'allowed' ? `AXFR ${axfrDomain} allowed`
|
|
346
|
+
: axfrRes.outcome === 'refused' ? `AXFR ${axfrDomain} denied`
|
|
347
|
+
: `AXFR ${axfrDomain} not measured — ${axfrRes.info}`,
|
|
341
348
|
response_banner: axfrRes.info
|
|
342
349
|
});
|
|
343
350
|
}
|
|
344
351
|
|
|
345
|
-
return { up, type:'dns', program, version, axfrAllowed, data };
|
|
352
|
+
return { up, type:'dns', program, version, axfrAllowed, axfrTested, data };
|
|
346
353
|
}
|
|
347
354
|
};
|
|
348
355
|
|
|
@@ -362,6 +369,7 @@ export async function conclude({ host, result }){
|
|
|
362
369
|
version: result?.version || 'Unknown',
|
|
363
370
|
status, info, banner,
|
|
364
371
|
axfrAllowed: result?.axfrAllowed ?? null,
|
|
372
|
+
axfrTested: result?.axfrTested ?? null,
|
|
365
373
|
source: 'dns',
|
|
366
374
|
evidence: rows,
|
|
367
375
|
authoritative: true
|