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,82 @@
1
+ // utils/export_csv.mjs
2
+ // Export scan results as CSV.
3
+
4
+ /**
5
+ * Escape a CSV field value.
6
+ * - Wrap in double quotes if contains comma, newline, or double quote
7
+ * - Escape double quotes by doubling them
8
+ * @param {*} value
9
+ * @returns {string}
10
+ */
11
+ export function escapeCsvField(value) {
12
+ if (value == null) return '';
13
+ let str = String(value);
14
+ // Defend against CSV formula injection in spreadsheets
15
+ if (/^[=+\-@\t\r]/.test(str)) {
16
+ str = "'" + str;
17
+ }
18
+ if (/[",\r\n]/.test(str)) {
19
+ return `"${str.replace(/"/g, '""')}"`;
20
+ }
21
+ return str;
22
+ }
23
+
24
+ const COLUMNS = ['host', 'port', 'protocol', 'service', 'program', 'version', 'status', 'cpe', 'security_findings'];
25
+
26
+ /**
27
+ * Build a security_findings string from a service record.
28
+ * @param {object} svc
29
+ * @returns {string}
30
+ */
31
+ function buildFindings(svc) {
32
+ const parts = [];
33
+
34
+ if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) {
35
+ parts.push(`weak_algorithms:${svc.weakAlgorithms.length}`);
36
+ }
37
+ if (svc.anonymousLogin) {
38
+ parts.push('anonymous_login');
39
+ }
40
+ if (svc.axfrAllowed) {
41
+ parts.push('axfr_allowed');
42
+ }
43
+ if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) {
44
+ parts.push(`dangerous_methods:${svc.dangerousMethods.join(';')}`);
45
+ }
46
+ if (svc.community) {
47
+ parts.push(`default_community:${svc.community}`);
48
+ }
49
+
50
+ return parts.join(',');
51
+ }
52
+
53
+ /**
54
+ * Build CSV string from scan conclusion.
55
+ * Columns: host, port, protocol, service, program, version, status, cpe, security_findings
56
+ *
57
+ * @param {{ host: string, conclusion: object }} scanData
58
+ * @returns {string} CSV content with header row
59
+ */
60
+ export function buildCsv(scanData) {
61
+ const { host, conclusion } = scanData;
62
+ const services = conclusion?.result?.services ?? [];
63
+
64
+ const header = COLUMNS.join(',');
65
+ const rows = services.map((svc) => {
66
+ const findings = buildFindings(svc);
67
+ const fields = [
68
+ host,
69
+ svc.port ?? '',
70
+ svc.protocol ?? '',
71
+ svc.service ?? '',
72
+ svc.program ?? '',
73
+ svc.version ?? '',
74
+ svc.status ?? '',
75
+ svc.cpe ?? '',
76
+ findings,
77
+ ];
78
+ return fields.map(escapeCsvField).join(',');
79
+ });
80
+
81
+ return [header, ...rows].join('\r\n') + '\r\n';
82
+ }
@@ -0,0 +1,64 @@
1
+ // utils/finding_queue.mjs
2
+
3
+ import { validateFinding, generateFindingId } from './finding_schema.mjs';
4
+
5
+ const SEVERITY_SCORE = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
6
+
7
+ export class FindingQueue {
8
+ constructor() {
9
+ this.findings = [];
10
+ }
11
+
12
+ add(finding) {
13
+ const errors = validateFinding(finding);
14
+ if (errors.length > 0) throw new Error(`Invalid finding: ${errors.join(', ')}`);
15
+ const id = finding.id || generateFindingId();
16
+ this.findings.push({ ...finding, id });
17
+ return id;
18
+ }
19
+
20
+ getByCategory(cat) {
21
+ return this.findings.filter(f => f.category === cat);
22
+ }
23
+
24
+ getByStatus(status) {
25
+ return this.findings.filter(f => f.status === status);
26
+ }
27
+
28
+ getUnverified() {
29
+ return this.getByStatus('UNVERIFIED');
30
+ }
31
+
32
+ markVerified(id, verification) {
33
+ const f = this._find(id);
34
+ f.status = 'VERIFIED';
35
+ f.evidence = { ...(f.evidence ?? {}), verification };
36
+ }
37
+
38
+ markFalsePositive(id, reason) {
39
+ const f = this._find(id);
40
+ f.status = 'FALSE_POSITIVE';
41
+ f.falsePositiveReason = reason;
42
+ }
43
+
44
+ prioritize() {
45
+ this.findings.sort(
46
+ (a, b) => (SEVERITY_SCORE[b.severity] ?? 0) - (SEVERITY_SCORE[a.severity] ?? 0)
47
+ );
48
+ return this;
49
+ }
50
+
51
+ toJSON() {
52
+ return JSON.parse(JSON.stringify(this.findings));
53
+ }
54
+
55
+ get size() {
56
+ return this.findings.length;
57
+ }
58
+
59
+ _find(id) {
60
+ const f = this.findings.find(f => f.id === id);
61
+ if (!f) throw new Error(`Finding not found: ${id}`);
62
+ return f;
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ // utils/finding_schema.mjs
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ export const FINDING_CATEGORIES = ['AUTH', 'CRYPTO', 'CONFIG', 'SERVICE', 'EXPOSURE', 'CVE'];
5
+ export const FINDING_STATUSES = ['UNVERIFIED', 'VERIFIED', 'POTENTIAL', 'FALSE_POSITIVE'];
6
+ export const FINDING_SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
7
+ export const FINDING_EFFORTS = ['LOW', 'MEDIUM', 'HIGH'];
8
+
9
+ /**
10
+ * Validate a finding object against the schema.
11
+ * @param {object} f
12
+ * @returns {string[]} Array of error messages; empty = valid
13
+ */
14
+ export function validateFinding(f) {
15
+ const errors = [];
16
+ if (!FINDING_CATEGORIES.includes(f?.category))
17
+ errors.push(`invalid category: ${f?.category}`);
18
+ if (!FINDING_STATUSES.includes(f?.status))
19
+ errors.push(`invalid status: ${f?.status}`);
20
+ if (!FINDING_SEVERITIES.includes(f?.severity))
21
+ errors.push(`invalid severity: ${f?.severity}`);
22
+ if (!f?.title || typeof f.title !== 'string')
23
+ errors.push('title required');
24
+ if (!f?.target?.host)
25
+ errors.push('target.host required');
26
+ return errors;
27
+ }
28
+
29
+ /**
30
+ * Generate a globally unique finding ID.
31
+ * Format: F-<uuid-v4> (e.g. F-3d7e4b2a-91f0-4c3e-b8a6-7f2d5e9c1a04)
32
+ * UUID-based — no counter to reset, no collision risk across restarts.
33
+ */
34
+ export function generateFindingId() {
35
+ return `F-${uuidv4()}`;
36
+ }
@@ -0,0 +1,166 @@
1
+ // utils/host_iterator.mjs
2
+ // Expand host specifications: single IP, CIDR notation, or host file paths.
3
+
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Parse a dotted-quad IPv4 string into a 32-bit unsigned integer.
9
+ * Throws on invalid format.
10
+ */
11
+ function ipToUint32(ip) {
12
+ const parts = ip.split('.');
13
+ if (parts.length !== 4) throw new Error(`Invalid IPv4 address: ${ip}`);
14
+ let num = 0;
15
+ for (let i = 0; i < 4; i++) {
16
+ const octet = Number(parts[i]);
17
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
18
+ throw new Error(`Invalid IPv4 address: ${ip}`);
19
+ }
20
+ num = (num * 256) + octet;
21
+ }
22
+ return num >>> 0; // ensure unsigned
23
+ }
24
+
25
+ /**
26
+ * Convert a 32-bit unsigned integer back to dotted-quad string.
27
+ */
28
+ function uint32ToIp(num) {
29
+ return [
30
+ (num >>> 24) & 0xFF,
31
+ (num >>> 16) & 0xFF,
32
+ (num >>> 8) & 0xFF,
33
+ num & 0xFF
34
+ ].join('.');
35
+ }
36
+
37
+ /**
38
+ * Expand a CIDR notation string to an array of host IPs.
39
+ * Example: '192.168.1.0/30' → ['192.168.1.1', '192.168.1.2']
40
+ * Excludes network address and broadcast address for /31 and larger.
41
+ * For /32 returns the single IP. For /31 returns both IPs (point-to-point).
42
+ */
43
+ export function expandCidr(cidr) {
44
+ const parts = cidr.split('/');
45
+ if (parts.length !== 2) throw new Error(`Invalid CIDR notation: ${cidr}`);
46
+
47
+ const ip = parts[0];
48
+ const prefix = Number(parts[1]);
49
+
50
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
51
+ throw new Error(`Invalid prefix length: ${parts[1]} (must be 0-32)`);
52
+ }
53
+ if (prefix < 16) {
54
+ throw new Error(`Prefix /${prefix} too large (max 65534 hosts). Minimum prefix is /16.`);
55
+ }
56
+
57
+ const ipNum = ipToUint32(ip); // validates IP format
58
+
59
+ if (prefix === 32) {
60
+ return [ip];
61
+ }
62
+
63
+ if (prefix === 31) {
64
+ // RFC 3021 point-to-point: return both IPs
65
+ const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
66
+ const network = (ipNum & mask) >>> 0;
67
+ return [uint32ToIp(network), uint32ToIp((network + 1) >>> 0)];
68
+ }
69
+
70
+ // Standard subnets: exclude network and broadcast
71
+ const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
72
+ const network = (ipNum & mask) >>> 0;
73
+ const broadcast = (network | (~mask >>> 0)) >>> 0;
74
+ const hosts = [];
75
+ for (let addr = network + 1; addr < broadcast; addr++) {
76
+ hosts.push(uint32ToIp(addr >>> 0));
77
+ }
78
+ return hosts;
79
+ }
80
+
81
+ /**
82
+ * Read a host file (one host per line, # comments, blank lines ignored).
83
+ */
84
+ const HOST_LINE_RE = /^[\w.:\/\-]+$/;
85
+
86
+ export async function parseHostFile(filePath) {
87
+ const content = await fsp.readFile(filePath, 'utf8');
88
+ return content
89
+ .split(/\r?\n/)
90
+ .map((line) => line.trim())
91
+ .filter((line) => line && !line.startsWith('#'))
92
+ .filter((line) => {
93
+ if (!HOST_LINE_RE.test(line)) {
94
+ console.warn(`[host-file] Ignoring suspicious line: ${line.slice(0, 50)}`);
95
+ return false;
96
+ }
97
+ return true;
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Expand a dash-range notation to an array of IPs.
103
+ * Example: '192.168.1.1-50' → ['192.168.1.1', '192.168.1.2', ..., '192.168.1.50']
104
+ * Also supports full IP ranges: '192.168.1.1-192.168.1.50'
105
+ */
106
+ export function expandRange(range) {
107
+ // Full range: 192.168.1.1-192.168.1.50
108
+ const fullMatch = range.match(/^(\d{1,3}(?:\.\d{1,3}){3})-(\d{1,3}(?:\.\d{1,3}){3})$/);
109
+ if (fullMatch) {
110
+ const startNum = ipToUint32(fullMatch[1]);
111
+ const endNum = ipToUint32(fullMatch[2]);
112
+ if (endNum < startNum) throw new Error(`Invalid range: end < start in ${range}`);
113
+ const count = endNum - startNum + 1;
114
+ if (count > 65534) throw new Error(`Range too large: ${count} hosts (max 65534)`);
115
+ const hosts = [];
116
+ for (let i = startNum; i <= endNum; i++) hosts.push(uint32ToIp(i >>> 0));
117
+ return hosts;
118
+ }
119
+
120
+ // Short range: 192.168.1.1-50 (last octet range)
121
+ const shortMatch = range.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})-(\d{1,3})$/);
122
+ if (shortMatch) {
123
+ const prefix = shortMatch[1];
124
+ const start = Number(shortMatch[2]);
125
+ const end = Number(shortMatch[3]);
126
+ if (start > 255 || end > 255) throw new Error(`Invalid octet in range: ${range}`);
127
+ if (end < start) throw new Error(`Invalid range: end < start in ${range}`);
128
+ const hosts = [];
129
+ for (let i = start; i <= end; i++) hosts.push(`${prefix}.${i}`);
130
+ return hosts;
131
+ }
132
+
133
+ throw new Error(`Invalid range notation: ${range}`);
134
+ }
135
+
136
+ /**
137
+ * Detect input type and return array of hosts.
138
+ * - If contains '/' → CIDR
139
+ * - If contains '-' with IP pattern → dash range
140
+ * - If file exists → host file
141
+ * - Otherwise → single IP/hostname
142
+ */
143
+ export async function parseHostArg(arg) {
144
+ // Match CIDR notation: digits.digits.digits.digits/digits
145
+ if (/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/.test(arg)) {
146
+ return expandCidr(arg);
147
+ }
148
+
149
+ // Match dash-range notation: 192.168.1.1-50 or 192.168.1.1-192.168.1.50
150
+ if (/^\d{1,3}(\.\d{1,3}){3}-\d/.test(arg)) {
151
+ return expandRange(arg);
152
+ }
153
+
154
+ // Path traversal guard: reject absolute paths and paths resolving outside cwd
155
+ if (path.isAbsolute(arg)) return [arg]; // treat as hostname, not file
156
+ const resolved = path.resolve(arg);
157
+ if (!resolved.startsWith(process.cwd() + path.sep)) return [arg]; // outside CWD = hostname
158
+
159
+ try {
160
+ await fsp.access(arg);
161
+ return parseHostFile(arg);
162
+ } catch {
163
+ // Not a file — treat as single host
164
+ return [arg];
165
+ }
166
+ }
@@ -0,0 +1,29 @@
1
+ // utils/license.mjs
2
+ // CE stub implementation. Full ES256 JWT validation added in Phase 2 (roadmap).
3
+
4
+ /**
5
+ * Parse tier from NSAUDITOR_LICENSE_KEY environment variable.
6
+ * Stub uses key prefix: pro_* → 'pro', enterprise_* → 'enterprise'.
7
+ * Phase 2 roadmap replaces this with offline jose ES256 JWT verification.
8
+ */
9
+ export function getTierFromEnv() {
10
+ const key = process.env.NSAUDITOR_LICENSE_KEY;
11
+ if (!key) return 'ce';
12
+ if (key.startsWith('pro_')) return 'pro';
13
+ if (key.startsWith('enterprise_')) return 'enterprise';
14
+ return 'ce';
15
+ }
16
+
17
+ /**
18
+ * Validate a license key string.
19
+ * Phase 2 roadmap: replace with jose.jwtVerify() against embedded ECDSA P-256 public key.
20
+ * Gracefully degrades to CE on any failure — never throws.
21
+ *
22
+ * @param {string|undefined} keyStr
23
+ * @returns {Promise<{valid: boolean, tier: string, reason: string}>}
24
+ */
25
+ export async function loadLicense(keyStr) {
26
+ if (!keyStr) return { valid: false, tier: 'ce', reason: 'no key provided' };
27
+ // TODO (Phase 2): implement jose.jwtVerify with embedded public key
28
+ return { valid: false, tier: 'ce', reason: 'JWT validation not yet implemented' };
29
+ }
@@ -0,0 +1,66 @@
1
+ // utils/net_validation.mjs
2
+ // Shared IP/host validation utilities for SSRF prevention.
3
+
4
+ import dns from 'node:dns/promises';
5
+
6
+ /**
7
+ * Check whether an IP address belongs to a blocked (internal/private) range.
8
+ * Covers loopback, RFC 1918, RFC 6598, link-local, unspecified, and IPv6 equivalents.
9
+ * @param {string} ip
10
+ * @returns {boolean}
11
+ */
12
+ export function isBlockedIp(ip) {
13
+ const addr = ip.replace(/^\[|\]$/g, '').trim();
14
+
15
+ // IPv6-mapped IPv4 — extract the IPv4 part and check it
16
+ const mappedMatch = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
17
+ if (mappedMatch) return isBlockedIp(mappedMatch[1]);
18
+
19
+ // IPv6 blocked addresses
20
+ if (addr === '::1' || addr === '::') return true;
21
+ if (/^fe80:/i.test(addr)) return true; // link-local (fe80::/10)
22
+ if (/^f[cd]/i.test(addr.slice(0, 2))) return true; // fc00::/7 unique local (fc__ and fd__)
23
+ // IPv4-compatible loopback: ::127.0.0.1 maps to 127.0.0.1/8
24
+ const compatMatch = addr.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
25
+ if (compatMatch) return isBlockedIp(compatMatch[1]);
26
+
27
+ // IPv4 checks
28
+ const parts = addr.split('.');
29
+ if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p))) {
30
+ const [a, b] = parts.map(Number);
31
+ if (a === 127) return true; // 127.0.0.0/8 (loopback)
32
+ if (a === 10) return true; // 10.0.0.0/8 (RFC 1918)
33
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 (RFC 1918)
34
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 (RFC 1918)
35
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (RFC 6598 CGNAT)
36
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
37
+ if (a === 0) return true; // 0.0.0.0/8
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * True when `ip` is a private/local-network address.
45
+ * Plugins that operate only on local networks use this to filter targets.
46
+ * @param {string|null|undefined} ip
47
+ * @returns {boolean}
48
+ */
49
+ export function isPrivateLike(ip) {
50
+ if (!ip) return false;
51
+ return isBlockedIp(ip);
52
+ }
53
+
54
+ /**
55
+ * Resolve a hostname and verify the resolved IP is not in a blocked range.
56
+ * @param {string} hostname
57
+ * @returns {Promise<string>} resolved IP address
58
+ * @throws {Error} if hostname resolves to a blocked IP or DNS fails
59
+ */
60
+ export async function resolveAndValidate(hostname) {
61
+ const { address } = await dns.lookup(hostname);
62
+ if (isBlockedIp(address)) {
63
+ throw new Error(`Host resolves to blocked IP range`);
64
+ }
65
+ return address;
66
+ }
@@ -0,0 +1,77 @@
1
+ // utils/nvd_cache.mjs
2
+ // File-based cache for NVD API responses to respect rate limits.
3
+
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ const DEFAULT_TTL_DAYS = 7;
8
+ const MAX_ENTRIES = 10000;
9
+
10
+ export class NvdCache {
11
+ constructor(cacheDir = '.nvd_cache') {
12
+ this.cacheFile = path.resolve(cacheDir, 'nvd_cache.json');
13
+ this.ttlMs = (Number(process.env.NVD_CACHE_TTL_DAYS) || DEFAULT_TTL_DAYS) * 86400000;
14
+ this._data = null;
15
+ this._writeQueue = Promise.resolve();
16
+ }
17
+
18
+ async _load() {
19
+ if (this._data) return;
20
+ try {
21
+ const raw = await fsp.readFile(this.cacheFile, 'utf8');
22
+ this._data = JSON.parse(raw);
23
+ } catch {
24
+ this._data = {};
25
+ }
26
+ this._sweepExpired();
27
+ }
28
+
29
+ _sweepExpired() {
30
+ const now = Date.now();
31
+ for (const key of Object.keys(this._data)) {
32
+ if (now - this._data[key].timestamp > this.ttlMs) {
33
+ delete this._data[key];
34
+ }
35
+ }
36
+ }
37
+
38
+ _evictOldest() {
39
+ const entries = Object.entries(this._data);
40
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
41
+ const excess = entries.length - MAX_ENTRIES;
42
+ for (let i = 0; i < excess; i++) {
43
+ delete this._data[entries[i][0]];
44
+ }
45
+ }
46
+
47
+ async _save() {
48
+ await fsp.mkdir(path.dirname(this.cacheFile), { recursive: true });
49
+ await fsp.writeFile(this.cacheFile, JSON.stringify(this._data, null, 2), 'utf8');
50
+ }
51
+
52
+ async get(key) {
53
+ await this._load();
54
+ const entry = this._data[key];
55
+ if (!entry) return null;
56
+ if (Date.now() - entry.timestamp > this.ttlMs) {
57
+ delete this._data[key];
58
+ return null;
59
+ }
60
+ return entry.data;
61
+ }
62
+
63
+ async set(key, data) {
64
+ this._writeQueue = this._writeQueue.then(async () => {
65
+ await this._load();
66
+ this._data[key] = { data, timestamp: Date.now() };
67
+ if (Object.keys(this._data).length > MAX_ENTRIES) {
68
+ this._sweepExpired();
69
+ if (Object.keys(this._data).length > MAX_ENTRIES) {
70
+ this._evictOldest();
71
+ }
72
+ }
73
+ await this._save();
74
+ });
75
+ return this._writeQueue;
76
+ }
77
+ }
@@ -0,0 +1,130 @@
1
+ // utils/nvd_client.mjs
2
+ // NVD 2.0 API client with rate limiting and caching.
3
+
4
+ import { NvdCache } from './nvd_cache.mjs';
5
+
6
+ const NVD_BASE = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
7
+
8
+ // --- Rate limiter (sliding window) ---
9
+
10
+ export class RateLimiter {
11
+ constructor(maxRequests, windowMs) {
12
+ this.max = maxRequests;
13
+ this.windowMs = windowMs;
14
+ this.timestamps = [];
15
+ }
16
+
17
+ async wait() {
18
+ const now = Date.now();
19
+ this.timestamps = this.timestamps.filter(t => now - t < this.windowMs);
20
+ if (this.timestamps.length >= this.max) {
21
+ const waitMs = this.windowMs - (now - this.timestamps[0]);
22
+ if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
23
+ }
24
+ this.timestamps.push(Date.now());
25
+ }
26
+ }
27
+
28
+ // --- Helpers ---
29
+
30
+ function extractCvss(metrics) {
31
+ const m31 = metrics?.cvssMetricV31?.[0]?.cvssData;
32
+ if (m31) return m31;
33
+ const m30 = metrics?.cvssMetricV30?.[0]?.cvssData;
34
+ return m30 || null;
35
+ }
36
+
37
+ function parseCve(vuln) {
38
+ const { cve } = vuln;
39
+ const cvss = extractCvss(cve.metrics);
40
+ const enDesc = cve.descriptions?.find(d => d.lang === 'en');
41
+ return {
42
+ cveId: cve.id,
43
+ description: enDesc?.value ?? '',
44
+ cvssScore: cvss?.baseScore ?? null,
45
+ severity: cvss?.baseSeverity ?? null,
46
+ vectorString: cvss?.vectorString ?? null,
47
+ published: cve.published,
48
+ lastModified: cve.lastModified,
49
+ };
50
+ }
51
+
52
+ // --- Client ---
53
+
54
+ class NvdClient {
55
+ constructor({ apiKey, cacheDir } = {}) {
56
+ this.apiKey = apiKey || process.env.NVD_API_KEY || null;
57
+ this.cache = new NvdCache(cacheDir);
58
+ // With API key: 50 req / 30s. Without: 5 req / 30s.
59
+ const max = this.apiKey ? 50 : 5;
60
+ this.limiter = new RateLimiter(max, 30_000);
61
+ }
62
+
63
+ async _fetch(url) {
64
+ await this.limiter.wait();
65
+ const headers = {};
66
+ if (this.apiKey) headers.apiKey = this.apiKey;
67
+ const controller = new AbortController();
68
+ const timer = setTimeout(() => controller.abort(), 15_000);
69
+ try {
70
+ const res = await fetch(url, { headers, signal: controller.signal });
71
+ if (!res.ok) {
72
+ const text = await res.text().catch(() => '');
73
+ throw new Error(`NVD API ${res.status}: ${text}`);
74
+ }
75
+ return res.json();
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ async queryCvesByCpe(cpeString) {
82
+ const cacheKey = `cpe:${cpeString}`;
83
+ const cached = await this.cache.get(cacheKey);
84
+ if (cached) return cached;
85
+
86
+ const url = `${NVD_BASE}?cpeName=${encodeURIComponent(cpeString)}`;
87
+ const body = await this._fetch(url);
88
+ const results = (body.vulnerabilities || []).map(parseCve);
89
+
90
+ await this.cache.set(cacheKey, results);
91
+ return results;
92
+ }
93
+
94
+ async validateCveId(cveId) {
95
+ const cacheKey = `cve:${cveId}`;
96
+ const cached = await this.cache.get(cacheKey);
97
+ if (cached) return cached;
98
+
99
+ let result;
100
+ try {
101
+ const url = `${NVD_BASE}?cveId=${encodeURIComponent(cveId)}`;
102
+ const body = await this._fetch(url);
103
+ const vulns = body.vulnerabilities || [];
104
+ if (vulns.length === 0) {
105
+ result = { exists: false, cveId };
106
+ } else {
107
+ const parsed = parseCve(vulns[0]);
108
+ result = {
109
+ exists: true,
110
+ cveId: parsed.cveId,
111
+ cvssScore: parsed.cvssScore,
112
+ severity: parsed.severity,
113
+ description: parsed.description,
114
+ };
115
+ }
116
+ } catch {
117
+ // Transient failure — don't cache, return unknown
118
+ return { exists: false, cveId, transient: true };
119
+ }
120
+
121
+ await this.cache.set(cacheKey, result);
122
+ return result;
123
+ }
124
+ }
125
+
126
+ // --- Factory ---
127
+
128
+ export function createNvdClient(options) {
129
+ return new NvdClient(options);
130
+ }