nsauditor-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/bin/nsauditor-ai-mcp.mjs +2 -0
  5. package/bin/nsauditor-ai.mjs +2 -0
  6. package/cli.mjs +939 -0
  7. package/config/services.json +304 -0
  8. package/docs/EULA-nsauditor-ai.md +324 -0
  9. package/index.mjs +15 -0
  10. package/mcp_server.mjs +382 -0
  11. package/package.json +44 -0
  12. package/plugin_manager.mjs +829 -0
  13. package/plugins/arp_scanner.mjs +162 -0
  14. package/plugins/db_scanner.mjs +248 -0
  15. package/plugins/dns_scanner.mjs +369 -0
  16. package/plugins/dnssd-scanner.mjs +245 -0
  17. package/plugins/ftp_banner_check.mjs +247 -0
  18. package/plugins/host_up_check.mjs +337 -0
  19. package/plugins/http_probe.mjs +290 -0
  20. package/plugins/llmnr_scanner.mjs +130 -0
  21. package/plugins/mdns_scanner.mjs +522 -0
  22. package/plugins/netbios_scanner.mjs +737 -0
  23. package/plugins/opensearch_scanner.mjs +276 -0
  24. package/plugins/os_detector.mjs +436 -0
  25. package/plugins/ping_checker.mjs +271 -0
  26. package/plugins/port_scanner.mjs +250 -0
  27. package/plugins/result_concluder.mjs +274 -0
  28. package/plugins/snmp_scanner.mjs +278 -0
  29. package/plugins/ssh_scanner.mjs +421 -0
  30. package/plugins/sunrpc_scanner.mjs +339 -0
  31. package/plugins/syn_scanner.mjs +314 -0
  32. package/plugins/tls_scanner.mjs +225 -0
  33. package/plugins/upnp_scanner.mjs +441 -0
  34. package/plugins/webapp_detector.mjs +246 -0
  35. package/plugins/wsd_scanner.mjs +290 -0
  36. package/utils/attack_map.mjs +180 -0
  37. package/utils/capabilities.mjs +53 -0
  38. package/utils/conclusion_utils.mjs +70 -0
  39. package/utils/cpe.mjs +74 -0
  40. package/utils/cve_validator.mjs +64 -0
  41. package/utils/cvss.mjs +129 -0
  42. package/utils/delta_reporter.mjs +110 -0
  43. package/utils/export_csv.mjs +82 -0
  44. package/utils/finding_queue.mjs +64 -0
  45. package/utils/finding_schema.mjs +36 -0
  46. package/utils/host_iterator.mjs +166 -0
  47. package/utils/license.mjs +29 -0
  48. package/utils/net_validation.mjs +66 -0
  49. package/utils/nvd_cache.mjs +77 -0
  50. package/utils/nvd_client.mjs +130 -0
  51. package/utils/oui.mjs +107 -0
  52. package/utils/plugin_discovery.mjs +89 -0
  53. package/utils/prompts.mjs +143 -0
  54. package/utils/raw_report_html.mjs +170 -0
  55. package/utils/redact.mjs +79 -0
  56. package/utils/report_html.mjs +236 -0
  57. package/utils/sarif.mjs +225 -0
  58. package/utils/scan_history.mjs +248 -0
  59. package/utils/scheduler.mjs +157 -0
  60. package/utils/webhook.mjs +177 -0
@@ -0,0 +1,180 @@
1
+ // utils/attack_map.mjs
2
+ // MITRE ATT&CK mapping for network security audit findings.
3
+ // Maps service types, CVEs, and scan findings to ATT&CK technique IDs.
4
+
5
+ /**
6
+ * Technique definition: { techniqueId, name }
7
+ */
8
+ const T = (techniqueId, name) => ({ techniqueId, name });
9
+
10
+ /**
11
+ * Mapping from finding/service patterns to ATT&CK techniques.
12
+ */
13
+ export const SERVICE_TECHNIQUE_MAP = {
14
+ ssh_cve: [T('T1021.004', 'Remote Services: SSH')],
15
+ smb_cve: [T('T1021.002', 'Remote Services: SMB/Windows Admin Shares')],
16
+ ftp_anonymous: [T('T1078', 'Valid Accounts'), T('T1530', 'Data from Cloud Storage Object')],
17
+ dns_zone_transfer: [T('T1590.002', 'Gather Victim Network Information: DNS')],
18
+ snmp_default: [T('T1078', 'Valid Accounts'), T('T1040', 'Network Sniffing')],
19
+ http_dangerous: [T('T1190', 'Exploit Public-Facing Application')],
20
+ privesc_cve: [T('T1068', 'Exploitation for Privilege Escalation')],
21
+ default_credentials: [T('T1110', 'Brute Force')],
22
+ tls_weakness: [T('T1557', 'Adversary-in-the-Middle')],
23
+ rdp_exposure: [T('T1021.001', 'Remote Services: RDP')],
24
+ mdns_llmnr_exposure: [T('T1557.001', 'LLMNR/NBT-NS Poisoning')],
25
+ };
26
+
27
+ /**
28
+ * Convert a technique ID to a MITRE ATT&CK URL.
29
+ * Sub-techniques use dot notation (T1021.004) which maps to slash paths (/T1021/004/).
30
+ * @param {string} techniqueId - e.g. "T1021.004" or "T1190"
31
+ * @returns {string} Full URL
32
+ */
33
+ export function attackUrl(techniqueId) {
34
+ const path = String(techniqueId).replace(/\./g, '/');
35
+ return `https://attack.mitre.org/techniques/${path}/`;
36
+ }
37
+
38
+ /**
39
+ * Map a service record to matching ATT&CK techniques.
40
+ * Inspects service type and boolean/array fields to identify relevant techniques.
41
+ *
42
+ * @param {object} service - Service record from scan conclusion
43
+ * @returns {Array<{ techniqueId: string, name: string, url: string }>}
44
+ */
45
+ export function mapServiceToAttack(service) {
46
+ if (!service || typeof service !== 'object') return [];
47
+
48
+ const techniques = [];
49
+ const svcName = String(service.service || '').toLowerCase();
50
+
51
+ // FTP anonymous login
52
+ if (service.anonymousLogin === true) {
53
+ techniques.push(...SERVICE_TECHNIQUE_MAP.ftp_anonymous);
54
+ }
55
+
56
+ // DNS zone transfer
57
+ if (service.axfrAllowed === true) {
58
+ techniques.push(...SERVICE_TECHNIQUE_MAP.dns_zone_transfer);
59
+ }
60
+
61
+ // SNMP default community string
62
+ if (service.community === 'public' || service.community === 'private') {
63
+ techniques.push(...SERVICE_TECHNIQUE_MAP.snmp_default);
64
+ }
65
+
66
+ // HTTP dangerous methods
67
+ if (Array.isArray(service.dangerousMethods) && service.dangerousMethods.length > 0) {
68
+ techniques.push(...SERVICE_TECHNIQUE_MAP.http_dangerous);
69
+ }
70
+
71
+ // TLS/SSL weaknesses
72
+ if (
73
+ (Array.isArray(service.weakAlgorithms) && service.weakAlgorithms.length > 0) ||
74
+ (Array.isArray(service.weakCiphers) && service.weakCiphers.length > 0) ||
75
+ (Array.isArray(service.weakProtocols) && service.weakProtocols.length > 0)
76
+ ) {
77
+ techniques.push(...SERVICE_TECHNIQUE_MAP.tls_weakness);
78
+ }
79
+
80
+ // RDP exposure
81
+ if (svcName === 'rdp' || svcName === 'ms-wbt-server') {
82
+ techniques.push(...SERVICE_TECHNIQUE_MAP.rdp_exposure);
83
+ }
84
+
85
+ // mDNS / LLMNR exposure
86
+ if (svcName === 'mdns' || svcName === 'llmnr') {
87
+ techniques.push(...SERVICE_TECHNIQUE_MAP.mdns_llmnr_exposure);
88
+ }
89
+
90
+ // Default/weak credentials (generic flag)
91
+ if (service.defaultCredentials === true || service.weakCredentials === true) {
92
+ techniques.push(...SERVICE_TECHNIQUE_MAP.default_credentials);
93
+ }
94
+
95
+ // CVE-based mappings
96
+ const cves = service.cves || service.cve || [];
97
+ if (Array.isArray(cves)) {
98
+ for (const cve of cves) {
99
+ const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || '');
100
+ if (cveId) {
101
+ techniques.push(...mapCveToAttack(cveId, svcName));
102
+ }
103
+ }
104
+ }
105
+
106
+ return dedup(techniques).map(t => ({ ...t, url: attackUrl(t.techniqueId) }));
107
+ }
108
+
109
+ /**
110
+ * Map a CVE + service context to ATT&CK technique(s).
111
+ * Uses the service type to determine the most relevant technique category.
112
+ *
113
+ * @param {string} cveId - e.g. "CVE-2023-12345"
114
+ * @param {string} [serviceType] - e.g. "ssh", "smb", "http"
115
+ * @returns {Array<{ techniqueId: string, name: string }>}
116
+ */
117
+ export function mapCveToAttack(cveId, serviceType) {
118
+ if (!cveId) return [];
119
+
120
+ const svc = String(serviceType || '').toLowerCase();
121
+ const id = String(cveId).toUpperCase();
122
+
123
+ // Service-specific CVE mapping
124
+ if (svc === 'ssh' || svc === 'openssh') {
125
+ return [...SERVICE_TECHNIQUE_MAP.ssh_cve];
126
+ }
127
+ if (svc === 'smb' || svc === 'microsoft-ds' || svc === 'netbios-ssn') {
128
+ return [...SERVICE_TECHNIQUE_MAP.smb_cve];
129
+ }
130
+ if (svc === 'rdp' || svc === 'ms-wbt-server') {
131
+ return [...SERVICE_TECHNIQUE_MAP.rdp_exposure];
132
+ }
133
+ if (svc === 'http' || svc === 'https' || svc === 'http-proxy') {
134
+ return [...SERVICE_TECHNIQUE_MAP.http_dangerous];
135
+ }
136
+ if (svc === 'ftp') {
137
+ return [...SERVICE_TECHNIQUE_MAP.http_dangerous]; // T1190: exploiting a public-facing service
138
+ }
139
+ if (svc === 'dns' || svc === 'domain') {
140
+ return [...SERVICE_TECHNIQUE_MAP.dns_zone_transfer];
141
+ }
142
+
143
+ // No service context — cannot reliably map to a specific ATT&CK technique
144
+
145
+ return [];
146
+ }
147
+
148
+ /**
149
+ * Collect all ATT&CK techniques across all services in a scan conclusion.
150
+ * Returns deduplicated list.
151
+ *
152
+ * @param {object} conclusion - Full scan conclusion: { result: { services: [...] } }
153
+ * @returns {Array<{ techniqueId: string, name: string, url: string }>}
154
+ */
155
+ export function getAllTechniques(conclusion) {
156
+ const services = conclusion?.result?.services ?? [];
157
+ if (!Array.isArray(services) || services.length === 0) return [];
158
+
159
+ const all = [];
160
+ for (const svc of services) {
161
+ all.push(...mapServiceToAttack(svc));
162
+ }
163
+
164
+ return dedup(all).map(t => t.url ? t : { ...t, url: attackUrl(t.techniqueId) });
165
+ }
166
+
167
+ /**
168
+ * Deduplicate techniques by techniqueId.
169
+ * @param {Array<{ techniqueId: string, name: string }>} techniques
170
+ * @returns {Array<{ techniqueId: string, name: string }>}
171
+ */
172
+ function dedup(techniques) {
173
+ const seen = new Map();
174
+ for (const t of techniques) {
175
+ if (!seen.has(t.techniqueId)) {
176
+ seen.set(t.techniqueId, { techniqueId: t.techniqueId, name: t.name });
177
+ }
178
+ }
179
+ return [...seen.values()];
180
+ }
@@ -0,0 +1,53 @@
1
+ // utils/capabilities.mjs
2
+
3
+ export const CAPABILITIES = {
4
+ // CE (always available)
5
+ coreScanning: { tier: 'ce' },
6
+ aiAnalysis: { tier: 'ce' }, // Any provider (OpenAI/Claude/Ollama), basic prompts
7
+ basicCTEM: { tier: 'ce' },
8
+ basicRedaction: { tier: 'ce' },
9
+ basicMCP: { tier: 'ce' },
10
+ findingQueue: { tier: 'ce' },
11
+
12
+ // Pro
13
+ intelligenceEngine: { tier: 'pro' },
14
+ riskScoring: { tier: 'pro' },
15
+ proAI: { tier: 'pro' },
16
+ analysisAgents: { tier: 'pro' },
17
+ verificationEngine: { tier: 'pro' },
18
+ advancedCTEM: { tier: 'pro' },
19
+ enhancedRedaction: { tier: 'pro' },
20
+ proMCP: { tier: 'pro' },
21
+ pdfExport: { tier: 'pro' },
22
+ brandedReports: { tier: 'pro' },
23
+
24
+ // Enterprise
25
+ cloudScanners: { tier: 'enterprise' },
26
+ zeroTrust: { tier: 'enterprise' },
27
+ complianceEngine: { tier: 'enterprise' },
28
+ zdePolicyEngine: { tier: 'enterprise' },
29
+ enterpriseCTEM: { tier: 'enterprise' },
30
+ enterpriseMCP: { tier: 'enterprise' },
31
+ usageMetering: { tier: 'enterprise' },
32
+ airGapped: { tier: 'enterprise' },
33
+ dockerIsolation: { tier: 'enterprise' },
34
+ };
35
+
36
+ const TIER_CAPS = {
37
+ ce: new Set(['ce']),
38
+ pro: new Set(['ce', 'pro']),
39
+ enterprise: new Set(['ce', 'pro', 'enterprise']),
40
+ };
41
+
42
+ export function resolveCapabilities(tier = 'ce') {
43
+ const allowed = TIER_CAPS[tier] ?? TIER_CAPS.ce;
44
+ const caps = {};
45
+ for (const [key, def] of Object.entries(CAPABILITIES)) {
46
+ caps[key] = allowed.has(def.tier);
47
+ }
48
+ return caps;
49
+ }
50
+
51
+ export function hasCapability(capabilities, cap) {
52
+ return Boolean(capabilities?.[cap]);
53
+ }
@@ -0,0 +1,70 @@
1
+ // utils/conclusion_utils.mjs
2
+ // Shared helpers for normalizing plugin findings into service records.
3
+
4
+ import { generateCpe } from './cpe.mjs';
5
+
6
+ export const keyOf = (svc) => `${(svc.protocol || 'tcp').toLowerCase()}:${Number(svc.port)}`;
7
+
8
+ export const firstDataRow = (res) =>
9
+ Array.isArray(res?.data) ? res.data.find(Boolean) : null;
10
+
11
+ export function statusFrom({ info, banner, fallbackUp }) {
12
+ const s = `${info || ''} ${banner || ''}`.toLowerCase();
13
+ if (/connection refused|closed/.test(s)) return 'closed';
14
+ if (/filtered|no route|host unreachable/.test(s)) return 'filtered';
15
+ if (/\bopen\b|\bready\b|^220(?:-| )/m.test(s)) return 'open';
16
+ if (typeof fallbackUp === 'boolean') return fallbackUp ? 'open' : 'unknown';
17
+ return 'unknown';
18
+ }
19
+
20
+ export function normalizeService(svc) {
21
+ return {
22
+ port: Number(svc.port),
23
+ protocol: (svc.protocol || 'tcp').toLowerCase(),
24
+ service: String(svc.service || 'unknown').toLowerCase(),
25
+ program: svc.program ?? null,
26
+ version: svc.version ?? null,
27
+ cpe: svc.program ? generateCpe(svc.program, svc.version) : null,
28
+ status: svc.status || 'unknown',
29
+ info: svc.info ?? null,
30
+ banner: svc.banner ?? null,
31
+ source: svc.source || 'unknown',
32
+ evidence: Array.isArray(svc.evidence) ? svc.evidence : []
33
+ };
34
+ }
35
+
36
+ // Merge by protocol:port with basic authority precedence.
37
+ // If 'authoritative' flag is true on a record, it wins over non-authoritative.
38
+ export function upsertService(services, next, { authoritative = false } = {}) {
39
+ const key = keyOf(next);
40
+ const i = services.findIndex(s => keyOf(s) === key);
41
+ if (i === -1) {
42
+ services.push({ ...next, __authoritative: !!authoritative });
43
+ return;
44
+ }
45
+ const cur = services[i];
46
+ // Authority precedence
47
+ if (authoritative && !cur.__authoritative) {
48
+ services[i] = { ...cur, ...next, __authoritative: true };
49
+ return;
50
+ }
51
+ if (!authoritative && cur.__authoritative) {
52
+ // keep current authoritative, but allow filling blanks
53
+ services[i] = {
54
+ ...cur,
55
+ program: cur.program || next.program || null,
56
+ version: cur.version || next.version || null,
57
+ info: cur.info || next.info || null,
58
+ banner: cur.banner || next.banner || null,
59
+ evidence: (cur.evidence || []).concat(next.evidence || [])
60
+ };
61
+ return;
62
+ }
63
+ // Same authority level: last-write-wins while preserving non-null fields
64
+ services[i] = {
65
+ ...cur,
66
+ ...next,
67
+ evidence: (cur.evidence || []).concat(next.evidence || []),
68
+ __authoritative: cur.__authoritative || authoritative
69
+ };
70
+ }
package/utils/cpe.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // utils/cpe.mjs
2
+ // CPE 2.3 string generator for known service programs.
3
+
4
+ export const CPE_MAP = {
5
+ 'nginx': { vendor: 'nginx', product: 'nginx' },
6
+ 'openssh': { vendor: 'openbsd', product: 'openssh' },
7
+ 'apache': { vendor: 'apache', product: 'http_server' },
8
+ 'proftpd': { vendor: 'proftpd', product: 'proftpd' },
9
+ 'pure-ftpd': { vendor: 'pureftpd', product: 'pure-ftpd' },
10
+ 'bftpd': { vendor: 'bftpd', product: 'bftpd' },
11
+ 'isc bind': { vendor: 'isc', product: 'bind' },
12
+ 'bind': { vendor: 'isc', product: 'bind' },
13
+ 'microsoft-iis':{ vendor: 'microsoft', product: 'internet_information_services' },
14
+ 'iis': { vendor: 'microsoft', product: 'internet_information_services' },
15
+ 'lighttpd': { vendor: 'lighttpd', product: 'lighttpd' },
16
+ 'tomcat': { vendor: 'apache', product: 'tomcat' },
17
+ 'mysql': { vendor: 'oracle', product: 'mysql' },
18
+ 'postgresql': { vendor: 'postgresql', product: 'postgresql' },
19
+ 'mongodb': { vendor: 'mongodb', product: 'mongodb' },
20
+ 'redis': { vendor: 'redis', product: 'redis' },
21
+ 'opensearch': { vendor: 'amazon', product: 'opensearch' },
22
+ 'elasticsearch':{ vendor: 'elastic', product: 'elasticsearch' },
23
+ 'vsftpd': { vendor: 'beasts', product: 'vsftpd' },
24
+ 'dropbear': { vendor: 'dropbear_ssh_project', product: 'dropbear_ssh' },
25
+ 'exim': { vendor: 'exim', product: 'exim' },
26
+ 'postfix': { vendor: 'postfix', product: 'postfix' },
27
+ 'dovecot': { vendor: 'dovecot', product: 'dovecot' },
28
+ 'openssl': { vendor: 'openssl', product: 'openssl' },
29
+ };
30
+
31
+ /**
32
+ * Escape special characters in a CPE 2.3 component value.
33
+ * Colons, backslashes, asterisks, and question marks must be escaped.
34
+ */
35
+ export function escapeCpeComponent(s) {
36
+ return String(s).replace(/([:\\*?])/g, '\\$1');
37
+ }
38
+
39
+ /**
40
+ * Split a version string like "8.2p1" into { version, update }.
41
+ * Handles dash/underscore separators (e.g. "9.0-rc1", "2.4.57-2").
42
+ * If no update suffix is found, update defaults to '*'.
43
+ */
44
+ export function parseVersion(versionString) {
45
+ if (!versionString) return { version: '*', update: '*' };
46
+
47
+ // Match version (digits and dots) followed by optional separator and update suffix.
48
+ // Supports: "8.2p1", "9.0-rc1", "2.4.57-2", "1.25.0_beta"
49
+ const m = versionString.match(/^(\d[\d.]*)(?:[-_]([a-zA-Z0-9]\w*)|([a-zA-Z]\w*))?$/);
50
+ if (!m) return { version: versionString, update: '*' };
51
+
52
+ return {
53
+ version: m[1],
54
+ update: m[2] || m[3] || '*',
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Generate a CPE 2.3 formatted string for a known program/version.
60
+ * Returns null when the program is not in CPE_MAP.
61
+ */
62
+ export function generateCpe(program, version) {
63
+ if (!program) return null;
64
+
65
+ const key = String(program).toLowerCase();
66
+ const entry = CPE_MAP[key];
67
+ if (!entry) return null;
68
+
69
+ const { version: ver, update } = parseVersion(version || null);
70
+
71
+ const safeVer = ver === '*' ? '*' : escapeCpeComponent(ver);
72
+ const safeUpd = update === '*' ? '*' : escapeCpeComponent(update);
73
+ return `cpe:2.3:a:${entry.vendor}:${entry.product}:${safeVer}:${safeUpd}:*:*:*:*:*:*`;
74
+ }
@@ -0,0 +1,64 @@
1
+ // utils/cve_validator.mjs
2
+ // Post-processes LLM output to validate CVE IDs against NVD.
3
+
4
+ import { createNvdClient } from './nvd_client.mjs';
5
+
6
+ const CVE_RE = /\bCVE-\d{4}-\d{4,}\b/g;
7
+
8
+ /**
9
+ * Extract deduplicated CVE IDs from text.
10
+ * @param {string} text — LLM response text (markdown, plain, etc.)
11
+ * @returns {string[]}
12
+ */
13
+ export function extractCveIds(text) {
14
+ if (!text) return [];
15
+ const matches = String(text).match(CVE_RE);
16
+ if (!matches) return [];
17
+ return [...new Set(matches)];
18
+ }
19
+
20
+ /**
21
+ * Validate an array of CVE IDs against NVD.
22
+ * @param {string[]} cveIds
23
+ * @param {{ apiKey?: string, cacheDir?: string }} [options]
24
+ * @returns {Promise<Map<string, { exists: boolean|null, cvssScore?: number, severity?: string }>>}
25
+ */
26
+ export async function validateCves(cveIds, options) {
27
+ const client = options?.client ?? createNvdClient(options);
28
+ const results = new Map();
29
+
30
+ for (const id of cveIds) {
31
+ try {
32
+ const res = await client.validateCveId(id);
33
+ results.set(id, {
34
+ exists: res.exists,
35
+ ...(res.cvssScore != null && { cvssScore: res.cvssScore }),
36
+ ...(res.severity != null && { severity: res.severity }),
37
+ });
38
+ } catch {
39
+ // NVD unreachable — mark as unknown
40
+ results.set(id, { exists: null });
41
+ }
42
+ }
43
+
44
+ return results;
45
+ }
46
+
47
+ /**
48
+ * Annotate CVE IDs in text with verification markers.
49
+ * - Verified: {{CVE_VERIFIED:CVE-XXXX-XXXXX}}
50
+ * - Unverified: {{CVE_UNVERIFIED:CVE-XXXX-XXXXX}}
51
+ * - Unknown (exists: null): left as-is
52
+ * @param {string} text
53
+ * @param {Map<string, { exists: boolean|null }>} validationMap
54
+ * @returns {string}
55
+ */
56
+ export function annotateCveText(text, validationMap) {
57
+ if (!text) return text;
58
+ return String(text).replace(CVE_RE, (match) => {
59
+ const info = validationMap.get(match);
60
+ if (!info || info.exists === null) return match;
61
+ if (info.exists) return `{{CVE_VERIFIED:${match}}}`;
62
+ return `{{CVE_UNVERIFIED:${match}}}`;
63
+ });
64
+ }
package/utils/cvss.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // utils/cvss.mjs
2
+ // CVSS v3.1 base score calculator following the FIRST.org specification.
3
+ // Pure implementation — no external dependencies.
4
+
5
+ const METRIC_WEIGHTS = {
6
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.20 },
7
+ AC: { L: 0.77, H: 0.44 },
8
+ PR: {
9
+ U: { N: 0.85, L: 0.62, H: 0.27 },
10
+ C: { N: 0.85, L: 0.68, H: 0.50 },
11
+ },
12
+ UI: { N: 0.85, R: 0.62 },
13
+ C: { H: 0.56, L: 0.22, N: 0 },
14
+ I: { H: 0.56, L: 0.22, N: 0 },
15
+ A: { H: 0.56, L: 0.22, N: 0 },
16
+ };
17
+
18
+ const VALID_VALUES = {
19
+ AV: ['N', 'A', 'L', 'P'],
20
+ AC: ['L', 'H'],
21
+ PR: ['N', 'L', 'H'],
22
+ UI: ['N', 'R'],
23
+ S: ['U', 'C'],
24
+ C: ['H', 'L', 'N'],
25
+ I: ['H', 'L', 'N'],
26
+ A: ['H', 'L', 'N'],
27
+ };
28
+
29
+ const REQUIRED_METRICS = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A'];
30
+
31
+ function roundUp(val) {
32
+ return Math.ceil(val * 10) / 10;
33
+ }
34
+
35
+ /**
36
+ * Parse a CVSS v3.1 vector string into a metrics object.
37
+ * @param {string} vectorString — e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"
38
+ * @returns {{ AV: string, AC: string, PR: string, UI: string, S: string, C: string, I: string, A: string }}
39
+ */
40
+ export function parseCvssVector(vectorString) {
41
+ if (typeof vectorString !== 'string') {
42
+ throw new Error('CVSS vector must be a string');
43
+ }
44
+
45
+ const trimmed = vectorString.trim();
46
+ if (!trimmed.startsWith('CVSS:3.1/')) {
47
+ throw new Error(`Invalid CVSS v3.1 vector prefix: "${trimmed}"`);
48
+ }
49
+
50
+ const parts = trimmed.slice('CVSS:3.1/'.length).split('/');
51
+ const metrics = {};
52
+
53
+ for (const part of parts) {
54
+ const [key, value] = part.split(':');
55
+ if (!key || value === undefined) {
56
+ throw new Error(`Malformed metric component: "${part}"`);
57
+ }
58
+ if (!VALID_VALUES[key]) {
59
+ throw new Error(`Unknown metric key: "${key}"`);
60
+ }
61
+ if (!VALID_VALUES[key].includes(value)) {
62
+ throw new Error(`Invalid value "${value}" for metric "${key}". Valid: ${VALID_VALUES[key].join(', ')}`);
63
+ }
64
+ metrics[key] = value;
65
+ }
66
+
67
+ for (const req of REQUIRED_METRICS) {
68
+ if (!metrics[req]) {
69
+ throw new Error(`Missing required metric: "${req}"`);
70
+ }
71
+ }
72
+
73
+ return metrics;
74
+ }
75
+
76
+ /**
77
+ * Calculate the CVSS v3.1 base score from a metrics object.
78
+ * @param {{ AV: string, AC: string, PR: string, UI: string, S: string, C: string, I: string, A: string }} metrics
79
+ * @returns {number} Base score 0.0–10.0
80
+ */
81
+ export function calculateBaseScore(metrics) {
82
+ const scopeChanged = metrics.S === 'C';
83
+
84
+ const cWeight = METRIC_WEIGHTS.C[metrics.C];
85
+ const iWeight = METRIC_WEIGHTS.I[metrics.I];
86
+ const aWeight = METRIC_WEIGHTS.A[metrics.A];
87
+
88
+ const iss = 1 - ((1 - cWeight) * (1 - iWeight) * (1 - aWeight));
89
+
90
+ let impact;
91
+ if (scopeChanged) {
92
+ impact = 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
93
+ } else {
94
+ impact = 6.42 * iss;
95
+ }
96
+
97
+ if (impact <= 0) return 0.0;
98
+
99
+ const avWeight = METRIC_WEIGHTS.AV[metrics.AV];
100
+ const acWeight = METRIC_WEIGHTS.AC[metrics.AC];
101
+ const prWeight = scopeChanged
102
+ ? METRIC_WEIGHTS.PR.C[metrics.PR]
103
+ : METRIC_WEIGHTS.PR.U[metrics.PR];
104
+ const uiWeight = METRIC_WEIGHTS.UI[metrics.UI];
105
+
106
+ const exploitability = 8.22 * avWeight * acWeight * prWeight * uiWeight;
107
+
108
+ let baseScore;
109
+ if (scopeChanged) {
110
+ baseScore = roundUp(Math.min(1.08 * (impact + exploitability), 10));
111
+ } else {
112
+ baseScore = roundUp(Math.min(impact + exploitability, 10));
113
+ }
114
+
115
+ return baseScore;
116
+ }
117
+
118
+ /**
119
+ * Map a numeric CVSS score to its severity label per CVSS v3.1 spec.
120
+ * @param {number} score — 0.0–10.0
121
+ * @returns {"None"|"Low"|"Medium"|"High"|"Critical"}
122
+ */
123
+ export function severityFromScore(score) {
124
+ if (score === 0.0) return 'None';
125
+ if (score <= 3.9) return 'Low';
126
+ if (score <= 6.9) return 'Medium';
127
+ if (score <= 8.9) return 'High';
128
+ return 'Critical';
129
+ }
@@ -0,0 +1,110 @@
1
+ // utils/delta_reporter.mjs
2
+ // Delta reporting: compare two full scan cycles across multiple hosts.
3
+
4
+ import { computeDiff } from './scan_history.mjs';
5
+
6
+ /**
7
+ * Compare two full scan cycle results and produce a delta report.
8
+ * @param {Map<string, object>|object} currentResults - host → scan result (Map or plain object)
9
+ * @param {Map<string, object>|object|null} previousResults - host → scan result from prior cycle
10
+ * @returns {{ newHosts: string[], removedHosts: string[], hostDiffs: Map<string, object> }}
11
+ */
12
+ export function buildDeltaReport(currentResults, previousResults) {
13
+ const currMap = currentResults instanceof Map
14
+ ? currentResults
15
+ : new Map(Object.entries(currentResults || {}));
16
+ const prevMap = previousResults instanceof Map
17
+ ? previousResults
18
+ : new Map(Object.entries(previousResults || {}));
19
+
20
+ const currentHosts = new Set(currMap.keys());
21
+ const previousHosts = new Set(prevMap.keys());
22
+
23
+ const newHosts = [];
24
+ for (const h of currentHosts) {
25
+ if (!previousHosts.has(h)) newHosts.push(h);
26
+ }
27
+
28
+ const removedHosts = [];
29
+ for (const h of previousHosts) {
30
+ if (!currentHosts.has(h)) removedHosts.push(h);
31
+ }
32
+
33
+ const hostDiffs = new Map();
34
+ for (const h of currentHosts) {
35
+ const curr = currMap.get(h);
36
+ const prev = prevMap.has(h) ? prevMap.get(h) : null;
37
+ hostDiffs.set(h, computeDiff(curr, prev));
38
+ }
39
+
40
+ return { newHosts, removedHosts, hostDiffs };
41
+ }
42
+
43
+ /**
44
+ * Format a delta report into a human-readable summary string.
45
+ * @param {{ newHosts: string[], removedHosts: string[], hostDiffs: Map<string, object> }} deltaReport
46
+ * @returns {string}
47
+ */
48
+ export function formatDeltaSummary(deltaReport) {
49
+ if (!deltaReport) return '';
50
+
51
+ const lines = [];
52
+ lines.push('=== Delta Report ===');
53
+ lines.push('');
54
+
55
+ if (deltaReport.newHosts.length) {
56
+ lines.push(`New hosts (${deltaReport.newHosts.length}): ${deltaReport.newHosts.join(', ')}`);
57
+ }
58
+ if (deltaReport.removedHosts.length) {
59
+ lines.push(`Removed hosts (${deltaReport.removedHosts.length}): ${deltaReport.removedHosts.join(', ')}`);
60
+ }
61
+
62
+ if (deltaReport.hostDiffs && deltaReport.hostDiffs.size > 0) {
63
+ lines.push('');
64
+ lines.push('Per-host changes:');
65
+ for (const [host, diff] of deltaReport.hostDiffs) {
66
+ lines.push(` ${host}: ${diff.summary}`);
67
+ }
68
+ }
69
+
70
+ if (!deltaReport.newHosts.length && !deltaReport.removedHosts.length) {
71
+ let anyChange = false;
72
+ if (deltaReport.hostDiffs) {
73
+ for (const diff of deltaReport.hostDiffs.values()) {
74
+ if (diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length || diff.newFindings) {
75
+ anyChange = true;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ if (!anyChange) {
81
+ lines.push('No significant changes detected.');
82
+ }
83
+ }
84
+
85
+ return lines.join('\n');
86
+ }
87
+
88
+ /**
89
+ * Determine whether a delta report contains significant changes.
90
+ * Returns true if any new/removed hosts exist, or if any host has service changes.
91
+ * @param {{ newHosts: string[], removedHosts: string[], hostDiffs: Map<string, object> }} deltaReport
92
+ * @returns {boolean}
93
+ */
94
+ export function hasSignificantChanges(deltaReport) {
95
+ if (!deltaReport) return false;
96
+
97
+ if (deltaReport.newHosts.length > 0) return true;
98
+ if (deltaReport.removedHosts.length > 0) return true;
99
+
100
+ if (deltaReport.hostDiffs) {
101
+ for (const diff of deltaReport.hostDiffs.values()) {
102
+ if (diff.newServices?.length > 0) return true;
103
+ if (diff.removedServices?.length > 0) return true;
104
+ if (diff.changedServices?.length > 0) return true;
105
+ if (diff.newFindings && diff.newFindings !== 0) return true;
106
+ }
107
+ }
108
+
109
+ return false;
110
+ }