nsauditor-ai 0.1.12 → 0.1.21

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.
@@ -0,0 +1,308 @@
1
+ // utils/report_md.mjs
2
+ // Render scan conclusion as GitHub-flavored Markdown.
3
+ //
4
+ // Used by:
5
+ // - CLI --output-format md → writes scan_report.md alongside other formats
6
+ // - MCP scan_host tool → returns ready-to-quote markdown block in the tool response
7
+ //
8
+ // Pure synchronous renderer — no I/O, no network. Empty-conclusion inputs produce a
9
+ // minimal report (header + "no services detected") rather than throwing, so callers
10
+ // don't need to guard before invocation.
11
+
12
+ const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info'];
13
+
14
+ /**
15
+ * Escape Markdown special characters that would break table cell rendering.
16
+ * Pipes break tables; backticks break inline code; newlines break row layout.
17
+ */
18
+ function escapeCell(value) {
19
+ if (value == null) return '';
20
+ return String(value)
21
+ .replace(/\|/g, '\\|')
22
+ .replace(/`/g, '\\`')
23
+ .replace(/[\r\n]+/g, ' ');
24
+ }
25
+
26
+ /**
27
+ * Trim a value for table display; '' if null/undefined/'Unknown'.
28
+ */
29
+ function cell(value) {
30
+ if (value == null) return '';
31
+ const s = String(value).trim();
32
+ if (!s || s === 'Unknown') return '';
33
+ return escapeCell(s);
34
+ }
35
+
36
+ /**
37
+ * Extract security findings from per-service fields.
38
+ * Mirrors the per-service finding extraction used by sarif.mjs and export_csv.mjs:
39
+ * findings are derived from service flags (anonymousLogin, weakAlgorithms, cves, etc.),
40
+ * not from a separate findings array on the conclusion (which doesn't exist today).
41
+ *
42
+ * @param {object[]} services
43
+ * @param {string} host
44
+ * @returns {Array<{ severity: string, title: string, target: string, evidence: string|null }>}
45
+ */
46
+ function extractFindings(services, host) {
47
+ const findings = [];
48
+
49
+ for (const svc of services) {
50
+ const target = `${host}:${svc.port}/${svc.protocol || 'tcp'}`;
51
+
52
+ if (svc.anonymousLogin === true) {
53
+ findings.push({
54
+ severity: 'Critical',
55
+ title: 'FTP anonymous login enabled',
56
+ target,
57
+ evidence: `${svc.service || 'ftp'} on ${target} accepts anonymous authentication.`,
58
+ });
59
+ }
60
+
61
+ if (svc.axfrAllowed === true) {
62
+ findings.push({
63
+ severity: 'Critical',
64
+ title: 'DNS zone transfer (AXFR) allowed',
65
+ target,
66
+ evidence: `Zone transfer permitted on ${target}; entire zone may be enumerated.`,
67
+ });
68
+ }
69
+
70
+ if (svc.community && (svc.community === 'public' || svc.community === 'private')) {
71
+ findings.push({
72
+ severity: 'High',
73
+ title: `SNMP default community string: ${svc.community}`,
74
+ target,
75
+ evidence: `SNMP responds to community "${svc.community}" on ${target}.`,
76
+ });
77
+ }
78
+
79
+ if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) {
80
+ const algos = svc.weakAlgorithms
81
+ .map((a) => (typeof a === 'string' ? a : a?.algorithm || a?.name || ''))
82
+ .filter(Boolean);
83
+ findings.push({
84
+ severity: 'Medium',
85
+ title: `Weak algorithm(s) supported: ${algos.join(', ')}`,
86
+ target,
87
+ evidence: null,
88
+ });
89
+ }
90
+
91
+ if (Array.isArray(svc.weakProtocols) && svc.weakProtocols.length > 0) {
92
+ findings.push({
93
+ severity: 'Medium',
94
+ title: `Weak protocol(s) enabled: ${svc.weakProtocols.join(', ')}`,
95
+ target,
96
+ evidence: null,
97
+ });
98
+ }
99
+
100
+ if (Array.isArray(svc.weakCiphers) && svc.weakCiphers.length > 0) {
101
+ findings.push({
102
+ severity: 'Medium',
103
+ title: `Weak cipher(s) supported: ${svc.weakCiphers.length} cipher(s)`,
104
+ target,
105
+ evidence: svc.weakCiphers.slice(0, 5).join(', '),
106
+ });
107
+ }
108
+
109
+ if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) {
110
+ findings.push({
111
+ severity: 'Medium',
112
+ title: `Dangerous HTTP method(s) allowed: ${svc.dangerousMethods.join(', ')}`,
113
+ target,
114
+ evidence: null,
115
+ });
116
+ }
117
+
118
+ const cves = svc.cves || svc.cve || [];
119
+ if (Array.isArray(cves)) {
120
+ for (const cve of cves) {
121
+ const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || '');
122
+ if (!cveId) continue;
123
+ const sev = (typeof cve === 'object' && cve?.severity) ? String(cve.severity) : 'High';
124
+ findings.push({
125
+ severity: normalizeSeverity(sev),
126
+ title: `${cveId} — ${svc.program || svc.service || 'service'}${svc.version && svc.version !== 'Unknown' ? ' ' + svc.version : ''}`,
127
+ target,
128
+ evidence: `See https://nvd.nist.gov/vuln/detail/${cveId}`,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ return findings;
135
+ }
136
+
137
+ function normalizeSeverity(sev) {
138
+ if (!sev) return 'Info';
139
+ const s = String(sev).trim().toLowerCase();
140
+ if (s.startsWith('crit')) return 'Critical';
141
+ if (s.startsWith('hi')) return 'High';
142
+ if (s.startsWith('med')) return 'Medium';
143
+ if (s.startsWith('lo')) return 'Low';
144
+ return 'Info';
145
+ }
146
+
147
+ function severityRank(sev) {
148
+ // Lower index → higher priority for sorting
149
+ const idx = SEVERITIES.indexOf(sev);
150
+ return idx === -1 ? SEVERITIES.length : idx;
151
+ }
152
+
153
+ /**
154
+ * Compute a fenced-code-block delimiter that's guaranteed not to be closed
155
+ * prematurely by backtick runs inside the content. Per CommonMark §4.5, the
156
+ * closing fence must contain at least as many backticks as the opening fence,
157
+ * so we pick (longest internal run + 1), with a floor of 3 (the standard).
158
+ *
159
+ * Defensive against Markdown injection when evidence contains user-supplied
160
+ * data (banner snippets, probe responses) that may include literal ``` runs.
161
+ *
162
+ * @param {*} content - The content that will be wrapped in the fenced block.
163
+ * @returns {string} Backtick string of appropriate length (length >= 3).
164
+ */
165
+ function safeFenceFor(content) {
166
+ const matches = String(content ?? '').match(/`+/g) || [];
167
+ let longestRun = 0;
168
+ for (const m of matches) {
169
+ if (m.length > longestRun) longestRun = m.length;
170
+ }
171
+ const fenceLen = Math.max(3, longestRun + 1);
172
+ return '`'.repeat(fenceLen);
173
+ }
174
+
175
+ /**
176
+ * Build a GitHub-flavored Markdown scan report.
177
+ *
178
+ * @param {object} scanData
179
+ * @param {string} scanData.host - Target host (IP or hostname)
180
+ * @param {object} scanData.conclusion - Concluder output: { result: { services }, summary, host }
181
+ * @param {string} [scanData.aiAnalysis] - Optional AI-generated analysis text (Markdown or plain)
182
+ * @param {string} [scanData.toolVersion] - Tool version string (e.g. "0.1.15")
183
+ * @param {string|Date} [scanData.scanTime] - Scan timestamp (defaults to now in ISO format)
184
+ * @returns {string} Markdown report
185
+ */
186
+ export function buildMarkdownReport(scanData) {
187
+ if (!scanData || typeof scanData !== 'object') {
188
+ throw new TypeError('scanData required');
189
+ }
190
+
191
+ const host = scanData.host ?? '(unknown host)';
192
+ const conclusion = scanData.conclusion ?? {};
193
+ const services = conclusion?.result?.services ?? [];
194
+ const hostInfo = conclusion?.host ?? {};
195
+ const summaryText = conclusion?.summary ?? '';
196
+ const toolVersion = scanData.toolVersion ?? '';
197
+ const scanTime = scanData.scanTime instanceof Date
198
+ ? scanData.scanTime.toISOString()
199
+ : (scanData.scanTime ?? new Date().toISOString());
200
+
201
+ const lines = [];
202
+
203
+ // ---- Header ----
204
+ lines.push(`# NSAuditor AI Scan Report`);
205
+ lines.push('');
206
+ const headerRows = [
207
+ ['Host', host],
208
+ ['Scan time', scanTime],
209
+ ];
210
+ if (toolVersion) headerRows.push(['Tool version', toolVersion]);
211
+ if (hostInfo.os) headerRows.push(['OS', `${hostInfo.os}${hostInfo.osVersion ? ' ' + hostInfo.osVersion : ''}`]);
212
+ if (hostInfo.name && hostInfo.name !== host) headerRows.push(['Hostname', hostInfo.name]);
213
+ for (const [k, v] of headerRows) {
214
+ lines.push(`- **${k}:** ${escapeCell(v)}`);
215
+ }
216
+ lines.push('');
217
+
218
+ // ---- Summary ----
219
+ lines.push(`## Summary`);
220
+ lines.push('');
221
+ if (summaryText) {
222
+ lines.push(escapeCell(summaryText));
223
+ lines.push('');
224
+ }
225
+ lines.push(`- **Services detected:** ${services.length}`);
226
+
227
+ const findings = extractFindings(services, host);
228
+ if (findings.length > 0) {
229
+ const counts = {};
230
+ for (const sev of SEVERITIES) counts[sev] = 0;
231
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
232
+ const sevSummary = SEVERITIES
233
+ .filter((s) => counts[s] > 0)
234
+ .map((s) => `${s}: ${counts[s]}`)
235
+ .join(', ');
236
+ lines.push(`- **Security findings:** ${findings.length} (${sevSummary})`);
237
+ } else {
238
+ lines.push(`- **Security findings:** 0`);
239
+ }
240
+ lines.push('');
241
+
242
+ // ---- Services table ----
243
+ lines.push(`## Services`);
244
+ lines.push('');
245
+ if (services.length === 0) {
246
+ lines.push('_No services detected._');
247
+ lines.push('');
248
+ } else {
249
+ lines.push('| Port | Protocol | Service | Program | Version | Status |');
250
+ lines.push('|------|----------|---------|---------|---------|--------|');
251
+ for (const svc of services) {
252
+ lines.push([
253
+ '',
254
+ cell(svc.port),
255
+ cell(svc.protocol || 'tcp'),
256
+ cell(svc.service),
257
+ cell(svc.program),
258
+ cell(svc.version),
259
+ cell(svc.status),
260
+ '',
261
+ ].join(' | ').trim());
262
+ }
263
+ lines.push('');
264
+ }
265
+
266
+ // ---- Findings ----
267
+ lines.push(`## Findings`);
268
+ lines.push('');
269
+ if (findings.length === 0) {
270
+ lines.push('_No security findings._');
271
+ lines.push('');
272
+ } else {
273
+ findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
274
+ for (const f of findings) {
275
+ lines.push(`### [${f.severity}] ${f.title}`);
276
+ lines.push('');
277
+ lines.push(`- **Target:** ${escapeCell(f.target)}`);
278
+ if (f.evidence) {
279
+ lines.push('- **Evidence:**');
280
+ lines.push('');
281
+ const fence = safeFenceFor(f.evidence);
282
+ lines.push(' ' + fence);
283
+ lines.push(' ' + String(f.evidence).split(/\r?\n/).join('\n '));
284
+ lines.push(' ' + fence);
285
+ }
286
+ lines.push('');
287
+ }
288
+ }
289
+
290
+ // ---- AI Analysis (optional) ----
291
+ if (scanData.aiAnalysis && String(scanData.aiAnalysis).trim()) {
292
+ lines.push(`## AI Analysis`);
293
+ lines.push('');
294
+ lines.push(String(scanData.aiAnalysis).trim());
295
+ lines.push('');
296
+ }
297
+
298
+ return lines.join('\n');
299
+ }
300
+
301
+ // Internal helpers exported for testing.
302
+ export const _internals = {
303
+ extractFindings,
304
+ normalizeSeverity,
305
+ severityRank,
306
+ escapeCell,
307
+ safeFenceFor,
308
+ };
@@ -0,0 +1,30 @@
1
+ // utils/tool_version.mjs
2
+ //
3
+ // Single source of truth for the nsauditor-ai package version, resolved at
4
+ // module-load time from package.json via createRequire.
5
+ //
6
+ // Why a dedicated module:
7
+ // - process.env.npm_package_version is ONLY set when invoked via `npm run`.
8
+ // When users invoke through the bin shim (the normal install path) it's
9
+ // undefined, which silently produced empty version fields in rendered
10
+ // reports prior to v0.1.16 (see Task N.15 / N.6 review).
11
+ // - Centralizing the resolution avoids each consumer reinventing the
12
+ // pattern (or inventing a broken version of it).
13
+
14
+ import { createRequire } from 'node:module';
15
+
16
+ const _require = createRequire(import.meta.url);
17
+ const _pkg = _require('../package.json');
18
+
19
+ /**
20
+ * The nsauditor-ai package version, e.g. "0.1.16".
21
+ * Resolved from package.json — independent of how the process was invoked.
22
+ * @type {string}
23
+ */
24
+ export const TOOL_VERSION = _pkg.version;
25
+
26
+ /**
27
+ * The nsauditor-ai package name, e.g. "nsauditor-ai".
28
+ * @type {string}
29
+ */
30
+ export const TOOL_NAME = _pkg.name;
@@ -0,0 +1,293 @@
1
+ // utils/validate.mjs
2
+ //
3
+ // Pre-flight environment validation for the `nsauditor-ai validate` CLI command.
4
+ // Verifies that the runtime environment is correctly configured WITHOUT running
5
+ // a scan: plugins load, license JWT is valid (if set), at least one AI provider
6
+ // is configured, output dir is writable with adequate free space, and DNS
7
+ // resolution works.
8
+ //
9
+ // Designed to:
10
+ // - Complete in <2s end-to-end (each check has its own timeout)
11
+ // - Run hermetically in CI (no real network required — uses 'localhost')
12
+ // - Be Docker HEALTHCHECK friendly (exit code 0/1/2)
13
+ // - Support both human-readable and JSON output modes (CLI handles formatting)
14
+ //
15
+ // Each check returns a CheckResult: { name, status, message, details? }
16
+ // status: 'ok' | 'warn' | 'error' | 'skip'
17
+ //
18
+ // Dependencies are injectable via opts so tests can substitute fakes.
19
+
20
+ import fsp from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import dnsP from 'node:dns/promises';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { resolveBaseOutDir } from './output_dir.mjs';
25
+
26
+ // Package root — derived from THIS file's location (utils/validate.mjs) by
27
+ // going up one directory. Used as the default plugin-discovery base so that
28
+ // `nsauditor-ai validate` finds the plugins shipped with the package, NOT
29
+ // whatever happens to be in the user's cwd. Fixed in v0.1.21 (Task N.25).
30
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+
32
+ export const STATUSES = Object.freeze({
33
+ OK: 'ok',
34
+ WARN: 'warn',
35
+ ERROR: 'error',
36
+ SKIP: 'skip',
37
+ });
38
+
39
+ const FREE_SPACE_WARN_MB = 100;
40
+ const DEFAULT_NETWORK_TIMEOUT_MS = 1500;
41
+ const DEFAULT_NETWORK_HOST = 'localhost'; // hermetic — no external dependency
42
+
43
+ /**
44
+ * Verify all installed plugins load without error.
45
+ *
46
+ * Discovery base is the **package root** (derived from this file's location),
47
+ * not `process.cwd()`. Critical: when the bin shim is invoked from anywhere
48
+ * other than the install dir, `process.cwd()` is the user's working
49
+ * directory — which has no plugins. v0.1.20 had this bug; fixed in v0.1.21.
50
+ *
51
+ * @param {object} [opts]
52
+ * @param {Function} [opts.discover] - Override for testing.
53
+ * @param {string} [opts.pkgRoot] - Override package root path (test injection).
54
+ */
55
+ export async function checkPlugins({ discover, pkgRoot = PKG_ROOT } = {}) {
56
+ try {
57
+ const fn = discover || (await import('./plugin_discovery.mjs')).discoverPlugins;
58
+ const result = await fn(pkgRoot);
59
+ const plugins = Array.isArray(result) ? result : (result?.plugins ?? []);
60
+ return {
61
+ name: 'plugins',
62
+ status: STATUSES.OK,
63
+ message: `${plugins.length} plugin${plugins.length === 1 ? '' : 's'} loaded`,
64
+ details: { count: plugins.length, basePath: pkgRoot },
65
+ };
66
+ } catch (err) {
67
+ return {
68
+ name: 'plugins',
69
+ status: STATUSES.ERROR,
70
+ message: `Plugin discovery failed: ${err.message}`,
71
+ details: { error: err.message, basePath: pkgRoot },
72
+ };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * If NSAUDITOR_LICENSE_KEY is set, verify the JWT and report the resolved tier.
78
+ * Otherwise, skip (CE works fine without a license).
79
+ */
80
+ export async function checkLicense({ loadFn, env = process.env } = {}) {
81
+ const key = env.NSAUDITOR_LICENSE_KEY;
82
+ if (!key) {
83
+ return {
84
+ name: 'license',
85
+ status: STATUSES.SKIP,
86
+ message: 'No license key set — running as Community Edition',
87
+ details: { tier: 'ce' },
88
+ };
89
+ }
90
+ try {
91
+ const fn = loadFn || (await import('./license.mjs')).loadLicense;
92
+ const result = await fn(key);
93
+ if (!result.valid) {
94
+ return {
95
+ name: 'license',
96
+ status: STATUSES.ERROR,
97
+ message: `License invalid: ${result.reason || 'unknown reason'}`,
98
+ details: { tier: result.tier ?? 'ce', reason: result.reason },
99
+ };
100
+ }
101
+ // Warn if expiring within 7 days
102
+ const days = Number(result.daysUntilExpiry);
103
+ if (Number.isFinite(days) && days <= 7) {
104
+ return {
105
+ name: 'license',
106
+ status: STATUSES.WARN,
107
+ message: `License valid (${result.tier}) but expires in ${days} day${days === 1 ? '' : 's'}`,
108
+ details: { tier: result.tier, daysUntilExpiry: days, expiresAt: result.expiresAt },
109
+ };
110
+ }
111
+ return {
112
+ name: 'license',
113
+ status: STATUSES.OK,
114
+ message: `License valid (${result.tier}, ${result.org || 'unknown org'})`,
115
+ details: { tier: result.tier, org: result.org, expiresAt: result.expiresAt },
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ name: 'license',
120
+ status: STATUSES.ERROR,
121
+ message: `License verification threw: ${err.message}`,
122
+ details: { error: err.message },
123
+ };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Verify at least one AI provider is configured. Warn if none — AI is optional
129
+ * but most users want it.
130
+ */
131
+ export function checkAiProviders({ env = process.env } = {}) {
132
+ const providers = [];
133
+ if (env.OPENAI_API_KEY) providers.push('openai');
134
+ if (env.ANTHROPIC_API_KEY) providers.push('claude');
135
+ // Ollama is host-based, not key-based — presence of OLLAMA_HOST or default localhost both count
136
+ if (env.OLLAMA_HOST || env.AI_PROVIDER === 'ollama') providers.push('ollama');
137
+
138
+ if (providers.length === 0) {
139
+ return {
140
+ name: 'ai_providers',
141
+ status: STATUSES.WARN,
142
+ message: 'No AI provider configured — AI analysis will be skipped (set OPENAI_API_KEY, ANTHROPIC_API_KEY, or AI_PROVIDER=ollama)',
143
+ details: { providers: [] },
144
+ };
145
+ }
146
+ return {
147
+ name: 'ai_providers',
148
+ status: STATUSES.OK,
149
+ message: `${providers.length} provider${providers.length === 1 ? '' : 's'} configured: ${providers.join(', ')}`,
150
+ details: { providers },
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Verify the resolved output directory is writable. Warn if free space is below
156
+ * the configured threshold (100 MB by default).
157
+ *
158
+ * @param {object} [opts]
159
+ * @param {string} [opts.dir] - Override resolved dir for testing.
160
+ * @param {object} [opts.fsApi] - Override fsp for testing.
161
+ */
162
+ export async function checkOutputDir({ dir, fsApi = fsp, freeSpaceWarnMB = FREE_SPACE_WARN_MB } = {}) {
163
+ const target = dir ?? resolveBaseOutDir();
164
+ try {
165
+ await fsApi.mkdir(target, { recursive: true });
166
+ // Round-trip a tiny file to prove writability
167
+ const probe = path.join(target, `.nsauditor-validate-${process.pid}`);
168
+ await fsApi.writeFile(probe, 'ok', 'utf8');
169
+ await fsApi.unlink(probe);
170
+ } catch (err) {
171
+ return {
172
+ name: 'output_dir',
173
+ status: STATUSES.ERROR,
174
+ message: `Output dir not writable (${target}): ${err.message}`,
175
+ details: { dir: target, error: err.message },
176
+ };
177
+ }
178
+ // Free-space check — fs.promises.statfs is Node 19+ (project requires Node 20+).
179
+ // If the API throws (rare; some filesystems don't support it), surface as a warn,
180
+ // not an error — writability already proved.
181
+ let freeBytes = null;
182
+ try {
183
+ if (typeof fsApi.statfs === 'function') {
184
+ const st = await fsApi.statfs(target);
185
+ // bavail is blocks available to non-root user; bsize is block size
186
+ freeBytes = Number(st.bavail) * Number(st.bsize);
187
+ }
188
+ } catch { /* statfs unsupported — skip the free-space portion */ }
189
+
190
+ if (freeBytes != null) {
191
+ const freeMB = Math.floor(freeBytes / (1024 * 1024));
192
+ if (freeMB < freeSpaceWarnMB) {
193
+ return {
194
+ name: 'output_dir',
195
+ status: STATUSES.WARN,
196
+ message: `Output dir writable (${target}) but only ${freeMB} MB free (threshold ${freeSpaceWarnMB} MB)`,
197
+ details: { dir: target, freeMB, threshold: freeSpaceWarnMB },
198
+ };
199
+ }
200
+ return {
201
+ name: 'output_dir',
202
+ status: STATUSES.OK,
203
+ message: `Output dir writable (${target}), ${freeMB} MB free`,
204
+ details: { dir: target, freeMB },
205
+ };
206
+ }
207
+ return {
208
+ name: 'output_dir',
209
+ status: STATUSES.OK,
210
+ message: `Output dir writable (${target})`,
211
+ details: { dir: target },
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Verify DNS resolution works. Defaults to 'localhost' for hermetic CI runs;
217
+ * override via `host` for environments where the user wants to test external
218
+ * resolution.
219
+ *
220
+ * @param {object} [opts]
221
+ * @param {string} [opts.host] - Hostname to resolve.
222
+ * @param {number} [opts.timeoutMs]
223
+ * @param {Function} [opts.lookup] - Override dnsP.lookup for testing.
224
+ */
225
+ export async function checkNetwork({
226
+ host = DEFAULT_NETWORK_HOST,
227
+ timeoutMs = DEFAULT_NETWORK_TIMEOUT_MS,
228
+ lookup = dnsP.lookup,
229
+ } = {}) {
230
+ let timer;
231
+ const lookupP = lookup(host).then((res) => {
232
+ // dns.lookup returns { address, family }
233
+ return res?.address || (Array.isArray(res) ? res[0]?.address : null);
234
+ });
235
+ const timeoutP = new Promise((_, reject) => {
236
+ timer = setTimeout(() => reject(new Error(`DNS timeout after ${timeoutMs}ms`)), timeoutMs);
237
+ });
238
+ try {
239
+ const address = await Promise.race([lookupP, timeoutP]);
240
+ return {
241
+ name: 'network',
242
+ status: STATUSES.OK,
243
+ message: `DNS resolution OK (${host} → ${address})`,
244
+ details: { host, address },
245
+ };
246
+ } catch (err) {
247
+ return {
248
+ name: 'network',
249
+ status: STATUSES.WARN,
250
+ message: `DNS resolution failed (${host}): ${err.message}`,
251
+ details: { host, error: err.message },
252
+ };
253
+ } finally {
254
+ clearTimeout(timer);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Run all validation checks in parallel and aggregate results.
260
+ *
261
+ * Exit-code mapping (computed here for the CLI):
262
+ * - any 'error' → exit 2
263
+ * - any 'warn' → exit 1
264
+ * - else → exit 0
265
+ *
266
+ * @param {object} [opts] - Forwarded to individual check functions for testability.
267
+ * @returns {Promise<{ overall: 'ok'|'warn'|'error', checks: object[], exitCode: 0|1|2 }>}
268
+ */
269
+ export async function runValidation(opts = {}) {
270
+ const checks = await Promise.all([
271
+ checkPlugins(opts.plugins ?? {}),
272
+ checkLicense(opts.license ?? {}),
273
+ Promise.resolve(checkAiProviders(opts.ai ?? {})),
274
+ checkOutputDir(opts.outputDir ?? {}),
275
+ checkNetwork(opts.network ?? {}),
276
+ ]);
277
+
278
+ let overall = STATUSES.OK;
279
+ for (const c of checks) {
280
+ if (c.status === STATUSES.ERROR) { overall = STATUSES.ERROR; break; }
281
+ if (c.status === STATUSES.WARN && overall !== STATUSES.ERROR) overall = STATUSES.WARN;
282
+ }
283
+ const exitCode = overall === STATUSES.ERROR ? 2 : overall === STATUSES.WARN ? 1 : 0;
284
+ return { overall, checks, exitCode };
285
+ }
286
+
287
+ // Internal constants exposed for testing.
288
+ export const _internals = {
289
+ FREE_SPACE_WARN_MB,
290
+ DEFAULT_NETWORK_TIMEOUT_MS,
291
+ DEFAULT_NETWORK_HOST,
292
+ PKG_ROOT,
293
+ };