nsauditor-ai 0.1.11 → 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 +38 -13
- package/cli.mjs +55 -7
- package/mcp_server.mjs +18 -0
- package/package.json +13 -1
- package/utils/attack_map.mjs +114 -1
- package/utils/finding_schema.mjs +30 -0
- package/utils/license.mjs +18 -1
- package/utils/output_dir.mjs +45 -0
- package/utils/path_helpers.mjs +29 -0
- package/utils/report_md.mjs +308 -0
- package/utils/tool_version.mjs +30 -0
- package/utils/validate.mjs +279 -0
- package/.nvd-cache/nvd_cache.json +0 -322
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A modular, AI-assisted network security audit platform that scans, understands,
|
|
|
7
7
|
[](https://www.npmjs.com/package/nsauditor-ai)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
|
-
[](#tests)
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -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>` |
|
|
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
|
|
@@ -532,17 +559,13 @@ export default {
|
|
|
532
559
|
|
|
533
560
|
## Pro & Enterprise Activation
|
|
534
561
|
|
|
535
|
-
|
|
562
|
+
After purchasing at [nsauditor.com/ai/pricing](https://www.nsauditor.com/ai/pricing), you'll receive an email with your license key and an npm install command. Two steps:
|
|
536
563
|
|
|
537
564
|
```bash
|
|
538
|
-
|
|
539
|
-
|
|
565
|
+
# 1. Install EE package (one-time, token included in email)
|
|
566
|
+
npm install -g @nsasoft/nsauditor-ai-ee --//registry.npmjs.org/:_authToken=npm_xxxxx
|
|
540
567
|
|
|
541
|
-
Set your license key
|
|
542
|
-
|
|
543
|
-
```bash
|
|
544
|
-
echo "NSAUDITOR_LICENSE_KEY=pro_eyJhbGci..." >> ~/.nsauditor/.env
|
|
545
|
-
# or export directly
|
|
568
|
+
# 2. Set your license key
|
|
546
569
|
export NSAUDITOR_LICENSE_KEY=pro_eyJhbGci...
|
|
547
570
|
```
|
|
548
571
|
|
|
@@ -556,6 +579,8 @@ nsauditor-ai license --capabilities
|
|
|
556
579
|
# ✓ intelligenceEngine ✓ riskScoring ✓ proAI ✓ advancedCTEM ...
|
|
557
580
|
```
|
|
558
581
|
|
|
582
|
+
License keys are delivered automatically via Stripe webhook — no manual processing. Subscription renewals generate a fresh key and email it to you before the current one expires.
|
|
583
|
+
|
|
559
584
|
No license key? Everything in this repository works perfectly without one. The CE is not crippled — it's a complete, production-ready security scanner.
|
|
560
585
|
|
|
561
586
|
→ [Pricing](https://www.nsauditor.com/ai/pricing) · [Start free trial](https://www.nsauditor.com/ai/trial) · [Enterprise contact](https://www.nsauditor.com/ai/enterprise)
|
|
@@ -564,7 +589,7 @@ No license key? Everything in this repository works perfectly without one. The C
|
|
|
564
589
|
|
|
565
590
|
## Tests
|
|
566
591
|
|
|
567
|
-
Run all
|
|
592
|
+
Run all 506 tests:
|
|
568
593
|
|
|
569
594
|
```bash
|
|
570
595
|
npm test
|
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
|
-
|
|
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 (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const baseOutDir =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|
package/utils/attack_map.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/utils/finding_schema.mjs
CHANGED
|
@@ -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
|
|
package/utils/license.mjs
CHANGED
|
@@ -81,6 +81,21 @@ export async function loadLicense(keyStr) {
|
|
|
81
81
|
// Cache verified tier for synchronous access
|
|
82
82
|
_verifiedTier = payload.tier;
|
|
83
83
|
|
|
84
|
+
// Compute days until expiry for renewal warnings (air-gapped VPC support)
|
|
85
|
+
const expiresAt = new Date(payload.exp * 1000);
|
|
86
|
+
const daysUntilExpiry = Math.max(0, Math.floor((expiresAt - Date.now()) / 86_400_000));
|
|
87
|
+
|
|
88
|
+
let expiryWarning = null;
|
|
89
|
+
if (daysUntilExpiry <= 1) {
|
|
90
|
+
expiryWarning = 'License expires tomorrow — update NSAUDITOR_LICENSE_KEY now';
|
|
91
|
+
} else if (daysUntilExpiry <= 7) {
|
|
92
|
+
expiryWarning = `License expires in ${daysUntilExpiry} days — check email for renewal key`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (expiryWarning) {
|
|
96
|
+
console.warn(`\u26A0 ${expiryWarning}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
return {
|
|
85
100
|
valid: true,
|
|
86
101
|
tier: payload.tier,
|
|
@@ -88,7 +103,9 @@ export async function loadLicense(keyStr) {
|
|
|
88
103
|
seats: payload.seats,
|
|
89
104
|
licenseId: payload.licenseId,
|
|
90
105
|
capabilities: payload.capabilities,
|
|
91
|
-
expiresAt:
|
|
106
|
+
expiresAt: expiresAt.toISOString(),
|
|
107
|
+
daysUntilExpiry,
|
|
108
|
+
expiryWarning,
|
|
92
109
|
};
|
|
93
110
|
} catch {
|
|
94
111
|
// Verification failure — actively downgrade to CE (prevents prefix spoofing).
|
|
@@ -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
|
+
}
|