wize-dev-kit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/security-overlay/_shared/allowlist.js +154 -0
- package/src/security-overlay/_shared/cli-runner.js +87 -0
- package/src/security-overlay/_shared/cvss.js +108 -0
- package/src/security-overlay/_shared/detect.js +125 -0
- package/src/security-overlay/_shared/install-script.js +205 -0
- package/src/security-overlay/_shared/invoke-phase.js +86 -0
- package/src/security-overlay/_shared/owasp.js +56 -0
- package/src/security-overlay/_shared/partial.js +225 -0
- package/src/security-overlay/_shared/preflight.js +175 -0
- package/src/security-overlay/_shared/scope-gate.js +172 -0
- package/src/security-overlay/_shared/scope-parser.js +120 -0
- package/src/security-overlay/agents/red-teamer/agent.yaml +51 -0
- package/src/security-overlay/agents/red-teamer/persona.md +43 -0
- package/src/security-overlay/data/common.txt +115 -0
- package/src/security-overlay/data/owasp-top10.json +15 -0
- package/src/security-overlay/data/tool-allowlist.json +31 -0
- package/src/security-overlay/skills/wize-sec-enumerate/scripts/run-enumerate.js +180 -0
- package/src/security-overlay/skills/wize-sec-enumerate/skill.md +32 -0
- package/src/security-overlay/skills/wize-sec-exploit/data/common.txt +117 -0
- package/src/security-overlay/skills/wize-sec-exploit/scripts/run-ffuf.js +147 -0
- package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nikto.js +145 -0
- package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nuclei.js +176 -0
- package/src/security-overlay/skills/wize-sec-exploit/scripts/run-sqlmap.js +139 -0
- package/src/security-overlay/skills/wize-sec-pentest/scripts/run-pipeline.js +157 -0
- package/src/security-overlay/skills/wize-sec-pentest/skill.md +52 -0
- package/src/security-overlay/skills/wize-sec-recon/scripts/run-gitleaks.js +139 -0
- package/src/security-overlay/skills/wize-sec-recon/scripts/run-osv.js +227 -0
- package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +162 -0
- package/src/security-overlay/skills/wize-sec-recon/skill.md +35 -0
- package/src/security-overlay/skills/wize-sec-report/scripts/render-report.js +999 -0
- package/tools/installer/onboarding.js +1 -0
- package/tools/installer/render-shared.js +5 -1
- package/tools/installer/wize-cli.js +8 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_schema": "Each entry is a list of allowed flag tokens. Flags ending in ':' (e.g. '-f:') consume the next arg as their value; flags without ':' are standalone switches.",
|
|
3
|
+
"nmap": [
|
|
4
|
+
"-sV", "-Pn", "-p-", "--open", "-T4", "-sn", "-O"
|
|
5
|
+
],
|
|
6
|
+
"gitleaks": [
|
|
7
|
+
"detect", "--no-banner", "-s:", "-f:", "-r:", "-v", "--log-level=", "--exit-code:"
|
|
8
|
+
],
|
|
9
|
+
"osv-scanner": [
|
|
10
|
+
"scan", "source", "--format:", "--output-file:", "-L:", "-r", "--recursive"
|
|
11
|
+
],
|
|
12
|
+
"grype": [
|
|
13
|
+
"dir:.", "-o", "json", "-f"
|
|
14
|
+
],
|
|
15
|
+
"nuclei": [
|
|
16
|
+
"-u:", "-t:", "-severity:", "-s:", "-jsonl", "-o:", "-duc", "-rl:", "-c:", "-nc", "-silent"
|
|
17
|
+
],
|
|
18
|
+
"nikto": [
|
|
19
|
+
"-h:", "-ask", "no", "-Tuning", "-timeout=", "-port="
|
|
20
|
+
],
|
|
21
|
+
"sqlmap": [
|
|
22
|
+
"-u:", "--batch", "--level=1", "--risk=1", "--threads=2", "--timeout=10",
|
|
23
|
+
"--dbms=", "--technique=", "--tamper=", "--random-agent", "--retries=1"
|
|
24
|
+
],
|
|
25
|
+
"ffuf": [
|
|
26
|
+
"-u:", "-w:", "-mc:", "-t:", "-rate:", "-recursion", "-recursion-depth:", "-ac:", "-of:", "-o:", "-ma:", "-fs:"
|
|
27
|
+
],
|
|
28
|
+
"curl": [
|
|
29
|
+
"-sI:", "-s:", "-o:", "-L", "-k", "-A=", "-H=", "--max-time="
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// run-enumerate.js — second phase: HTTP probing + tech inference from
|
|
4
|
+
// the recon partial. Doesn't run any active tools by default.
|
|
5
|
+
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const { assertTargetInScope } = require('../../../_shared/scope-gate.js');
|
|
10
|
+
const { filterArgs } = require('../../../_shared/allowlist.js');
|
|
11
|
+
const { writePartial, loadPartial } = require('../../../_shared/partial.js');
|
|
12
|
+
|
|
13
|
+
// Parse the recon.md partial's open_ports section into a list of {port, service, version}
|
|
14
|
+
// tuples. We accept both the markdown bullet form we write (- **80/tcp** `http` — nginx)
|
|
15
|
+
// and a raw `PORT/PROTO SERVICE VERSION` form.
|
|
16
|
+
function parseOpenPorts(body) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const line of String(body || '').split('\n')) {
|
|
19
|
+
// Markdown bullet form
|
|
20
|
+
let m = line.match(/^- \*\*(\d+\/tcp)\*\* `(\S+)` — (.+?)$/);
|
|
21
|
+
if (m) { out.push({ port: m[1], service: m[2], version: m[3].trim() }); continue; }
|
|
22
|
+
// Raw form
|
|
23
|
+
m = line.match(/^(\d+\/tcp)\s+(\S+)\s+(.+?)$/);
|
|
24
|
+
if (m) out.push({ port: m[1], service: m[2], version: m[3].trim() });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build the URL the enumerator will probe given a port. We don't know the
|
|
30
|
+
// protocol from nmap alone; assume http for well-known ports, https for
|
|
31
|
+
// 443, otherwise default to http.
|
|
32
|
+
function urlForPort(port, host) {
|
|
33
|
+
const portNum = parseInt(port.split('/')[0], 10);
|
|
34
|
+
const scheme = (portNum === 443 || portNum === 8443) ? 'https' : 'http';
|
|
35
|
+
return `${scheme}://${host}:${portNum}/`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Parse a curl -sI response into a { status, headers } object. We split
|
|
39
|
+
// on `\r\n` (curl's default).
|
|
40
|
+
function parseCurlHead(stdout) {
|
|
41
|
+
const lines = String(stdout || '').split(/\r?\n/).filter(Boolean);
|
|
42
|
+
const status = (lines[0] || '').trim();
|
|
43
|
+
const headers = {};
|
|
44
|
+
for (const line of lines.slice(1)) {
|
|
45
|
+
const m = line.match(/^([A-Za-z0-9-]+):\s*(.*?)\s*$/);
|
|
46
|
+
if (m) headers[m[1].toLowerCase()] = m[2];
|
|
47
|
+
}
|
|
48
|
+
return { status, headers };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// runEnumerate({securityDir, scope, active, execFn?, detectFn?}) ->
|
|
52
|
+
// { ok, partialStatus }
|
|
53
|
+
async function runEnumerate(opts = {}) {
|
|
54
|
+
const sec = opts.securityDir;
|
|
55
|
+
const scope = opts.scope;
|
|
56
|
+
const active = opts.active === true;
|
|
57
|
+
|
|
58
|
+
const execFn = opts.execFn || ((bin, args) => {
|
|
59
|
+
return execFileSync(bin, args, { encoding: 'utf8', timeout: 10_000 });
|
|
60
|
+
});
|
|
61
|
+
const detectFn = opts.detectFn || require('../../../_shared/detect.js').detectTools;
|
|
62
|
+
|
|
63
|
+
const tools = detectFn(['curl', 'nuclei'], { cacheDir: sec });
|
|
64
|
+
const curlPresent = !!(tools.curl && tools.curl.present);
|
|
65
|
+
const nucleiPresent = !!(tools.nuclei && tools.nuclei.present);
|
|
66
|
+
|
|
67
|
+
// Load recon.md if it exists.
|
|
68
|
+
const recon = loadPartial({ securityDir: sec, phase: 'recon' });
|
|
69
|
+
const openPorts = recon ? parseOpenPorts(recon.body) : [];
|
|
70
|
+
|
|
71
|
+
// Determine which hosts to probe from the scope body. We accept the
|
|
72
|
+
// allowlist block as defined by scope-parser: `## allowlist` then a
|
|
73
|
+
// `hosts:` line followed by ` - value` items.
|
|
74
|
+
const hostAllowlist = (() => {
|
|
75
|
+
const lines = (scope.body || '').split('\n');
|
|
76
|
+
const hosts = [];
|
|
77
|
+
let inHosts = false;
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (/^hosts:\s*$/.test(line)) { inHosts = true; continue; }
|
|
80
|
+
if (inHosts) {
|
|
81
|
+
const m = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
82
|
+
if (m) hosts.push(m[1]);
|
|
83
|
+
else if (line.trim() !== '' && !/^\s/.test(line)) inHosts = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return hosts;
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
// Degraded paths: missing recon OR no tools.
|
|
90
|
+
const degraded = [];
|
|
91
|
+
if (!recon) degraded.push('recon.md ausente — sem superfície para enumerar');
|
|
92
|
+
if (!curlPresent && !nucleiPresent) degraded.push('curl e nuclei ausentes — sem probing HTTP possível');
|
|
93
|
+
|
|
94
|
+
if (degraded.length && openPorts.length === 0) {
|
|
95
|
+
writePartial({
|
|
96
|
+
securityDir: sec,
|
|
97
|
+
phase: 'enumerate',
|
|
98
|
+
mode: active ? 'active' : 'passive',
|
|
99
|
+
scope,
|
|
100
|
+
status: 'incomplete',
|
|
101
|
+
tools,
|
|
102
|
+
dependsOn: ['recon'],
|
|
103
|
+
sections: {
|
|
104
|
+
degraded_checks: degraded.join('\n')
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return { ok: true, partialStatus: 'incomplete' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Probe each host:port from the recon.
|
|
111
|
+
const surfaceLines = [];
|
|
112
|
+
const techHits = new Set();
|
|
113
|
+
let anyProbed = false;
|
|
114
|
+
let refusedCount = 0;
|
|
115
|
+
|
|
116
|
+
if (curlPresent && openPorts.length > 0) {
|
|
117
|
+
for (const host of hostAllowlist) {
|
|
118
|
+
for (const p of openPorts) {
|
|
119
|
+
// Gate: refuse out-of-scope targets BEFORE any probing.
|
|
120
|
+
const inScope = assertTargetInScope(scope, { host, port: p.port }, { refusalsDir: sec });
|
|
121
|
+
if (!inScope) { refusedCount++; continue; }
|
|
122
|
+
|
|
123
|
+
const url = urlForPort(p.port, host);
|
|
124
|
+
const args = filterArgs('curl', ['-sI', url]);
|
|
125
|
+
try {
|
|
126
|
+
const out = execFn('curl', args, { timeout: 10_000 });
|
|
127
|
+
const { status, headers } = parseCurlHead(out && out.stdout ? out.stdout : out);
|
|
128
|
+
surfaceLines.push(`- **${url}** — ${status || 'no status'}`);
|
|
129
|
+
if (headers.server) {
|
|
130
|
+
techHits.add(`server: ${headers.server}`);
|
|
131
|
+
surfaceLines.push(` - server: ${headers.server}`);
|
|
132
|
+
}
|
|
133
|
+
if (headers['x-powered-by']) {
|
|
134
|
+
techHits.add(`x-powered-by: ${headers['x-powered-by']}`);
|
|
135
|
+
surfaceLines.push(` - x-powered-by: ${headers['x-powered-by']}`);
|
|
136
|
+
}
|
|
137
|
+
if (headers['set-cookie']) {
|
|
138
|
+
surfaceLines.push(` - set-cookie: ${headers['set-cookie']}`);
|
|
139
|
+
}
|
|
140
|
+
anyProbed = true;
|
|
141
|
+
} catch (_) {
|
|
142
|
+
surfaceLines.push(`- **${url}** — probe failed`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// nuclei passive (only if present) — we don't fully parse nuclei output here;
|
|
149
|
+
// the report renderer will include the JSON dump if found.
|
|
150
|
+
const partialStatus = (anyProbed && refusedCount === 0 && degraded.length === 0) ? 'complete' : 'incomplete';
|
|
151
|
+
|
|
152
|
+
writePartial({
|
|
153
|
+
securityDir: sec,
|
|
154
|
+
phase: 'enumerate',
|
|
155
|
+
mode: active ? 'active' : 'passive',
|
|
156
|
+
scope,
|
|
157
|
+
status: partialStatus,
|
|
158
|
+
tools,
|
|
159
|
+
dependsOn: ['recon'],
|
|
160
|
+
sections: {
|
|
161
|
+
surface: surfaceLines.length ? surfaceLines.join('\n') : '_(nenhuma superfície enumerada)_',
|
|
162
|
+
tech: techHits.size ? Array.from(techHits).map(t => `- ${t}`).join('\n') : '_(tech não inferida — curl ausente ou nenhum host acessível)_',
|
|
163
|
+
...(degraded.length ? { degraded_checks: degraded.join('\n') } : {})
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return { ok: true, partialStatus };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { runEnumerate, parseOpenPorts, parseCurlHead, urlForPort };
|
|
170
|
+
|
|
171
|
+
if (require.main === module) {
|
|
172
|
+
require('../../../_shared/cli-runner.js').runFromArgv({
|
|
173
|
+
fn: ({ securityDir, scopePath, active, target } = {}) => {
|
|
174
|
+
const { loadScope } = require('../../../_shared/scope-gate.js');
|
|
175
|
+
const scope = loadScope(scopePath);
|
|
176
|
+
return runEnumerate({ securityDir, scope, active, target });
|
|
177
|
+
},
|
|
178
|
+
argMap: { 'securityDir': 'securityDir', 'scope': 'scopePath', 'active': 'active', 'target': 'target' }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
code: wize-sec-enumerate
|
|
3
|
+
name: wize-sec-enumerate
|
|
4
|
+
overlay: security
|
|
5
|
+
module: security-overlay
|
|
6
|
+
owner: red-teamer
|
|
7
|
+
status: ready
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# wize-sec-enumerate — Surface enumeration
|
|
11
|
+
|
|
12
|
+
Reads `recon.md`, probes HTTP/S ports via `curl -sI`, infers tech from `Server` and `X-Powered-By` headers. Writes `enumerate.md` with `## surface` and `## tech` sections, plus `depends_on: [recon]` in the frontmatter so the renderer orders parciais.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
/wize-sec-enumerate
|
|
18
|
+
/wize-sec-enumerate --active # currently a no-op for this phase; reserved for future
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Behavior
|
|
22
|
+
|
|
23
|
+
- Loads `.wize/security/scope.md` first; aborts on invalid scope.
|
|
24
|
+
- Reads `recon.md` partial; if missing, marks `partial_status: incomplete` and writes a degraded partial so the audit trail is complete.
|
|
25
|
+
- Probes **only the scope's allowlisted hosts**, not the recon's listed services. Out-of-scope hosts are never probed.
|
|
26
|
+
- curl and nuclei are detected via `command -v`; missing tools degrade the check rather than aborting.
|
|
27
|
+
- Calls `assertTargetInScope` for every probed target. Refusals are appended to `.refusals.log`.
|
|
28
|
+
|
|
29
|
+
## Output
|
|
30
|
+
|
|
31
|
+
- `.wize/security/enumerate.md` — partial with `## surface` (probed endpoints) and `## tech` (deduplicated `server`/`x-powered-by` hits).
|
|
32
|
+
- `.wize/security/.refusals.log` — appended on out-of-scope targets.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
admin
|
|
2
|
+
api
|
|
3
|
+
app
|
|
4
|
+
auth
|
|
5
|
+
backup
|
|
6
|
+
blog
|
|
7
|
+
cart
|
|
8
|
+
catalog
|
|
9
|
+
cms
|
|
10
|
+
config
|
|
11
|
+
console
|
|
12
|
+
contact
|
|
13
|
+
dashboard
|
|
14
|
+
data
|
|
15
|
+
db
|
|
16
|
+
debug
|
|
17
|
+
default
|
|
18
|
+
demo
|
|
19
|
+
dev
|
|
20
|
+
docs
|
|
21
|
+
download
|
|
22
|
+
editor
|
|
23
|
+
email
|
|
24
|
+
error
|
|
25
|
+
example
|
|
26
|
+
export
|
|
27
|
+
feed
|
|
28
|
+
file
|
|
29
|
+
files
|
|
30
|
+
forum
|
|
31
|
+
gallery
|
|
32
|
+
help
|
|
33
|
+
home
|
|
34
|
+
images
|
|
35
|
+
img
|
|
36
|
+
import
|
|
37
|
+
index
|
|
38
|
+
info
|
|
39
|
+
internal
|
|
40
|
+
js
|
|
41
|
+
json
|
|
42
|
+
lib
|
|
43
|
+
login
|
|
44
|
+
logout
|
|
45
|
+
mail
|
|
46
|
+
manage
|
|
47
|
+
member
|
|
48
|
+
message
|
|
49
|
+
metrics
|
|
50
|
+
mobile
|
|
51
|
+
new
|
|
52
|
+
news
|
|
53
|
+
node
|
|
54
|
+
notes
|
|
55
|
+
old
|
|
56
|
+
order
|
|
57
|
+
page
|
|
58
|
+
pages
|
|
59
|
+
panel
|
|
60
|
+
password
|
|
61
|
+
pdf
|
|
62
|
+
photo
|
|
63
|
+
php
|
|
64
|
+
ping
|
|
65
|
+
plugins
|
|
66
|
+
portal
|
|
67
|
+
post
|
|
68
|
+
posts
|
|
69
|
+
private
|
|
70
|
+
profile
|
|
71
|
+
public
|
|
72
|
+
readme
|
|
73
|
+
register
|
|
74
|
+
report
|
|
75
|
+
reports
|
|
76
|
+
reset
|
|
77
|
+
robots.txt
|
|
78
|
+
rss
|
|
79
|
+
search
|
|
80
|
+
secure
|
|
81
|
+
server
|
|
82
|
+
service
|
|
83
|
+
settings
|
|
84
|
+
setup
|
|
85
|
+
shop
|
|
86
|
+
sitemap
|
|
87
|
+
sitemap.xml
|
|
88
|
+
staff
|
|
89
|
+
static
|
|
90
|
+
stats
|
|
91
|
+
status
|
|
92
|
+
store
|
|
93
|
+
style.css
|
|
94
|
+
styles
|
|
95
|
+
subscribe
|
|
96
|
+
support
|
|
97
|
+
swagger
|
|
98
|
+
system
|
|
99
|
+
temp
|
|
100
|
+
test
|
|
101
|
+
tests
|
|
102
|
+
tmp
|
|
103
|
+
tools
|
|
104
|
+
tracker
|
|
105
|
+
upload
|
|
106
|
+
uploads
|
|
107
|
+
user
|
|
108
|
+
users
|
|
109
|
+
vendor
|
|
110
|
+
version
|
|
111
|
+
web
|
|
112
|
+
webhook
|
|
113
|
+
webmaster
|
|
114
|
+
widget
|
|
115
|
+
wiki
|
|
116
|
+
xml
|
|
117
|
+
xmlrpc
|
|
@@ -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
|
+
}
|