wize-dev-kit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +1 -1
  3. package/src/security-overlay/_shared/allowlist.js +154 -0
  4. package/src/security-overlay/_shared/backlog.js +180 -0
  5. package/src/security-overlay/_shared/cli-runner.js +87 -0
  6. package/src/security-overlay/_shared/cvss.js +108 -0
  7. package/src/security-overlay/_shared/detect.js +125 -0
  8. package/src/security-overlay/_shared/install-script.js +205 -0
  9. package/src/security-overlay/_shared/invoke-phase.js +86 -0
  10. package/src/security-overlay/_shared/owasp.js +56 -0
  11. package/src/security-overlay/_shared/partial.js +225 -0
  12. package/src/security-overlay/_shared/preflight.js +175 -0
  13. package/src/security-overlay/_shared/scope-gate.js +172 -0
  14. package/src/security-overlay/_shared/scope-parser.js +120 -0
  15. package/src/security-overlay/agents/red-teamer/agent.yaml +51 -0
  16. package/src/security-overlay/agents/red-teamer/persona.md +43 -0
  17. package/src/security-overlay/data/common.txt +115 -0
  18. package/src/security-overlay/data/owasp-top10.json +15 -0
  19. package/src/security-overlay/data/tool-allowlist.json +31 -0
  20. package/src/security-overlay/skills/wize-sec-enumerate/scripts/run-enumerate.js +180 -0
  21. package/src/security-overlay/skills/wize-sec-enumerate/skill.md +32 -0
  22. package/src/security-overlay/skills/wize-sec-exploit/data/common.txt +117 -0
  23. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-ffuf.js +147 -0
  24. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nikto.js +145 -0
  25. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nuclei.js +176 -0
  26. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-sqlmap.js +139 -0
  27. package/src/security-overlay/skills/wize-sec-pentest/scripts/run-pipeline.js +167 -0
  28. package/src/security-overlay/skills/wize-sec-pentest/skill.md +52 -0
  29. package/src/security-overlay/skills/wize-sec-recon/scripts/run-gitleaks.js +139 -0
  30. package/src/security-overlay/skills/wize-sec-recon/scripts/run-osv.js +227 -0
  31. package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +162 -0
  32. package/src/security-overlay/skills/wize-sec-recon/skill.md +35 -0
  33. package/src/security-overlay/skills/wize-sec-report/scripts/render-report.js +1033 -0
  34. package/tools/installer/onboarding.js +1 -0
  35. package/tools/installer/render-shared.js +5 -1
  36. package/tools/installer/wize-cli.js +8 -1
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // run-ffuf.js — DAST ffuf (path/param fuzzing). GATED by --active.
4
+ // Rate-limit (-rate 5) keeps the scan gentle even when active. The
5
+ // wordlist is a small embedded file in data/common.txt (~100 entries);
6
+ // users with SecLists should pass their own wordlist via extraArgs
7
+ // (the data file is the conservative default).
8
+
9
+ const path = require('node:path');
10
+ const fs = require('node:fs');
11
+ const { execFileSync } = require('node:child_process');
12
+
13
+ const { assertTargetInScope } = require('../../../_shared/scope-gate.js');
14
+ const { filterArgs } = require('../../../_shared/allowlist.js');
15
+ const { writePartial, loadPartial } = require('../../../_shared/partial.js');
16
+ const { tagOwasp } = require('../../../_shared/owasp.js');
17
+
18
+ const TIMEOUT_MS = 10 * 60 * 1000;
19
+ const DEFAULT_WORDLIST = path.join(__dirname, '..', '..', '..', 'data', 'common.txt');
20
+
21
+ function filterFfufArgs(targetUrl, wordlistPath) {
22
+ return [
23
+ '-u', targetUrl.replace(/\/+$/, '') + '/FUZZ',
24
+ '-w', wordlistPath,
25
+ '-mc', '200,201,202,301,302,401,403,500',
26
+ '-t', '5',
27
+ '-rate', '5',
28
+ '-of', 'json',
29
+ '-o', path.join(path.dirname(wordlistPath), '..', '..', '..', '.ffuf.json')
30
+ ];
31
+ }
32
+
33
+ function parseFfufJson(text) {
34
+ try {
35
+ const obj = JSON.parse(text);
36
+ return (obj && obj.results) ? obj.results : [];
37
+ } catch (_) {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ async function runFfuf(opts = {}) {
43
+ const sec = opts.securityDir;
44
+ const scope = opts.scope;
45
+ const active = opts.active === true;
46
+ const wordlist = opts.wordlist || DEFAULT_WORDLIST;
47
+
48
+ const execFn = opts.execFn || ((bin, args) => {
49
+ return execFileSync(bin, args, { encoding: 'utf8', timeout: TIMEOUT_MS });
50
+ });
51
+ const detectFn = opts.detectFn || require('../../../_shared/detect.js').detectTools;
52
+
53
+ const tools = detectFn(['ffuf'], { cacheDir: sec });
54
+ const present = !!(tools.ffuf && tools.ffuf.present);
55
+
56
+ let targetUrl = null;
57
+ for (const line of (scope.body || '').split('\n')) {
58
+ const m = line.match(/^url:\s*(.+?)\s*$/);
59
+ if (m && line.indexOf('url') === 0) { targetUrl = m[1]; break; }
60
+ }
61
+
62
+ function mergeDast(sections, status) {
63
+ const existing = loadPartial({ securityDir: sec, phase: 'dast' });
64
+ const merged = {};
65
+ if (existing && existing.body) {
66
+ const re = /## ([a-z_]+)\n\n([\s\S]*?)(?=\n## |$)/g;
67
+ let m;
68
+ while ((m = re.exec(existing.body)) !== null) {
69
+ merged[m[1]] = m[2].trim();
70
+ }
71
+ }
72
+ Object.assign(merged, sections);
73
+ const finalStatus = merged.degraded_checks ? 'incomplete' : status;
74
+ writePartial({
75
+ securityDir: sec, phase: 'dast', mode: active ? 'active' : 'passive',
76
+ scope, status: finalStatus, tools, sections: merged
77
+ });
78
+ }
79
+
80
+ // 1. Not active -> degraded with [ffuf-passive].
81
+ if (!active) {
82
+ mergeDast({ degraded_checks: '- ffuf-passive: --active ausente — ffuf não é invocado sem opt-in explícito.' }, 'incomplete');
83
+ return { ok: true, partialStatus: 'incomplete' };
84
+ }
85
+
86
+ if (!targetUrl) {
87
+ mergeDast({ degraded_checks: '- ffuf: dast_target.url ausente' }, 'incomplete');
88
+ return { ok: true, partialStatus: 'incomplete' };
89
+ }
90
+
91
+ const urlObj = new URL(targetUrl);
92
+ const inScope = assertTargetInScope(scope, { host: urlObj.hostname, url: targetUrl }, { refusalsDir: sec });
93
+ if (!inScope) {
94
+ mergeDast({ degraded_checks: `- ffuf: target ${targetUrl} recusado pelo gate` }, 'incomplete');
95
+ return { ok: false, partialStatus: 'incomplete' };
96
+ }
97
+
98
+ if (!present) {
99
+ mergeDast({ degraded_checks: '- ffuf: ffuf ausente — instale ffuf e re-rode com --active.' }, 'incomplete');
100
+ return { ok: true, partialStatus: 'incomplete' };
101
+ }
102
+
103
+ // Happy path.
104
+ const reportPath = path.join(sec, '.ffuf.json');
105
+ const args = filterArgs('ffuf', [
106
+ '-u', targetUrl.replace(/\/+$/, '') + '/FUZZ',
107
+ '-w', wordlist,
108
+ '-mc', '200,201,202,301,302,401,403,500',
109
+ '-t', '5',
110
+ '-rate', '5',
111
+ '-of', 'json',
112
+ '-o', reportPath
113
+ ]);
114
+ try {
115
+ execFn('ffuf', args, { timeout: TIMEOUT_MS });
116
+ } catch (_) { /* ffuf may exit non-zero; the JSON report is still written */ }
117
+
118
+ let findings = [];
119
+ if (fs.existsSync(reportPath)) {
120
+ try {
121
+ findings = parseFfufJson(fs.readFileSync(reportPath, 'utf8'));
122
+ } catch (_) { findings = []; }
123
+ }
124
+
125
+ const body = findings.length
126
+ ? findings.map(f => {
127
+ const owasp = tagOwasp({ rule: `status-${f.status || ''} path-${f.input && f.input.FUZZ || ''}` });
128
+ return `- **${f.url}** status=${f.status} length=${f.length || 0} owasp=\`${owasp}\``;
129
+ }).join('\n')
130
+ : '_(nenhum finding do ffuf)_';
131
+
132
+ mergeDast({ ffuf: body }, findings.length ? 'complete' : 'incomplete');
133
+ return { ok: true, partialStatus: findings.length ? 'complete' : 'incomplete' };
134
+ }
135
+
136
+ module.exports = { runFfuf, filterFfufArgs, parseFfufJson };
137
+
138
+ if (require.main === module) {
139
+ require('../../../_shared/cli-runner.js').runFromArgv({
140
+ fn: ({ securityDir, scopePath, active, target, wordlist } = {}) => {
141
+ const { loadScope } = require('../../../_shared/scope-gate.js');
142
+ const scope = loadScope(scopePath);
143
+ return runFfuf({ securityDir, scope, active, target, wordlist });
144
+ },
145
+ argMap: { 'securityDir': 'securityDir', 'scope': 'scopePath', 'active': 'active', 'target': 'target', 'wordlist': 'wordlist' }
146
+ });
147
+ }
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // run-nikto.js — DAST nikto phase. Nikto runs in safe-checks mode
4
+ // (-Tuning x6) which excludes brute force and DoS probes (NFR Security #6).
5
+
6
+ const path = require('node:path');
7
+ const fs = require('node:fs');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+ const { assertTargetInScope } = require('../../../_shared/scope-gate.js');
11
+ const { filterArgs } = require('../../../_shared/allowlist.js');
12
+ const { writePartial, loadPartial } = require('../../../_shared/partial.js');
13
+ const { tagOwasp } = require('../../../_shared/owasp.js');
14
+
15
+ // Build nikto argv (raw, before filterArgs).
16
+ function filterNiktoArgs(host, outputPath) {
17
+ return ['-h', host, '-ask', 'no', '-Tuning', 'x6', '-timeout', '10', '-o', outputPath];
18
+ }
19
+
20
+ // Parse nikto's text output. We accept a relaxed line shape — every line
21
+ // that starts with '+' (or contains OSVDB-) is treated as a finding.
22
+ function parseNiktoText(text) {
23
+ const findings = [];
24
+ for (const line of String(text || '').split('\n')) {
25
+ const t = line.trim();
26
+ if (!t || t.startsWith('- Nikto')) continue; // header
27
+ if (!t.startsWith('+') && !/OSVDB-\d+/.test(t)) continue;
28
+ // id: first OSVDB-#### match, else hash of first 8 chars.
29
+ const osvdb = t.match(/OSVDB-(\d+)/);
30
+ const id = osvdb ? `OSVDB-${osvdb[1]}` : `NIKTO-${Math.abs(hashStr(t)) % 1e6}`;
31
+ const msg = t.replace(/^\+\s*/, '').replace(/^OSVDB-\d+:\s*/, '');
32
+ // Severity inferred by keyword.
33
+ let severity = 'info';
34
+ if (/injection|sqli|xss|sql injection/i.test(t)) severity = 'high';
35
+ else if (/default.{0,5}account|admin|exposed/i.test(t)) severity = 'medium';
36
+ else if (/missing|cookie|httponly|header|csp/i.test(t)) severity = 'low';
37
+ findings.push({ id, msg, severity });
38
+ }
39
+ return findings;
40
+ }
41
+
42
+ function hashStr(s) {
43
+ let h = 0;
44
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
45
+ return h;
46
+ }
47
+
48
+ async function runNikto(opts = {}) {
49
+ const sec = opts.securityDir;
50
+ const scope = opts.scope;
51
+ const active = opts.active === true;
52
+
53
+ const execFn = opts.execFn || ((bin, args) => {
54
+ return execFileSync(bin, args, { encoding: 'utf8', timeout: 10 * 60 * 1000 });
55
+ });
56
+ const detectFn = opts.detectFn || require('../../../_shared/detect.js').detectTools;
57
+
58
+ const tools = detectFn(['nikto'], { cacheDir: sec });
59
+ const present = !!(tools.nikto && tools.nikto.present);
60
+
61
+ // Resolve target URL.
62
+ let targetUrl = null;
63
+ for (const line of (scope.body || '').split('\n')) {
64
+ if (/^## dast_target/.test(line)) continue;
65
+ const m = line.match(/^url:\s*(.+?)\s*$/);
66
+ if (m && line.indexOf('url') === 0) { targetUrl = m[1]; break; }
67
+ }
68
+ if (!targetUrl) {
69
+ mergeDast(sec, scope, active, tools, { degraded_checks: '- nikto: dast_target.url ausente' }, 'incomplete');
70
+ return { ok: true, partialStatus: 'incomplete' };
71
+ }
72
+
73
+ const urlObj = new URL(targetUrl);
74
+
75
+ const inScope = assertTargetInScope(scope, { host: urlObj.hostname, url: targetUrl }, { refusalsDir: sec });
76
+ if (!inScope) {
77
+ mergeDast(sec, scope, active, tools, { degraded_checks: `- nikto: target ${targetUrl} recusado pelo gate` }, 'incomplete');
78
+ return { ok: false, partialStatus: 'incomplete' };
79
+ }
80
+
81
+ if (!present) {
82
+ mergeDast(sec, scope, active, tools, { degraded_checks: '- nikto: nikto ausente — instale nikto e re-rode.' }, 'incomplete');
83
+ return { ok: true, partialStatus: 'incomplete' };
84
+ }
85
+
86
+ const outPath = path.join(sec, '.nikto.txt');
87
+ const rawArgs = filterNiktoArgs(urlObj.hostname, outPath);
88
+ const args = filterArgs('nikto', rawArgs);
89
+ const r = execFn('nikto', args, { timeout: 10 * 60 * 1000 });
90
+ let text = r && r.stdout ? r.stdout : '';
91
+ if (!text && fs.existsSync(outPath)) {
92
+ text = fs.readFileSync(outPath, 'utf8');
93
+ }
94
+ const raw = parseNiktoText(text);
95
+ const findings = raw.map(f => ({
96
+ id: f.id,
97
+ msg: f.msg,
98
+ severity: f.severity,
99
+ owasp: tagOwasp({ rule: f.id + ' ' + f.msg })
100
+ }));
101
+ const lines = findings.length
102
+ ? findings.map(f => `- **${f.id}** severity=${f.severity} owasp=\`${f.owasp}\` — ${f.msg}`)
103
+ : ['_(nenhum finding do nikto)_'];
104
+
105
+ // Merge into dast.md: preserve sections from earlier tools (e.g. nuclei).
106
+ mergeDast(sec, scope, active, tools, { nikto: lines.join('\n') }, findings.length ? 'complete' : 'incomplete');
107
+ return { ok: true, partialStatus: findings.length ? 'complete' : 'incomplete' };
108
+ }
109
+
110
+ function mergeDast(sec, scope, active, tools, update, defaultStatus) {
111
+ const existing = loadPartial({ securityDir: sec, phase: 'dast' });
112
+ const sections = {};
113
+ if (existing && existing.body) {
114
+ const re = /## ([a-z_]+)\n\n([\s\S]*?)(?=\n## |$)/g;
115
+ let m;
116
+ while ((m = re.exec(existing.body)) !== null) {
117
+ sections[m[1]] = m[2].trim();
118
+ }
119
+ }
120
+ Object.assign(sections, update);
121
+ // Status: incomplete if any degraded_checks section is present.
122
+ const status = sections.degraded_checks ? 'incomplete' : (defaultStatus || 'complete');
123
+ writePartial({
124
+ securityDir: sec,
125
+ phase: 'dast',
126
+ mode: active ? 'active' : 'passive',
127
+ scope,
128
+ status,
129
+ tools,
130
+ sections
131
+ });
132
+ }
133
+
134
+ module.exports = { runNikto, filterNiktoArgs, parseNiktoText };
135
+
136
+ if (require.main === module) {
137
+ require('../../../_shared/cli-runner.js').runFromArgv({
138
+ fn: ({ securityDir, scopePath, active, target } = {}) => {
139
+ const { loadScope } = require('../../../_shared/scope-gate.js');
140
+ const scope = loadScope(scopePath);
141
+ return runNikto({ securityDir, scope, active, target });
142
+ },
143
+ argMap: { 'securityDir': 'securityDir', 'scope': 'scopePath', 'active': 'active', 'target': 'target' }
144
+ });
145
+ }
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // run-nuclei.js — DAST nuclei phase. Default is passive (info disclosure,
4
+ // misconfig, headers). Active mode is gated by --active (sqlmap/ffuf
5
+ // come in separate stories).
6
+
7
+ const path = require('node:path');
8
+ const fs = require('node:fs');
9
+ const { execFileSync } = require('node:child_process');
10
+
11
+ const { assertTargetInScope } = require('../../../_shared/scope-gate.js');
12
+ const { filterArgs } = require('../../../_shared/allowlist.js');
13
+ const { writePartial } = require('../../../_shared/partial.js');
14
+ const { tagOwasp } = require('../../../_shared/owasp.js');
15
+
16
+ // Build the nuclei args list. Returns the raw argv (before filterArgs).
17
+ // Kept as a separate function so tests can assert on the raw list.
18
+ function filterNucleiArgs(targetUrl, active, outputPath) {
19
+ // nuclei v3: there is no `-t passive` template path. We run the default
20
+ // template set and filter by severity. Passive = lower-impact severities
21
+ // + aggressive rate limit; active = full severity range. Output is JSONL
22
+ // (-jsonl), disable update check (-duc), non-colored (-nc), silent.
23
+ const args = [
24
+ '-u', targetUrl,
25
+ '-severity', active ? 'info,low,medium,high,critical' : 'info,low,medium',
26
+ '-rl', '10',
27
+ '-jsonl', '-o', outputPath,
28
+ '-duc', '-nc', '-silent'
29
+ ];
30
+ return args;
31
+ }
32
+
33
+ // Parse line-delimited nuclei JSON output (one finding per line).
34
+ function parseNucleiJson(text) {
35
+ const out = [];
36
+ for (const line of String(text || '').split('\n')) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) continue;
39
+ try {
40
+ const f = JSON.parse(trimmed);
41
+ out.push(f);
42
+ } catch (_) { /* skip malformed lines */ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // Build a nuclei finding object (flat) with template_id, severity, owasp, etc.
48
+ function normalizeFinding(raw) {
49
+ const templateId = raw['template-id'] || raw.templateID || raw.template || '<unknown>';
50
+ const info = raw.info || {};
51
+ const severity = (info.severity || raw.severity || 'unknown').toString().toLowerCase();
52
+ const name = info.name || raw.name || templateId;
53
+ const matchedAt = raw['matched-at'] || raw.matched || raw.host || '<unknown>';
54
+ // CVSS: nuclei sometimes nests it.
55
+ let cvss = raw.cvss;
56
+ if (typeof cvss !== 'number' && info && info.classification && info.classification.cvss_score) {
57
+ cvss = Number(info.classification.cvss_score);
58
+ }
59
+ if (typeof cvss !== 'number') cvss = null;
60
+ const owasp = tagOwasp({ rule: templateId, cve: raw['cve-id'] || raw.cve });
61
+ return { template_id: templateId, name, severity, cvss, matched_at: matchedAt, owasp };
62
+ }
63
+
64
+ // runNuclei({securityDir, scope, active, execFn?, detectFn?}) -> {ok, partialStatus}
65
+ async function runNuclei(opts = {}) {
66
+ const sec = opts.securityDir;
67
+ const scope = opts.scope;
68
+ const active = opts.active === true;
69
+
70
+ const execFn = opts.execFn || ((bin, args) => {
71
+ return execFileSync(bin, args, { encoding: 'utf8', timeout: 10 * 60 * 1000 });
72
+ });
73
+ const detectFn = opts.detectFn || require('../../../_shared/detect.js').detectTools;
74
+
75
+ const tools = detectFn(['nuclei'], { cacheDir: sec });
76
+ const present = !!(tools.nuclei && tools.nuclei.present);
77
+
78
+ // 1. Target resolution — prefer scope body's ## dast_target.url.
79
+ let targetUrl = null;
80
+ const lines = (scope.body || '').split('\n');
81
+ let inDast = false;
82
+ for (const line of lines) {
83
+ if (/^## dast_target/.test(line)) { inDast = true; continue; }
84
+ if (inDast && /^url:\s*(.+?)\s*$/.test(line)) { targetUrl = line.match(/^url:\s*(.+?)\s*$/)[1]; break; }
85
+ if (inDast && /^## /.test(line)) break;
86
+ }
87
+ if (!targetUrl) {
88
+ writePartial({
89
+ securityDir: sec,
90
+ phase: 'dast',
91
+ mode: active ? 'active' : 'passive',
92
+ scope,
93
+ status: 'incomplete',
94
+ tools,
95
+ sections: { degraded_checks: '- nuclei: dast_target.url ausente no scope.md' }
96
+ });
97
+ return { ok: true, partialStatus: 'incomplete' };
98
+ }
99
+
100
+ // 2. Gate — target must be in scope.
101
+ const urlObj = new URL(targetUrl);
102
+ const inScope = assertTargetInScope(scope, { host: urlObj.hostname, url: targetUrl }, { refusalsDir: sec });
103
+ if (!inScope) {
104
+ writePartial({
105
+ securityDir: sec,
106
+ phase: 'dast',
107
+ mode: active ? 'active' : 'passive',
108
+ scope,
109
+ status: 'incomplete',
110
+ tools,
111
+ sections: { degraded_checks: `- nuclei: target ${targetUrl} recusado pelo gate` }
112
+ });
113
+ return { ok: false, partialStatus: 'incomplete' };
114
+ }
115
+
116
+ // 3. Missing tool -> degraded.
117
+ if (!present) {
118
+ writePartial({
119
+ securityDir: sec,
120
+ phase: 'dast',
121
+ mode: active ? 'active' : 'passive',
122
+ scope,
123
+ status: 'incomplete',
124
+ tools,
125
+ sections: { degraded_checks: '- nuclei: nuclei ausente — instale nuclei e re-rode a fase.' }
126
+ });
127
+ return { ok: true, partialStatus: 'incomplete' };
128
+ }
129
+
130
+ // 4. happy path.
131
+ const outPath = path.join(sec, '.nuclei.json');
132
+ const rawArgs = filterNucleiArgs(targetUrl, active, outPath);
133
+ const args = filterArgs('nuclei', rawArgs);
134
+ // nuclei may exit non-zero on some template errors; the JSONL output is
135
+ // still written for whatever ran. Read it regardless of exit status.
136
+ try {
137
+ execFn('nuclei', args, { timeout: 10 * 60 * 1000 });
138
+ } catch (_) { /* partial results still in outPath */ }
139
+
140
+ let findings = [];
141
+ if (fs.existsSync(outPath)) {
142
+ try {
143
+ findings = parseNucleiJson(fs.readFileSync(outPath, 'utf8'));
144
+ } catch (_) {
145
+ findings = [];
146
+ }
147
+ }
148
+ const normalized = findings.map(normalizeFinding);
149
+ const bodyLines = normalized.length
150
+ ? normalized.map(f => `- **${f.template_id}** (\`${f.name}\`) severity=${f.severity} owasp=\`${f.owasp}\`${f.cvss != null ? ` cvss=${f.cvss}` : ''} matched_at: ${f.matched_at}`)
151
+ : ['_(nenhum finding do nuclei)_'];
152
+
153
+ writePartial({
154
+ securityDir: sec,
155
+ phase: 'dast',
156
+ mode: active ? 'active' : 'passive',
157
+ scope,
158
+ status: normalized.length ? 'complete' : 'incomplete',
159
+ tools,
160
+ sections: { nuclei: bodyLines.join('\n') }
161
+ });
162
+ return { ok: true, partialStatus: normalized.length ? 'complete' : 'incomplete' };
163
+ }
164
+
165
+ module.exports = { runNuclei, filterNucleiArgs, parseNucleiJson, normalizeFinding };
166
+
167
+ if (require.main === module) {
168
+ require('../../../_shared/cli-runner.js').runFromArgv({
169
+ fn: ({ securityDir, scopePath, active, target } = {}) => {
170
+ const { loadScope } = require('../../../_shared/scope-gate.js');
171
+ const scope = loadScope(scopePath);
172
+ return runNuclei({ securityDir, scope, active, target });
173
+ },
174
+ argMap: { 'securityDir': 'securityDir', 'scope': 'scopePath', 'active': 'active', 'target': 'target' }
175
+ });
176
+ }
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ // run-sqlmap.js — DAST sqlmap. GATED by --active (ADR-004). Without
4
+ // --active, sqlmap is NOT invoked even if the tool is installed.
5
+ // filterArgs() blocks --dump and --os-shell (exfiltration / shell) —
6
+ // they're not in the allowlist.
7
+
8
+ const path = require('node:path');
9
+ const fs = require('node:fs');
10
+ const { execFileSync } = require('node:child_process');
11
+
12
+ const { assertTargetInScope } = require('../../../_shared/scope-gate.js');
13
+ const { filterArgs } = require('../../../_shared/allowlist.js');
14
+ const { writePartial, loadPartial } = require('../../../_shared/partial.js');
15
+ const { tagOwasp } = require('../../../_shared/owasp.js');
16
+
17
+ const TIMEOUT_MS = 5 * 60 * 1000;
18
+
19
+ function defaultArgs(targetUrl) {
20
+ // Conservative defaults. filterArgs() drops --dump, --os-shell, --level>1,
21
+ // --risk>1, etc. — they're not in the allowlist.
22
+ return ['-u', targetUrl, '--batch', '--level=1', '--risk=1', '--threads=2', '--timeout=10', '--retries=1', '--random-agent'];
23
+ }
24
+
25
+ // Parse a slice of sqlmap stdout into findings. Sqlmap's output is fairly
26
+ // free-form; we use a relaxed parser that looks for the canonical
27
+ // "Parameter: <name> ... Type: ..." block.
28
+ function parseSqlmapStdout(stdout) {
29
+ const findings = [];
30
+ const lines = String(stdout || '').split('\n');
31
+ let current = null;
32
+ for (const line of lines) {
33
+ const pm = line.match(/Parameter:\s*(\S+).*Type:\s*(\S+)/i);
34
+ if (pm) {
35
+ if (current) findings.push(current);
36
+ current = { parameter: pm[1], type: pm[2] };
37
+ continue;
38
+ }
39
+ const dm = line.match(/DBMS:\s*(.+?)\s*$/i);
40
+ if (dm && current) current.dbms = dm[1];
41
+ const pm2 = line.match(/Payload:\s*(.+?)\s*$/i);
42
+ if (pm2 && current) current.payload = pm2[1];
43
+ const reqm = line.match(/^\s*Type:\s*UNION.*query/i);
44
+ if (reqm && current) current.kind = 'union';
45
+ }
46
+ if (current) findings.push(current);
47
+ return findings;
48
+ }
49
+
50
+ async function runSqlmap(opts = {}) {
51
+ const sec = opts.securityDir;
52
+ const scope = opts.scope;
53
+ const active = opts.active === true;
54
+
55
+ const execFn = opts.execFn || ((bin, args) => {
56
+ return execFileSync(bin, args, { encoding: 'utf8', timeout: TIMEOUT_MS });
57
+ });
58
+ const detectFn = opts.detectFn || require('../../../_shared/detect.js').detectTools;
59
+
60
+ const tools = detectFn(['sqlmap'], { cacheDir: sec });
61
+ const present = !!(tools.sqlmap && tools.sqlmap.present);
62
+
63
+ // Resolve dast_target.url.
64
+ let targetUrl = null;
65
+ for (const line of (scope.body || '').split('\n')) {
66
+ const m = line.match(/^url:\s*(.+?)\s*$/);
67
+ if (m && line.indexOf('url') === 0) { targetUrl = m[1]; break; }
68
+ }
69
+
70
+ function mergeDast(sections, status) {
71
+ const existing = loadPartial({ securityDir: sec, phase: 'dast' });
72
+ const merged = {};
73
+ if (existing && existing.body) {
74
+ const re = /## ([a-z_]+)\n\n([\s\S]*?)(?=\n## |$)/g;
75
+ let m;
76
+ while ((m = re.exec(existing.body)) !== null) {
77
+ merged[m[1]] = m[2].trim();
78
+ }
79
+ }
80
+ Object.assign(merged, sections);
81
+ const finalStatus = merged.degraded_checks ? 'incomplete' : status;
82
+ writePartial({
83
+ securityDir: sec, phase: 'dast', mode: active ? 'active' : 'passive',
84
+ scope, status: finalStatus, tools, sections: merged
85
+ });
86
+ }
87
+
88
+ // 1. Not active -> degraded with [sqlmap-pasive].
89
+ if (!active) {
90
+ mergeDast({ degraded_checks: '- sqlmap-pasive: --active ausente — sqlmap não é invocado sem opt-in explícito.' }, 'incomplete');
91
+ return { ok: true, partialStatus: 'incomplete' };
92
+ }
93
+
94
+ if (!targetUrl) {
95
+ mergeDast({ degraded_checks: '- sqlmap: dast_target.url ausente' }, 'incomplete');
96
+ return { ok: true, partialStatus: 'incomplete' };
97
+ }
98
+
99
+ // 2. Gate first.
100
+ const urlObj = new URL(targetUrl);
101
+ const inScope = assertTargetInScope(scope, { host: urlObj.hostname, url: targetUrl }, { refusalsDir: sec });
102
+ if (!inScope) {
103
+ mergeDast({ degraded_checks: `- sqlmap: target ${targetUrl} recusado pelo gate` }, 'incomplete');
104
+ return { ok: false, partialStatus: 'incomplete' };
105
+ }
106
+
107
+ // 3. Missing tool.
108
+ if (!present) {
109
+ mergeDast({ degraded_checks: '- sqlmap: sqlmap ausente — instale sqlmap e re-rode com --active.' }, 'incomplete');
110
+ return { ok: true, partialStatus: 'incomplete' };
111
+ }
112
+
113
+ // 4. happy path (active + present + in scope).
114
+ const rawArgs = defaultArgs(targetUrl);
115
+ const args = filterArgs('sqlmap', rawArgs);
116
+ const r = execFn('sqlmap', args, { timeout: TIMEOUT_MS });
117
+ const stdout = r && r.stdout ? r.stdout : '';
118
+ const findings = parseSqlmapStdout(stdout);
119
+ const findingsWithOwasp = findings.map(f => ({ ...f, owasp: tagOwasp({ rule: f.type || 'sqlmap' }) }));
120
+ const body = findingsWithOwasp.length
121
+ ? findingsWithOwasp.map(f => `- **${f.parameter || '?'}** (\`${f.type || '?'}\`) dbms=\`${f.dbms || '?'}\` owasp=\`${f.owasp}\` payload: ${f.payload || 'n/a'}`).join('\n')
122
+ : '_(nenhum finding do sqlmap)_';
123
+
124
+ mergeDast({ sqlmap: body }, findingsWithOwasp.length ? 'complete' : 'incomplete');
125
+ return { ok: true, partialStatus: findingsWithOwasp.length ? 'complete' : 'incomplete' };
126
+ }
127
+
128
+ module.exports = { runSqlmap, defaultArgs, parseSqlmapStdout };
129
+
130
+ if (require.main === module) {
131
+ require('../../../_shared/cli-runner.js').runFromArgv({
132
+ fn: ({ securityDir, scopePath, active, target } = {}) => {
133
+ const { loadScope } = require('../../../_shared/scope-gate.js');
134
+ const scope = loadScope(scopePath);
135
+ return runSqlmap({ securityDir, scope, active, target });
136
+ },
137
+ argMap: { 'securityDir': 'securityDir', 'scope': 'scopePath', 'active': 'active', 'target': 'target' }
138
+ });
139
+ }