nsauditor-ai 0.2.55 → 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.
Files changed (42) hide show
  1. package/README.md +76 -62
  2. package/cli.mjs +210 -179
  3. package/mcp_server.mjs +71 -31
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -1
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -30
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/ee_load.mjs +45 -0
  26. package/utils/executive_report.mjs +48 -8
  27. package/utils/export_csv.mjs +14 -22
  28. package/utils/mcp_call_digest.mjs +31 -0
  29. package/utils/net_validation.mjs +107 -37
  30. package/utils/output_dir.mjs +70 -0
  31. package/utils/plugin_discovery.mjs +4 -2
  32. package/utils/ports_spec.mjs +46 -0
  33. package/utils/report_inputs.mjs +64 -1
  34. package/utils/report_md.mjs +51 -112
  35. package/utils/sarif.mjs +33 -93
  36. package/utils/scan_delta.mjs +514 -38
  37. package/utils/scan_delta_view.mjs +5 -3
  38. package/utils/scan_history.mjs +149 -0
  39. package/utils/scheduler.mjs +17 -5
  40. package/utils/service_flags.mjs +363 -0
  41. package/utils/watch_cycle.mjs +37 -0
  42. 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
- const ipSans = extractSANs(cert, "IP");
96
- return ipSans.includes(hostname);
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
- .filter((s) => s.startsWith(`${type}:`))
124
- .map((s) => s.slice(type.length + 1));
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.signatureAlgorithm || "unknown",
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
- if (depth > 0 && WEAK_SIG_ALGORITHMS.test(entry.signatureAlgorithm)) {
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
- function analyzeKeyStrength(cert, config) {
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.pubkey?.type || "unknown";
226
- const keyBits = cert.bits || cert.pubkey?.size || null;
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" || 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" || 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
- if (isSelfSigned) {
394
- issues.push({
395
- severity: SEVERITY.HIGH,
396
- check: "self_signed",
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
- issues.push({
406
- severity: SEVERITY.HIGH,
407
- check: "hostname_mismatch",
408
- detail: `Hostname "${host}" does not match certificate names: ${certNames}`,
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
- const sigAlg = cert.signatureAlgorithm || "unknown";
425
- if (WEAK_SIG_ALGORITHMS.test(sigAlg)) {
426
- issues.push({
427
- severity: SEVERITY.HIGH,
428
- check: "weak_signature",
429
- detail: `Weak signature algorithm: ${sigAlg}`,
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
- const hasForwardSecrecy = /ECDHE|DHE/i.test(cipher.name);
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
- if (!probe.authorized && !isSelfSigned) {
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: `Certificate not trusted by system CA store: ${probe.authError || "unknown reason"}`,
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
- // Filter actionable issues (skip PASS and INFO for conclude)
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
- issues: actionableIssues,
723
- details: {
724
- subject: pr.certificate.subject,
725
- issuer: pr.certificate.issuer,
726
- names: pr.certificate.names,
727
- validFrom: pr.certificate.validFrom,
728
- validTo: pr.certificate.validTo,
729
- signatureAlgorithm: pr.certificate.signatureAlgorithm,
730
- keyType: pr.certificate.keyType,
731
- keyBits: pr.certificate.keyBits,
732
- chainDepth: pr.chain.depth,
733
- authorized: pr.authorized,
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
- const items = [];
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
- // Service identity item
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 items = [];
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
- // Summary item
953
- items.push({
966
+ return [{
954
967
  port: 53,
955
968
  protocol: "udp",
956
969
  service: "dns-security",
957
- program: "DNS-Audit",
958
- version: "v2",
959
- status: result.summary.actionable > 0 ? "action_required" : "hardened",
960
- severity: result.overallSeverity,
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(),
@@ -202,11 +202,14 @@ function sendAxfrQuery(host, domain, port=53, timeoutMs=TIMEOUT){
202
202
  let done = false;
203
203
  const records = [];
204
204
 
205
- const finish = (ok, info) => {
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.ok;
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.ok ? `AXFR ${axfrDomain} allowed` : `AXFR ${axfrDomain} denied`,
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