nsauditor-ai 0.1.12 → 0.1.20

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/README.md CHANGED
@@ -110,6 +110,9 @@ Results land in `./out/<host>_<timestamp>/`:
110
110
  | `scan_response_ai.json` | Raw AI API response |
111
111
  | `scan_response_ai.txt` | AI conclusion (markdown) |
112
112
  | `scan_response_ai.html` | Styled HTML report with CVE links and badges |
113
+ | `scan_results.sarif.json` | SARIF 2.1 — only with `--output-format sarif` (renamed `scan_<host>.sarif.json` for multi-host runs) |
114
+ | `scan_results.csv` | CSV — only with `--output-format csv` |
115
+ | `scan_report.md` | GitHub-flavored Markdown report — only with `--output-format md` (or `markdown`) |
113
116
 
114
117
  > Works on Node 20+ (tested on Node 22).
115
118
 
@@ -184,7 +187,7 @@ NSAuditor AI supports three AI providers for vulnerability analysis. **All provi
184
187
 
185
188
  **What changes by tier is the prompt content, not the provider:**
186
189
 
187
- - **CE** — basic scan-summary prompts (services, ports, versions detected)
190
+ - **CE** — basic scan-summary prompts (services, ports, versions detected). Local MITRE ATT&CK mapping via `utils/attack_map.mjs`: service-context-aware CVE→technique mapping (`mapCveToAttack`, `mapServiceToAttack`), plus a CWE→technique fallback (`cweToMitre`, `cwesToMitre`) covering ~30 common CWEs (auth, crypto, injection, memory safety, info disclosure, privilege escalation, web). The CWE fallback fires only when CVE-derived mapping returns no techniques — useful for findings annotated with `evidence.cwe[]` (per FindingSchema v0.1.13+) but no CVE context, such as agent-detected misconfigurations and compliance-flagged weaknesses
188
191
  - **Pro** — intelligence-enriched prompts (CVE matches, MITRE techniques, risk scores, verification status injected into the prompt). Same API call, vastly better output
189
192
  - **Enterprise** — Pro prompts + compliance context
190
193
 
@@ -350,9 +353,9 @@ nsauditor-ai scan [options]
350
353
  | `--host-file <path>` | File with one host per line (`#` comments, blank lines OK) | — |
351
354
  | `--plugins <list>` | Comma-separated plugin IDs or `all` | `all` |
352
355
  | `--ports <list>` | Comma-separated ports to pass to plugins | — |
353
- | `--out <dir>` | Custom output directory | `out/` |
356
+ | `--out <dir>` | Custom output directory — applies to the per-scan folder *and* to alternate-format files (SARIF/CSV/Markdown) | `out/` |
354
357
  | `--parallel <n>` | Concurrent host scans | `1` |
355
- | `--output-format <fmt>` | Output format: `sarif` for CI/CD | — |
358
+ | `--output-format <fmt>` | Additional output format: `sarif` (CI/CD) · `csv` (spreadsheet) · `md` or `markdown` (chat/PR/Slack quotable) | — |
356
359
  | `--fail-on <sev>` | Exit code 2 if findings ≥ severity: `critical\|high\|medium\|low\|info` | — |
357
360
  | `--insecure-https` | Accept self-signed TLS certificates | `false` |
358
361
  | `--watch` | Enable CTEM continuous scanning | `false` |
@@ -388,6 +391,9 @@ nsauditor-ai scan --host 192.168.1.8 --plugins 011,006,009,013,008
388
391
  # SARIF output for CI/CD, fail on high+ findings
389
392
  nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format sarif --fail-on high
390
393
 
394
+ # Markdown report — paste straight into a GitHub issue, Slack thread, or chat
395
+ nsauditor-ai scan --host 10.0.0.5 --plugins all --output-format md
396
+
391
397
  # Continuous monitoring with webhook alerts
392
398
  nsauditor-ai scan --host 192.168.1.0/24 --plugins all \
393
399
  --watch --interval 30 \
@@ -398,6 +404,27 @@ nsauditor-ai scan --host 192.168.1.0/24 --plugins all \
398
404
  nsauditor-ai scan --host-file targets.txt --plugins all --parallel 4
399
405
  ```
400
406
 
407
+ ### Pre-flight `validate` command
408
+
409
+ `nsauditor-ai validate` runs a fast (<2s) environment check without scanning anything. Useful for CI/CD setups, Docker `HEALTHCHECK` probes, and first-time-user diagnosis. Each check returns a status; the overall exit code is 0 (all OK), 1 (warnings), or 2 (errors).
410
+
411
+ Checks: plugin discovery, license JWT validation (if key set), AI provider configuration, output-directory writability + free space, DNS resolution.
412
+
413
+ ```bash
414
+ # Human-readable output
415
+ nsauditor-ai validate
416
+
417
+ # Machine-readable JSON for CI parsing
418
+ nsauditor-ai validate --json
419
+ ```
420
+
421
+ Docker HEALTHCHECK example:
422
+
423
+ ```dockerfile
424
+ HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=3 \
425
+ CMD nsauditor-ai validate --json | grep -q '"overall": "ok"' || exit 1
426
+ ```
427
+
401
428
  ---
402
429
 
403
430
  ## Configuration
package/cli.mjs CHANGED
@@ -12,6 +12,7 @@ import { openaiSimplePrompt, openaiPrompt as openaiProPrompt, openaiPromptOptimi
12
12
  import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
13
13
  import { buildSarifLog } from './utils/sarif.mjs';
14
14
  import { buildCsv } from './utils/export_csv.mjs';
15
+ import { buildMarkdownReport } from './utils/report_md.mjs';
15
16
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
16
17
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
17
18
  import { resolveCapabilities, hasCapability } from './utils/capabilities.mjs';
@@ -21,6 +22,9 @@ import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhoo
21
22
  import { scrubByKey } from './utils/redact.mjs';
22
23
  import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
23
24
  import { getAllTechniques } from './utils/attack_map.mjs';
25
+ import { TOOL_VERSION } from './utils/tool_version.mjs';
26
+ import { resolveBaseOutDir } from './utils/output_dir.mjs';
27
+ import { toCleanPath } from './utils/path_helpers.mjs';
24
28
 
25
29
  /* ------------------------- helpers & utilities ------------------------- */
26
30
 
@@ -42,7 +46,7 @@ const nowStamp = () => {
42
46
  );
43
47
  };
44
48
  const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
45
- const toCleanPath = (s) => String(s ?? '').trim().replace(/^['"]+|['"]+$/g, '');
49
+ // toCleanPath imported from ./utils/path_helpers.mjs (consolidated in v0.1.20)
46
50
 
47
51
  /** Minimal redactor used if nothing external is provided. */
48
52
  function redactSensitiveForAI(input, targetHost) {
@@ -104,10 +108,10 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
104
108
  : await resolveSecret(process.env.OPENAI_API_KEY);
105
109
  const key = keyRaw ? String(keyRaw).trim() : null;
106
110
 
107
- // Base output folder (directory ONLY; if a file path is given, take its dir)
108
- const outHintRaw = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out');
109
- const parsedHint = path.parse(outHintRaw);
110
- const baseOutDir = parsedHint.ext ? (parsedHint.dir || 'out') : (outHintRaw || 'out');
111
+ // Base output folder (resolved via the shared helper honors --out and
112
+ // the SCAN_OUT_PATH / OPENAI_OUT_PATH env vars consistently with the
113
+ // SARIF/CSV/MD writers below).
114
+ const baseOutDir = resolveBaseOutDir();
111
115
 
112
116
  await fsp.mkdir(baseOutDir, { recursive: true });
113
117
 
@@ -797,6 +801,26 @@ async function main() {
797
801
  process.exit(0);
798
802
  }
799
803
 
804
+ if (cmd === 'validate') {
805
+ const { runValidation } = await import('./utils/validate.mjs');
806
+ const rawArgs = process.argv.slice(2);
807
+ const wantJson = rawArgs.includes('--json');
808
+
809
+ const { overall, checks, exitCode } = await runValidation();
810
+
811
+ if (wantJson) {
812
+ console.log(JSON.stringify({ overall, exitCode, checks }, null, 2));
813
+ } else {
814
+ const glyph = { ok: '✓', warn: '⚠', error: '✗', skip: '·' };
815
+ console.log(`NSAuditor AI environment validation:\n`);
816
+ for (const c of checks) {
817
+ console.log(` ${glyph[c.status] ?? '?'} [${c.status}] ${c.name}: ${c.message}`);
818
+ }
819
+ console.log(`\nOverall: ${overall.toUpperCase()} (exit ${exitCode})`);
820
+ }
821
+ process.exit(exitCode);
822
+ }
823
+
800
824
  if (cmd !== 'scan') {
801
825
  console.error(`Unknown command: ${cmd}`);
802
826
  process.exit(2);
@@ -954,7 +978,7 @@ async function main() {
954
978
  // --- SARIF output ---
955
979
  const wantSarif = outputFormat && String(outputFormat).toLowerCase().includes('sarif');
956
980
  if (wantSarif) {
957
- const outDir = 'out';
981
+ const outDir = resolveBaseOutDir();
958
982
  await fsp.mkdir(outDir, { recursive: true });
959
983
 
960
984
  for (const scanOut of scanOutputs) {
@@ -976,7 +1000,7 @@ async function main() {
976
1000
  // --- CSV output ---
977
1001
  const wantCsv = outputFormat && String(outputFormat).toLowerCase().includes('csv');
978
1002
  if (wantCsv) {
979
- const outDir = 'out';
1003
+ const outDir = resolveBaseOutDir();
980
1004
  await fsp.mkdir(outDir, { recursive: true });
981
1005
 
982
1006
  for (const scanOut of scanOutputs) {
@@ -994,6 +1018,30 @@ async function main() {
994
1018
  }
995
1019
  }
996
1020
 
1021
+ // --- Markdown output ---
1022
+ // Accept "md" or "markdown" in --output-format. Word-boundary match avoids matching
1023
+ // "md" inside other tokens (e.g. a hypothetical future format with "md" as a substring).
1024
+ const wantMd = outputFormat && /\b(md|markdown)\b/i.test(String(outputFormat));
1025
+ if (wantMd) {
1026
+ const outDir = resolveBaseOutDir();
1027
+ await fsp.mkdir(outDir, { recursive: true });
1028
+
1029
+ for (const scanOut of scanOutputs) {
1030
+ if (!scanOut?.conclusion) continue;
1031
+ const md = buildMarkdownReport({
1032
+ host: scanOut.host,
1033
+ conclusion: scanOut.conclusion,
1034
+ toolVersion: TOOL_VERSION,
1035
+ });
1036
+ const mdFileName = scanOutputs.length > 1
1037
+ ? `scan_${safeHost(scanOut.host)}.md`
1038
+ : 'scan_report.md';
1039
+ const mdPath = path.join(outDir, mdFileName);
1040
+ await fsp.writeFile(mdPath, md, 'utf8');
1041
+ console.log(`[MD] Wrote Markdown report: ${mdPath}`);
1042
+ }
1043
+ }
1044
+
997
1045
  // --- Fail-on severity threshold ---
998
1046
  if (failOn) {
999
1047
  const threshold = SEVERITY_RANK[String(failOn).toLowerCase()];
package/mcp_server.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from '@modelcontextprotocol/sdk/types.js';
22
22
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
23
23
  import { resolveCapabilities } from './utils/capabilities.mjs';
24
+ import { buildMarkdownReport } from './utils/report_md.mjs';
24
25
 
25
26
  const _require = createRequire(import.meta.url);
26
27
  const { version: TOOL_VERSION } = _require('./package.json');
@@ -233,11 +234,28 @@ export async function handleScanHost(args) {
233
234
  // Note: timeout is controlled via PLUGIN_TIMEOUT_MS env var at startup.
234
235
  // Runtime override is not supported to avoid process-global state mutation.
235
236
  const output = await pm.run(host, 'all');
237
+
238
+ // Render a Markdown summary of the scan so AI assistants get a ready-to-quote
239
+ // report alongside the structured fields. Failure to render must not break the
240
+ // scan response (defensive: any conclusion-shape surprise should degrade to
241
+ // markdown=null, not error out the whole tool call).
242
+ let markdown = null;
243
+ try {
244
+ if (output.conclusion) {
245
+ markdown = buildMarkdownReport({
246
+ host: output.host,
247
+ conclusion: output.conclusion,
248
+ toolVersion: TOOL_VERSION,
249
+ });
250
+ }
251
+ } catch { /* swallow — markdown is best-effort */ }
252
+
236
253
  return {
237
254
  host: output.host,
238
255
  conclusion: output.conclusion ?? null,
239
256
  manifest: output.manifest ?? [],
240
257
  pluginsRan: output.results?.length ?? 0,
258
+ markdown,
241
259
  };
242
260
  }
243
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.12",
3
+ "version": "0.1.20",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -13,6 +13,18 @@
13
13
  "nsauditor-ai": "bin/nsauditor-ai.mjs",
14
14
  "nsauditor-ai-mcp": "bin/nsauditor-ai-mcp.mjs"
15
15
  },
16
+ "files": [
17
+ "bin/",
18
+ "config/",
19
+ "docs/EULA-nsauditor-ai.md",
20
+ "plugins/",
21
+ "utils/",
22
+ "CONTRIBUTING.md",
23
+ "cli.mjs",
24
+ "index.mjs",
25
+ "mcp_server.mjs",
26
+ "plugin_manager.mjs"
27
+ ],
16
28
  "dependencies": {
17
29
  "@anthropic-ai/sdk": "^0.82.0",
18
30
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -24,6 +24,75 @@ export const SERVICE_TECHNIQUE_MAP = {
24
24
  mdns_llmnr_exposure: [T('T1557.001', 'LLMNR/NBT-NS Poisoning')],
25
25
  };
26
26
 
27
+ /**
28
+ * Mapping from CWE identifiers to ATT&CK techniques.
29
+ *
30
+ * Used by `cweToMitre()` and as a fallback path in `mapServiceToAttack()` for findings
31
+ * that have CWE annotations but no service-context-derivable technique (e.g., agent-detected
32
+ * misconfigurations or compliance violations that aren't tied to a specific CVE).
33
+ *
34
+ * Coverage: ~30 CWEs spanning the most common nsauditor finding categories
35
+ * (authentication, crypto, injection, memory safety, info disclosure, path traversal,
36
+ * privilege escalation, web-specific, resource consumption).
37
+ *
38
+ * IDs are uppercased CWE-NNN format. Lookup in `cweToMitre()` is case-insensitive.
39
+ */
40
+ export const CWE_TECHNIQUE_MAP = {
41
+ // Authentication / access control
42
+ 'CWE-287': [T('T1078', 'Valid Accounts')], // Improper Authentication
43
+ 'CWE-306': [T('T1078', 'Valid Accounts')], // Missing Authentication
44
+ 'CWE-521': [T('T1110', 'Brute Force')], // Weak Password Requirements
45
+ 'CWE-798': [T('T1552.001', 'Unsecured Credentials: Credentials In Files')], // Use of Hard-coded Credentials
46
+ 'CWE-256': [T('T1552', 'Unsecured Credentials')], // Plaintext Storage of a Password
47
+ 'CWE-862': [T('T1078', 'Valid Accounts')], // Missing Authorization
48
+ 'CWE-863': [T('T1078', 'Valid Accounts')], // Incorrect Authorization
49
+
50
+ // Cryptography
51
+ 'CWE-319': [T('T1040', 'Network Sniffing')], // Cleartext Transmission of Sensitive Information
52
+ 'CWE-326': [T('T1557', 'Adversary-in-the-Middle')], // Inadequate Encryption Strength
53
+ 'CWE-327': [T('T1557', 'Adversary-in-the-Middle')], // Use of a Broken or Risky Cryptographic Algorithm
54
+ 'CWE-328': [T('T1557', 'Adversary-in-the-Middle')], // Use of Weak Hash
55
+ 'CWE-331': [T('T1557', 'Adversary-in-the-Middle')], // Insufficient Entropy
56
+
57
+ // Injection
58
+ 'CWE-77': [T('T1059', 'Command and Scripting Interpreter')], // Command Injection (generic)
59
+ 'CWE-78': [T('T1059', 'Command and Scripting Interpreter')], // OS Command Injection
60
+ 'CWE-79': [T('T1059.007', 'Command and Scripting Interpreter: JavaScript')], // XSS
61
+ 'CWE-89': [T('T1190', 'Exploit Public-Facing Application')], // SQL Injection
62
+ 'CWE-94': [T('T1059', 'Command and Scripting Interpreter')], // Code Injection
63
+ 'CWE-1336': [T('T1059', 'Command and Scripting Interpreter')], // Template Injection
64
+
65
+ // Memory safety / RCE primitives
66
+ 'CWE-119': [T('T1203', 'Exploitation for Client Execution')], // Buffer Errors
67
+ 'CWE-120': [T('T1203', 'Exploitation for Client Execution')], // Buffer Overflow
68
+ 'CWE-125': [T('T1203', 'Exploitation for Client Execution')], // Out-of-bounds Read
69
+ 'CWE-416': [T('T1203', 'Exploitation for Client Execution')], // Use After Free
70
+ 'CWE-502': [T('T1190', 'Exploit Public-Facing Application')], // Deserialization of Untrusted Data
71
+ 'CWE-787': [T('T1203', 'Exploitation for Client Execution')], // Out-of-bounds Write
72
+
73
+ // Information disclosure
74
+ 'CWE-200': [T('T1592', 'Gather Victim Host Information')], // Information Exposure
75
+ 'CWE-209': [T('T1592', 'Gather Victim Host Information')], // Information Exposure Through Error Messages
76
+
77
+ // Path traversal / file
78
+ 'CWE-22': [T('T1083', 'File and Directory Discovery')], // Path Traversal
79
+ 'CWE-434': [T('T1190', 'Exploit Public-Facing Application')], // Unrestricted Upload of File with Dangerous Type
80
+
81
+ // Privilege escalation / permissions
82
+ 'CWE-250': [T('T1068', 'Exploitation for Privilege Escalation')], // Execution with Unnecessary Privileges
83
+ 'CWE-269': [T('T1068', 'Exploitation for Privilege Escalation')], // Improper Privilege Management
84
+ 'CWE-732': [T('T1574.005', 'Hijack Execution Flow: Executable Installer File Permissions Weakness')], // Incorrect Permission Assignment
85
+
86
+ // Web-specific
87
+ 'CWE-352': [T('T1185', 'Browser Session Hijacking')], // CSRF
88
+ 'CWE-601': [T('T1204.001', 'User Execution: Malicious Link')], // URL Redirection to Untrusted Site
89
+ 'CWE-918': [T('T1071', 'Application Layer Protocol')], // SSRF
90
+
91
+ // Resource consumption / DoS
92
+ 'CWE-400': [T('T1499', 'Endpoint Denial of Service')], // Uncontrolled Resource Consumption
93
+ 'CWE-770': [T('T1499', 'Endpoint Denial of Service')], // Allocation of Resources Without Limits or Throttling
94
+ };
95
+
27
96
  /**
28
97
  * Convert a technique ID to a MITRE ATT&CK URL.
29
98
  * Sub-techniques use dot notation (T1021.004) which maps to slash paths (/T1021/004/).
@@ -35,6 +104,37 @@ export function attackUrl(techniqueId) {
35
104
  return `https://attack.mitre.org/techniques/${path}/`;
36
105
  }
37
106
 
107
+ /**
108
+ * Map a single CWE identifier to ATT&CK techniques.
109
+ * Lookup is case-insensitive and tolerates surrounding whitespace.
110
+ * Returns a fresh array (callers may push into it without aliasing the static map).
111
+ *
112
+ * @param {string} cwe - e.g. "CWE-326", "cwe-89"
113
+ * @returns {Array<{ techniqueId: string, name: string }>} Empty if unknown or invalid input.
114
+ */
115
+ export function cweToMitre(cwe) {
116
+ if (typeof cwe !== 'string') return [];
117
+ const id = cwe.trim().toUpperCase();
118
+ const techs = CWE_TECHNIQUE_MAP[id];
119
+ return techs ? [...techs] : [];
120
+ }
121
+
122
+ /**
123
+ * Map an array (or single string) of CWE identifiers to a deduplicated set of techniques.
124
+ *
125
+ * @param {string[]|string} cwes - Array like ['CWE-326', 'CWE-89'] or single string.
126
+ * @returns {Array<{ techniqueId: string, name: string }>} Deduplicated by techniqueId.
127
+ */
128
+ export function cwesToMitre(cwes) {
129
+ if (!cwes) return [];
130
+ const list = Array.isArray(cwes) ? cwes : [cwes];
131
+ const techniques = [];
132
+ for (const cwe of list) {
133
+ techniques.push(...cweToMitre(cwe));
134
+ }
135
+ return dedup(techniques);
136
+ }
137
+
38
138
  /**
39
139
  * Map a service record to matching ATT&CK techniques.
40
140
  * Inspects service type and boolean/array fields to identify relevant techniques.
@@ -93,16 +193,29 @@ export function mapServiceToAttack(service) {
93
193
  }
94
194
 
95
195
  // CVE-based mappings
196
+ let cveDerivedCount = 0;
96
197
  const cves = service.cves || service.cve || [];
97
198
  if (Array.isArray(cves)) {
98
199
  for (const cve of cves) {
99
200
  const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || '');
100
201
  if (cveId) {
101
- techniques.push(...mapCveToAttack(cveId, svcName));
202
+ const cveTechs = mapCveToAttack(cveId, svcName);
203
+ cveDerivedCount += cveTechs.length;
204
+ techniques.push(...cveTechs);
102
205
  }
103
206
  }
104
207
  }
105
208
 
209
+ // CWE-based fallback: only applied when CVE mapping produced no techniques.
210
+ // Reads in priority order: service.cwes → service.cwe → service.evidence?.cwe.
211
+ // CVE-derived mappings are service-context-aware and authoritative; CWE mappings
212
+ // are heuristic and provide coverage for findings without CVE context (agent-detected
213
+ // misconfigurations, compliance-flagged weaknesses, etc.).
214
+ if (cveDerivedCount === 0) {
215
+ const cwes = service.cwes || service.cwe || service.evidence?.cwe || [];
216
+ techniques.push(...cwesToMitre(cwes));
217
+ }
218
+
106
219
  return dedup(techniques).map(t => ({ ...t, url: attackUrl(t.techniqueId) }));
107
220
  }
108
221
 
@@ -6,8 +6,15 @@ export const FINDING_STATUSES = ['UNVERIFIED', 'VERIFIED', 'POTENTIAL', 'FALSE
6
6
  export const FINDING_SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
7
7
  export const FINDING_EFFORTS = ['LOW', 'MEDIUM', 'HIGH'];
8
8
 
9
+ const CWE_ID_PATTERN = /^CWE-\d+$/;
10
+
9
11
  /**
10
12
  * Validate a finding object against the schema.
13
+ *
14
+ * Optional evidence fields (validated only when present):
15
+ * - evidence.cwe string[] of CWE-NNN identifiers, e.g. ['CWE-326', 'CWE-200']
16
+ * - evidence.owasp string[] of OWASP categories, e.g. ['A02:2021-Cryptographic Failures']
17
+ *
11
18
  * @param {object} f
12
19
  * @returns {string[]} Array of error messages; empty = valid
13
20
  */
@@ -23,6 +30,29 @@ export function validateFinding(f) {
23
30
  errors.push('title required');
24
31
  if (!f?.target?.host)
25
32
  errors.push('target.host required');
33
+
34
+ if (f?.evidence?.cwe !== undefined) {
35
+ if (!Array.isArray(f.evidence.cwe)) {
36
+ errors.push('evidence.cwe must be an array');
37
+ } else {
38
+ for (const id of f.evidence.cwe) {
39
+ if (typeof id !== 'string' || !CWE_ID_PATTERN.test(id))
40
+ errors.push(`invalid cwe id: ${id}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ if (f?.evidence?.owasp !== undefined) {
46
+ if (!Array.isArray(f.evidence.owasp)) {
47
+ errors.push('evidence.owasp must be an array');
48
+ } else {
49
+ for (const ent of f.evidence.owasp) {
50
+ if (typeof ent !== 'string')
51
+ errors.push(`invalid owasp entry: ${ent}`);
52
+ }
53
+ }
54
+ }
55
+
26
56
  return errors;
27
57
  }
28
58
 
@@ -0,0 +1,45 @@
1
+ // utils/output_dir.mjs
2
+ //
3
+ // Single source of truth for resolving the base output directory used by
4
+ // CLI scan-output writers (main scan, SARIF, CSV, Markdown).
5
+ //
6
+ // Why a dedicated module:
7
+ // - The CLI's `--out <dir>` flag is parsed and stamped onto
8
+ // `process.env.SCAN_OUT_PATH`. Multiple writers in cli.mjs read that
9
+ // env var to compute their target path.
10
+ // - Prior to v0.1.18, the SARIF/CSV/MD output blocks hardcoded `'out'`,
11
+ // ignoring `--out`. This helper centralizes the resolution so the bug
12
+ // can't recur in a new format writer (Task N.17).
13
+ // - `OPENAI_OUT_PATH` is honored as a legacy fallback.
14
+
15
+ import path from 'node:path';
16
+ import { toCleanPath } from './path_helpers.mjs';
17
+
18
+ /**
19
+ * Resolve the base output directory.
20
+ *
21
+ * Source priority:
22
+ * 1. `process.env.SCAN_OUT_PATH` (set by `--out <dir>`)
23
+ * 2. `process.env.OPENAI_OUT_PATH` (legacy fallback)
24
+ * 3. `'out'` (default)
25
+ *
26
+ * If the resolved value points at a file (has an extension), returns its
27
+ * parent directory. This handles the "user passed --out report.json" case
28
+ * — we use the file's containing directory rather than crashing.
29
+ *
30
+ * Read fresh each call so callers see the latest env state (important
31
+ * because the CLI sets SCAN_OUT_PATH during arg parsing, after module load).
32
+ *
33
+ * @returns {string} A directory path; never empty (defaults to `'out'`).
34
+ */
35
+ export function resolveBaseOutDir() {
36
+ const raw = toCleanPath(
37
+ process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out'
38
+ );
39
+ const parsed = path.parse(raw);
40
+ // If env var pointed at a file (has an extension), use its parent dir.
41
+ // Otherwise treat the whole value as a directory.
42
+ return parsed.ext ? (parsed.dir || 'out') : (raw || 'out');
43
+ }
44
+
45
+ // (toCleanPath moved to utils/path_helpers.mjs in v0.1.20 — no _internals export needed.)
@@ -0,0 +1,29 @@
1
+ // utils/path_helpers.mjs
2
+ //
3
+ // Small, generic path-handling helpers used across cli.mjs and the output-dir
4
+ // resolution logic. Extracted from cli.mjs and utils/output_dir.mjs so both
5
+ // consumers share a single implementation (Task N.20).
6
+ //
7
+ // Pure synchronous functions, no I/O — safe to import from any context.
8
+
9
+ /**
10
+ * Trim surrounding whitespace and strip surrounding quote characters
11
+ * (single or double, possibly stacked) from a path-like string.
12
+ *
13
+ * Useful when shells (especially Windows cmd.exe / PowerShell) pass paths
14
+ * with embedded quotes intact, or when env-var values arrive with stray
15
+ * outer whitespace.
16
+ *
17
+ * Examples:
18
+ * toCleanPath('"/tmp/foo"') → '/tmp/foo'
19
+ * toCleanPath("'/tmp/bar'") → '/tmp/bar'
20
+ * toCleanPath(' /a b/c ') → '/a b/c' (internal whitespace preserved)
21
+ * toCleanPath(null) → ''
22
+ * toCleanPath(42) → '42'
23
+ *
24
+ * @param {*} s - Any value; coerced to string before processing.
25
+ * @returns {string} Cleaned string. Empty if input is nullish or all-quote.
26
+ */
27
+ export function toCleanPath(s) {
28
+ return String(s ?? '').trim().replace(/^['"]+|['"]+$/g, '');
29
+ }