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.
- package/CONTRIBUTING.md +24 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/nsauditor-ai-mcp.mjs +2 -0
- package/bin/nsauditor-ai.mjs +2 -0
- package/cli.mjs +939 -0
- package/config/services.json +304 -0
- package/docs/EULA-nsauditor-ai.md +324 -0
- package/index.mjs +15 -0
- package/mcp_server.mjs +382 -0
- package/package.json +44 -0
- package/plugin_manager.mjs +829 -0
- package/plugins/arp_scanner.mjs +162 -0
- package/plugins/db_scanner.mjs +248 -0
- package/plugins/dns_scanner.mjs +369 -0
- package/plugins/dnssd-scanner.mjs +245 -0
- package/plugins/ftp_banner_check.mjs +247 -0
- package/plugins/host_up_check.mjs +337 -0
- package/plugins/http_probe.mjs +290 -0
- package/plugins/llmnr_scanner.mjs +130 -0
- package/plugins/mdns_scanner.mjs +522 -0
- package/plugins/netbios_scanner.mjs +737 -0
- package/plugins/opensearch_scanner.mjs +276 -0
- package/plugins/os_detector.mjs +436 -0
- package/plugins/ping_checker.mjs +271 -0
- package/plugins/port_scanner.mjs +250 -0
- package/plugins/result_concluder.mjs +274 -0
- package/plugins/snmp_scanner.mjs +278 -0
- package/plugins/ssh_scanner.mjs +421 -0
- package/plugins/sunrpc_scanner.mjs +339 -0
- package/plugins/syn_scanner.mjs +314 -0
- package/plugins/tls_scanner.mjs +225 -0
- package/plugins/upnp_scanner.mjs +441 -0
- package/plugins/webapp_detector.mjs +246 -0
- package/plugins/wsd_scanner.mjs +290 -0
- package/utils/attack_map.mjs +180 -0
- package/utils/capabilities.mjs +53 -0
- package/utils/conclusion_utils.mjs +70 -0
- package/utils/cpe.mjs +74 -0
- package/utils/cve_validator.mjs +64 -0
- package/utils/cvss.mjs +129 -0
- package/utils/delta_reporter.mjs +110 -0
- package/utils/export_csv.mjs +82 -0
- package/utils/finding_queue.mjs +64 -0
- package/utils/finding_schema.mjs +36 -0
- package/utils/host_iterator.mjs +166 -0
- package/utils/license.mjs +29 -0
- package/utils/net_validation.mjs +66 -0
- package/utils/nvd_cache.mjs +77 -0
- package/utils/nvd_client.mjs +130 -0
- package/utils/oui.mjs +107 -0
- package/utils/plugin_discovery.mjs +89 -0
- package/utils/prompts.mjs +143 -0
- package/utils/raw_report_html.mjs +170 -0
- package/utils/redact.mjs +79 -0
- package/utils/report_html.mjs +236 -0
- package/utils/sarif.mjs +225 -0
- package/utils/scan_history.mjs +248 -0
- package/utils/scheduler.mjs +157 -0
- 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
|
+
}
|