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,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// install-script.js — generates a self-contained install script for the
|
|
4
|
+
// security-overlay pentest tools, using the CORRECT install source per
|
|
5
|
+
// tool. Most security tools are NOT in apt/brew: they are Go binaries
|
|
6
|
+
// shipped as GitHub releases. Treating everything as a PM package
|
|
7
|
+
// produces a script that fails (apt has no `gitleaks`/`nuclei`/`ffuf`/
|
|
8
|
+
// `osv-scanner`/`grype`). So each tool declares its real method:
|
|
9
|
+
//
|
|
10
|
+
// - 'pm' : available in the OS package manager (nmap, nikto, sqlmap, curl)
|
|
11
|
+
// - 'github' : a GitHub release asset (gitleaks, nuclei, ffuf, osv-scanner)
|
|
12
|
+
// - 'script' : an official installer script (grype)
|
|
13
|
+
//
|
|
14
|
+
// The generated bash script is idempotent and installs only what's missing.
|
|
15
|
+
|
|
16
|
+
const HEADER = `#!/usr/bin/env bash
|
|
17
|
+
# security-overlay — install pentest tools
|
|
18
|
+
# Generated by wize-sec-preflight. Safe to re-run (idempotent).
|
|
19
|
+
#
|
|
20
|
+
# Most of these tools are NOT in apt/brew — they are GitHub release
|
|
21
|
+
# binaries. This script installs each from its correct source.
|
|
22
|
+
# GitHub binaries go to ~/.local/bin (no sudo needed for those).
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
|
|
25
|
+
LOCAL_BIN="\${HOME}/.local/bin"
|
|
26
|
+
mkdir -p "\${LOCAL_BIN}"
|
|
27
|
+
case ":\${PATH}:" in
|
|
28
|
+
*":\${LOCAL_BIN}:"*) : ;;
|
|
29
|
+
*) echo "NOTE: add \${LOCAL_BIN} to your PATH (e.g. in ~/.bashrc):"
|
|
30
|
+
echo ' export PATH="$HOME/.local/bin:$PATH"' ;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
# Detect arch for GitHub release downloads.
|
|
34
|
+
ARCH="$(uname -m)"
|
|
35
|
+
case "\${ARCH}" in
|
|
36
|
+
x86_64|amd64) GH_ARCH="amd64"; GH_ARCH_ALT="x64" ;;
|
|
37
|
+
aarch64|arm64) GH_ARCH="arm64"; GH_ARCH_ALT="arm64" ;;
|
|
38
|
+
*) GH_ARCH="amd64"; GH_ARCH_ALT="x64" ;;
|
|
39
|
+
esac
|
|
40
|
+
OS_LC="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
41
|
+
|
|
42
|
+
# Helper: download a tar.gz GitHub release and install one binary from it.
|
|
43
|
+
gh_targz() { # $1=url $2=binary-name-inside-archive
|
|
44
|
+
local url="$1" bin="$2" tmp
|
|
45
|
+
tmp="$(mktemp -d)"
|
|
46
|
+
echo " ↓ \${bin} <- \${url}"
|
|
47
|
+
curl -fsSL "\${url}" -o "\${tmp}/a.tgz"
|
|
48
|
+
tar -xzf "\${tmp}/a.tgz" -C "\${tmp}"
|
|
49
|
+
install -m 0755 "\${tmp}/\${bin}" "\${LOCAL_BIN}/\${bin}"
|
|
50
|
+
rm -rf "\${tmp}"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
gh_zip() { # $1=url $2=binary-name-inside-archive
|
|
54
|
+
local url="$1" bin="$2" tmp
|
|
55
|
+
tmp="$(mktemp -d)"
|
|
56
|
+
echo " ↓ \${bin} <- \${url}"
|
|
57
|
+
curl -fsSL "\${url}" -o "\${tmp}/a.zip"
|
|
58
|
+
unzip -q -o "\${tmp}/a.zip" -d "\${tmp}"
|
|
59
|
+
install -m 0755 "\${tmp}/\${bin}" "\${LOCAL_BIN}/\${bin}"
|
|
60
|
+
rm -rf "\${tmp}"
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
// Per-tool install recipe. Versions are pinned to known-good releases; the
|
|
65
|
+
// user can bump them. {GH_ARCH}/{GH_ARCH_ALT}/{OS} are substituted by the
|
|
66
|
+
// bash script's own variables at run time (we emit them as shell vars).
|
|
67
|
+
const TOOL_RECIPES = {
|
|
68
|
+
// --- package-manager tools ---
|
|
69
|
+
nmap: { via: 'pm' },
|
|
70
|
+
nikto: { via: 'pm' },
|
|
71
|
+
sqlmap: { via: 'pm' },
|
|
72
|
+
curl: { via: 'pm' },
|
|
73
|
+
|
|
74
|
+
// --- GitHub release binaries ---
|
|
75
|
+
gitleaks: {
|
|
76
|
+
via: 'github',
|
|
77
|
+
// gitleaks_8.18.4_linux_x64.tar.gz (note: x64/arm64, not amd64)
|
|
78
|
+
cmd: 'gh_targz "https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_${OS_LC}_${GH_ARCH_ALT}.tar.gz" gitleaks'
|
|
79
|
+
},
|
|
80
|
+
nuclei: {
|
|
81
|
+
via: 'github',
|
|
82
|
+
// nuclei_3.3.7_linux_amd64.zip
|
|
83
|
+
cmd: 'gh_zip "https://github.com/projectdiscovery/nuclei/releases/download/v3.3.7/nuclei_3.3.7_${OS_LC}_${GH_ARCH}.zip" nuclei'
|
|
84
|
+
},
|
|
85
|
+
ffuf: {
|
|
86
|
+
via: 'github',
|
|
87
|
+
// ffuf_2.1.0_linux_amd64.tar.gz
|
|
88
|
+
cmd: 'gh_targz "https://github.com/ffuf/ffuf/releases/download/v2.1.0/ffuf_2.1.0_${OS_LC}_${GH_ARCH}.tar.gz" ffuf'
|
|
89
|
+
},
|
|
90
|
+
'osv-scanner': {
|
|
91
|
+
via: 'github',
|
|
92
|
+
// osv-scanner_linux_amd64 (raw binary, not archived)
|
|
93
|
+
cmd: 'echo " ↓ osv-scanner"; curl -fsSL "https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_${OS_LC}_${GH_ARCH}" -o "${LOCAL_BIN}/osv-scanner"; chmod 0755 "${LOCAL_BIN}/osv-scanner"'
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// --- official installer script ---
|
|
97
|
+
grype: {
|
|
98
|
+
via: 'script',
|
|
99
|
+
cmd: 'curl -fsSL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "${LOCAL_BIN}"'
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const TOOL_URLS = {
|
|
104
|
+
nmap: 'https://nmap.org/download.html',
|
|
105
|
+
nuclei: 'https://github.com/projectdiscovery/nuclei',
|
|
106
|
+
gitleaks: 'https://github.com/gitleaks/gitleaks',
|
|
107
|
+
'osv-scanner': 'https://github.com/google/osv-scanner',
|
|
108
|
+
grype: 'https://github.com/anchore/grype',
|
|
109
|
+
nikto: 'https://github.com/sullo/nikto',
|
|
110
|
+
sqlmap: 'https://github.com/sqlmapproject/sqlmap',
|
|
111
|
+
ffuf: 'https://github.com/ffuf/ffuf'
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const PM_INSTALL = {
|
|
115
|
+
apt: (pkgs) => `sudo apt-get update && sudo apt-get install -y ${pkgs.join(' ')}`,
|
|
116
|
+
dnf: (pkgs) => `sudo dnf install -y ${pkgs.join(' ')}`,
|
|
117
|
+
pacman: (pkgs) => `sudo pacman -Syu --noconfirm ${pkgs.join(' ')}`,
|
|
118
|
+
zypper: (pkgs) => `sudo zypper install -y ${pkgs.join(' ')}`,
|
|
119
|
+
apk: (pkgs) => `sudo apk add ${pkgs.join(' ')}`,
|
|
120
|
+
brew: (pkgs) => `brew install ${pkgs.join(' ')}`,
|
|
121
|
+
scoop: (pkgs) => `scoop install ${pkgs.join(' ')}`,
|
|
122
|
+
chocolatey: (pkgs) => `choco install -y ${pkgs.join(' ')}`
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function generateInstallScript(preflight) {
|
|
126
|
+
const { os, arch, packageManager, missing = [] } = preflight;
|
|
127
|
+
const lines = [HEADER];
|
|
128
|
+
|
|
129
|
+
lines.push(`# OS: ${os} (${arch})`);
|
|
130
|
+
lines.push(`# Package manager: ${packageManager || 'none'}`);
|
|
131
|
+
lines.push(`# Missing tools: ${missing.length === 0 ? '(none)' : missing.join(', ')}`);
|
|
132
|
+
lines.push('');
|
|
133
|
+
|
|
134
|
+
if (missing.length === 0) {
|
|
135
|
+
lines.push('echo "All tools are already installed. Nothing to do."');
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Bucket the missing tools by install method.
|
|
140
|
+
const pmTools = [];
|
|
141
|
+
const ghTools = [];
|
|
142
|
+
const scriptTools = [];
|
|
143
|
+
const unknownTools = [];
|
|
144
|
+
for (const tool of missing) {
|
|
145
|
+
const r = TOOL_RECIPES[tool];
|
|
146
|
+
if (!r) { unknownTools.push(tool); continue; }
|
|
147
|
+
if (r.via === 'pm') pmTools.push(tool);
|
|
148
|
+
else if (r.via === 'github') ghTools.push(tool);
|
|
149
|
+
else if (r.via === 'script') scriptTools.push(tool);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 1. Package-manager tools (need a known PM).
|
|
153
|
+
if (pmTools.length) {
|
|
154
|
+
const pmFn = PM_INSTALL[packageManager];
|
|
155
|
+
if (pmFn) {
|
|
156
|
+
lines.push(`echo "==> Installing via ${packageManager}: ${pmTools.join(', ')}"`);
|
|
157
|
+
lines.push(pmFn(pmTools));
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(`echo "==> These need a package manager (none detected): ${pmTools.join(', ')}"`);
|
|
160
|
+
for (const t of pmTools) lines.push(`echo " ${t}: ${TOOL_URLS[t] || ''}"`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. GitHub release binaries.
|
|
166
|
+
if (ghTools.length) {
|
|
167
|
+
lines.push(`echo "==> Installing GitHub-release binaries to \${LOCAL_BIN}: ${ghTools.join(', ')}"`);
|
|
168
|
+
for (const t of ghTools) {
|
|
169
|
+
lines.push(TOOL_RECIPES[t].cmd);
|
|
170
|
+
}
|
|
171
|
+
lines.push('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. Official installer scripts.
|
|
175
|
+
if (scriptTools.length) {
|
|
176
|
+
lines.push(`echo "==> Installing via official scripts: ${scriptTools.join(', ')}"`);
|
|
177
|
+
for (const t of scriptTools) {
|
|
178
|
+
lines.push(TOOL_RECIPES[t].cmd);
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4. Anything we don't have a recipe for.
|
|
184
|
+
if (unknownTools.length) {
|
|
185
|
+
lines.push(`echo "==> Manual install required for: ${unknownTools.join(', ')}"`);
|
|
186
|
+
for (const t of unknownTools) {
|
|
187
|
+
lines.push(`echo " ${t}: ${TOOL_URLS[t] || 'https://www.google.com/search?q=' + encodeURIComponent(t + ' install')}"`);
|
|
188
|
+
}
|
|
189
|
+
lines.push('');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Verification.
|
|
193
|
+
lines.push('echo ""');
|
|
194
|
+
lines.push('echo "==> Verifying..."');
|
|
195
|
+
for (const tool of missing) {
|
|
196
|
+
lines.push(`command -v ${tool} >/dev/null 2>&1 && echo " ✓ ${tool}" || echo " ✗ ${tool} (not on PATH yet)"`);
|
|
197
|
+
}
|
|
198
|
+
lines.push('');
|
|
199
|
+
lines.push('echo "Done. If any tool shows ✗, ensure ~/.local/bin is on your PATH and re-open the shell."');
|
|
200
|
+
lines.push('echo "Then run /wize-sec-pentest."');
|
|
201
|
+
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { generateInstallScript, TOOL_RECIPES, TOOL_URLS, PM_INSTALL };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// invoke-phase.js — single point that spawns a phase skill as a Node
|
|
4
|
+
// subprocess. The orchestrator (wize-sec-pentest) calls invokePhase to
|
|
5
|
+
// run each phase in sequence. Failures return {ok:false, code} — the
|
|
6
|
+
// orchestrator decides whether to continue or abort.
|
|
7
|
+
//
|
|
8
|
+
// Security invariants (enforced by the canary test in
|
|
9
|
+
// test/security-overlay/invoke-phase.test.js):
|
|
10
|
+
// - NEVER uses shell:true (no command-injection escape).
|
|
11
|
+
// - Skill names cannot escape the kit root via path traversal.
|
|
12
|
+
|
|
13
|
+
const { spawn } = require('node:child_process');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per phase
|
|
18
|
+
|
|
19
|
+
// resolvePhaseScript(skill, { kitRoot }) -> absolute path.
|
|
20
|
+
// Convention: src/security-overlay/skills/<skill>/scripts/<last-segment>.js
|
|
21
|
+
// E.g. wize-sec-recon -> .../skills/wize-sec-recon/scripts/recon.js.
|
|
22
|
+
// The path is computed even if the file does not exist (callers that want
|
|
23
|
+
// to verify existence should call fs.existsSync on the result).
|
|
24
|
+
function resolvePhaseScript(skill, opts = {}) {
|
|
25
|
+
if (typeof skill !== 'string' || !skill) return null;
|
|
26
|
+
// Reject anything that tries to escape via ../
|
|
27
|
+
if (skill.includes('..') || skill.includes('/') || skill.includes('\\') || path.isAbsolute(skill)) {
|
|
28
|
+
throw new Error(`phase-skill-name-traversal: refused ${JSON.stringify(skill)}`);
|
|
29
|
+
}
|
|
30
|
+
const kitRoot = opts.kitRoot || path.resolve(__dirname, '..', '..', '..');
|
|
31
|
+
// Convention: src/security-overlay/skills/<skill>/scripts/<scriptName>
|
|
32
|
+
// where scriptName is the explicit opts.scriptName (must include .js)
|
|
33
|
+
// if given, otherwise 'run-<lastSegment>.js'.
|
|
34
|
+
const scriptName = opts.scriptName || `run-${skill.split('-').pop()}.js`;
|
|
35
|
+
return path.join(kitRoot, 'src', 'security-overlay', 'skills', skill, 'scripts', scriptName);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// invokePhase(skill, opts) -> Promise<{ok, code, stdout, stderr, error?}>.
|
|
39
|
+
// opts: { kitRoot?, active?, extraArgs?: string[], timeout?: ms }
|
|
40
|
+
// Does NOT throw on subprocess failure — returns {ok:false, code, error}.
|
|
41
|
+
function invokePhase(skill, opts = {}) {
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
let script;
|
|
44
|
+
try {
|
|
45
|
+
script = resolvePhaseScript(skill, opts);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return resolve({ ok: false, code: -1, stdout: '', stderr: '', error: String(e.message || e) });
|
|
48
|
+
}
|
|
49
|
+
if (!script || !fs.existsSync(script)) {
|
|
50
|
+
return resolve({ ok: false, code: -1, stdout: '', stderr: '', error: `phase script not found for ${skill}` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const argv = [];
|
|
54
|
+
if (opts.active) argv.push('--active');
|
|
55
|
+
if (opts.securityDir) argv.push(`--securityDir=${opts.securityDir}`);
|
|
56
|
+
if (opts.scopePath) argv.push(`--scope=${opts.scopePath}`);
|
|
57
|
+
if (Array.isArray(opts.extraArgs)) argv.push(...opts.extraArgs);
|
|
58
|
+
|
|
59
|
+
const child = spawn(process.execPath, [script, ...argv], {
|
|
60
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
+
// shell: false is the default in spawn(); we do not set it.
|
|
62
|
+
timeout: opts.timeout || DEFAULT_TIMEOUT_MS
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let stderr = '';
|
|
67
|
+
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
68
|
+
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
69
|
+
|
|
70
|
+
child.on('error', err => {
|
|
71
|
+
resolve({ ok: false, code: -1, stdout, stderr, error: String(err.message || err) });
|
|
72
|
+
});
|
|
73
|
+
child.on('close', code => {
|
|
74
|
+
resolve({ ok: code === 0, code: code == null ? -1 : code, stdout, stderr });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Exposed for tests that need to introspect the spawn call.
|
|
80
|
+
function _spawnForTest(...args) { return spawn(...args); }
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
invokePhase,
|
|
84
|
+
resolvePhaseScript,
|
|
85
|
+
_spawnForTest
|
|
86
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// owasp.js — maps a finding (rule id / cve / poc) to an OWASP Top 10 (2021)
|
|
4
|
+
// category. Zero-dep: the category table is loaded from
|
|
5
|
+
// src/security-overlay/data/owasp-top10.json.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const DATA_PATH = path.join(__dirname, '..', 'data', 'owasp-top10.json');
|
|
11
|
+
|
|
12
|
+
let _cache = null;
|
|
13
|
+
function _load() {
|
|
14
|
+
if (_cache) return _cache;
|
|
15
|
+
const raw = fs.readFileSync(DATA_PATH, 'utf8');
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
_cache = parsed.categories || [];
|
|
18
|
+
return _cache;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listOwaspCategories() {
|
|
22
|
+
return _load().slice();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listOwaspCategoryIds() {
|
|
26
|
+
return _load().map(c => c.id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Ordered rules: the FIRST match wins. We check cve before generic rule
|
|
30
|
+
// because a finding with a CVE is unambiguously A06 (vulnerable components).
|
|
31
|
+
const RULES = [
|
|
32
|
+
{ match: f => !!f.cve, to: 'A06:2021' },
|
|
33
|
+
{ match: f => /sqli|sql[-_ ]?injection|injection/i.test(f.rule || ''), to: 'A03:2021' },
|
|
34
|
+
{ match: f => /\bxss\b/i.test(f.rule || ''), to: 'A03:2021' },
|
|
35
|
+
{ match: f => /\bauth[-_ ]?bypass|auth[-_ ]?bypass|session/i.test(f.rule || ''), to: 'A07:2021' },
|
|
36
|
+
{ match: f => /\btls|cert|cipher|\bssl\b/i.test(f.rule || ''), to: 'A02:2021' },
|
|
37
|
+
{ match: f => /\bcors|\bcsp|headers?|\bhttponly/i.test(f.rule || ''), to: 'A05:2021' },
|
|
38
|
+
{ match: f => /\bexposed|\bdisclosed|server-status|admin\b/i.test(f.rule || ''), to: 'A05:2021' },
|
|
39
|
+
{ match: f => /\bssrf|redirect/i.test(f.rule || ''), to: 'A10:2021' }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function tagOwasp(finding) {
|
|
43
|
+
if (!finding || typeof finding !== 'object') return 'UNKNOWN';
|
|
44
|
+
for (const r of RULES) {
|
|
45
|
+
try {
|
|
46
|
+
if (r.match(finding)) return r.to;
|
|
47
|
+
} catch (_) { /* defensive: never throw from a tagger */ }
|
|
48
|
+
}
|
|
49
|
+
return 'UNKNOWN';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
tagOwasp,
|
|
54
|
+
listOwaspCategories,
|
|
55
|
+
listOwaspCategoryIds
|
|
56
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// partial.js — the contract between phase skills and the report render.
|
|
4
|
+
//
|
|
5
|
+
// All phase skills write their findings to <securityDir>/<phase>.md using
|
|
6
|
+
// this helper. The format is a YAML frontmatter (zero-dep serialization)
|
|
7
|
+
// followed by `## <heading>` sections in a stable order. The report
|
|
8
|
+
// (wize-sec-report) consumes these parciais and produces the final MD/HTML.
|
|
9
|
+
//
|
|
10
|
+
// Reruns are idempotent: writePartial overwrites the file in place; sections
|
|
11
|
+
// are rendered in the order provided by the caller.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const PARTIALS_SUBDIR = '.wize/security';
|
|
17
|
+
|
|
18
|
+
// --- serialization helpers (zero-dep) ------------------------------------
|
|
19
|
+
|
|
20
|
+
// YAML-quote a string value (only when needed). Strings that look like
|
|
21
|
+
// numbers or booleans are still rendered as plain scalars — but the parse
|
|
22
|
+
// side uses a STRICT type-coercion only when the string came in as a
|
|
23
|
+
// non-string. That keeps version="2.9" round-tripping as a string.
|
|
24
|
+
function yamlScalar(value) {
|
|
25
|
+
if (value === null || value === undefined) return 'null';
|
|
26
|
+
if (typeof value === 'boolean') return String(value);
|
|
27
|
+
if (typeof value === 'number') return String(value);
|
|
28
|
+
if (typeof value === 'string') {
|
|
29
|
+
// Quote when the string contains characters that YAML would mis-parse
|
|
30
|
+
// OR when the string is empty / has leading/trailing whitespace.
|
|
31
|
+
if (/[:#&*?|<>=!%@`\n]/.test(value) || value === '' || /^\s|\s$/.test(value)) {
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`yamlScalar: unsupported type ${typeof value}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Build a nested YAML mapping under a fixed indent. Used for `tools:` and
|
|
40
|
+
// any other { name: { ... } } structure. Depth is unbounded; objects are
|
|
41
|
+
// rendered as nested mappings, scalars as flat `key: value` lines.
|
|
42
|
+
function renderNestedMap(obj, indent) {
|
|
43
|
+
const pad = ' '.repeat(indent);
|
|
44
|
+
const inner = ' '.repeat(indent + 2);
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
47
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
48
|
+
out.push(`${pad}${k}:`);
|
|
49
|
+
for (const [k2, v2] of Object.entries(v)) {
|
|
50
|
+
if (v2 && typeof v2 === 'object' && !Array.isArray(v2)) {
|
|
51
|
+
out.push(`${inner}${k2}:`);
|
|
52
|
+
const inner2 = ' '.repeat(indent + 4);
|
|
53
|
+
for (const [k3, v3] of Object.entries(v2)) {
|
|
54
|
+
out.push(`${inner2}${k3}: ${yamlScalar(v3)}`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
out.push(`${inner}${k2}: ${yamlScalar(v2)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
out.push(`${pad}${k}: ${yamlScalar(v)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse a flat YAML frontmatter into an object. We only accept the limited
|
|
68
|
+
// shape the helper writes: flat `key: value` lines plus a `tools:` block
|
|
69
|
+
// that may nest 2 levels deep (tool -> { key: value }). This is
|
|
70
|
+
// intentionally not a general YAML parser.
|
|
71
|
+
function parseFrontmatter(text) {
|
|
72
|
+
const lines = text.split('\n');
|
|
73
|
+
const out = {};
|
|
74
|
+
let inTools = false;
|
|
75
|
+
let tools = null;
|
|
76
|
+
let toolKey = null;
|
|
77
|
+
for (const raw of lines) {
|
|
78
|
+
if (inTools) {
|
|
79
|
+
if (raw.trim() === '') continue;
|
|
80
|
+
// 2-space-indented top-level key under `tools:`.
|
|
81
|
+
const toolKeyMatch = raw.match(/^ ([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/);
|
|
82
|
+
if (toolKeyMatch) {
|
|
83
|
+
toolKey = toolKeyMatch[1];
|
|
84
|
+
tools[toolKey] = {};
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// 4-space-indented mapping under the current tool.
|
|
88
|
+
const valMatch = raw.match(/^ ([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
|
|
89
|
+
if (valMatch && toolKey) {
|
|
90
|
+
tools[toolKey][valMatch[1]] = coerceScalar(valMatch[2]);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Anything else ends the tools block.
|
|
94
|
+
if (/^\S/.test(raw)) {
|
|
95
|
+
inTools = false;
|
|
96
|
+
toolKey = null;
|
|
97
|
+
// fall through to the top-level branch on the same line
|
|
98
|
+
} else {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_-]*:(\s|$)/.test(raw)) {
|
|
103
|
+
const m = raw.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
|
|
104
|
+
if (!m) continue;
|
|
105
|
+
const [, k, vRaw] = m;
|
|
106
|
+
const v = vRaw.replace(/^['"]|['"]$/g, '');
|
|
107
|
+
if (k === 'tools') {
|
|
108
|
+
inTools = true;
|
|
109
|
+
tools = {};
|
|
110
|
+
out.tools = tools;
|
|
111
|
+
toolKey = null;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
out[k] = coerceScalar(v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function coerceScalar(v) {
|
|
121
|
+
// Strings are kept as strings (so version="2.9" round-trips as a string,
|
|
122
|
+
// not a number). Booleans and null are explicit.
|
|
123
|
+
if (v === 'true') return true;
|
|
124
|
+
if (v === 'false') return false;
|
|
125
|
+
if (v === 'null') return null;
|
|
126
|
+
// Inline JSON array: [a, b, c]
|
|
127
|
+
if (/^\[.*\]$/.test(v)) {
|
|
128
|
+
try { return JSON.parse(v); } catch (_) { /* fall through */ }
|
|
129
|
+
}
|
|
130
|
+
return v;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- public API ----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function defaultSecurityDir() {
|
|
136
|
+
return path.join(process.cwd(), PARTIALS_SUBDIR);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Refuse phase names that try to escape the securityDir.
|
|
140
|
+
function assertSafePhase(phase) {
|
|
141
|
+
if (typeof phase !== 'string' || !phase) {
|
|
142
|
+
throw new Error('invalid phase: empty');
|
|
143
|
+
}
|
|
144
|
+
if (phase.includes('..') || phase.includes('/') || phase.includes('\\') || path.isAbsolute(phase)) {
|
|
145
|
+
throw new Error(`partial phase-name-traversal: refused ${JSON.stringify(phase)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// writePartial({ securityDir?, phase, mode, scope, status, tools?, sections })
|
|
150
|
+
// - sections: object of { heading: content } (preserves order via Object.keys).
|
|
151
|
+
// - Returns the absolute path of the written file.
|
|
152
|
+
function writePartial(opts) {
|
|
153
|
+
if (!opts || typeof opts !== 'object') throw new Error('writePartial: opts required');
|
|
154
|
+
const phase = opts.phase;
|
|
155
|
+
assertSafePhase(phase);
|
|
156
|
+
const sec = opts.securityDir || defaultSecurityDir();
|
|
157
|
+
fs.mkdirSync(sec, { recursive: true });
|
|
158
|
+
|
|
159
|
+
const scope = opts.scope || {};
|
|
160
|
+
const scopeSha = (scope.frontmatter && scope.frontmatter.scope_sha256) || (opts.scopeSha256 || '');
|
|
161
|
+
|
|
162
|
+
const fm = [];
|
|
163
|
+
fm.push('---');
|
|
164
|
+
fm.push(`phase: ${yamlScalar(phase)}`);
|
|
165
|
+
fm.push(`generated_at: ${new Date().toISOString()}`);
|
|
166
|
+
fm.push(`scope_sha256: ${yamlScalar(scopeSha)}`);
|
|
167
|
+
fm.push(`mode: ${yamlScalar(opts.mode || 'passive')}`);
|
|
168
|
+
fm.push(`partial_status: ${yamlScalar(opts.status || 'complete')}`);
|
|
169
|
+
if (Array.isArray(opts.dependsOn) && opts.dependsOn.length) {
|
|
170
|
+
const arr = '[' + opts.dependsOn.map(v => JSON.stringify(String(v))).join(', ') + ']';
|
|
171
|
+
fm.push(`depends_on: ${arr}`);
|
|
172
|
+
}
|
|
173
|
+
if (opts.tools && Object.keys(opts.tools).length) {
|
|
174
|
+
fm.push(renderNestedMap({ tools: opts.tools }, 0));
|
|
175
|
+
}
|
|
176
|
+
fm.push('---');
|
|
177
|
+
fm.push('');
|
|
178
|
+
|
|
179
|
+
const body = [];
|
|
180
|
+
for (const [heading, content] of Object.entries(opts.sections || {})) {
|
|
181
|
+
body.push(`## ${heading}`);
|
|
182
|
+
body.push('');
|
|
183
|
+
body.push(String(content == null ? '' : content).trimEnd());
|
|
184
|
+
body.push('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const file = path.join(sec, `${phase}.md`);
|
|
188
|
+
fs.writeFileSync(file, fm.join('\n') + '\n' + body.join('\n'), 'utf8');
|
|
189
|
+
return file;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// loadPartial({ securityDir?, phase }) -> { frontmatter, body } or null.
|
|
193
|
+
function loadPartial(opts) {
|
|
194
|
+
const phase = opts.phase;
|
|
195
|
+
assertSafePhase(phase);
|
|
196
|
+
const sec = opts.securityDir || defaultSecurityDir();
|
|
197
|
+
const file = path.join(sec, `${phase}.md`);
|
|
198
|
+
if (!fs.existsSync(file)) return null;
|
|
199
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
200
|
+
const fmMatch = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
201
|
+
if (!fmMatch) return null;
|
|
202
|
+
const frontmatter = parseFrontmatter(fmMatch[1]);
|
|
203
|
+
const body = text.slice(fmMatch[0].length);
|
|
204
|
+
return { frontmatter, body };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// listPartials({ securityDir? }) -> array of phase names (sorted).
|
|
208
|
+
function listPartials(opts = {}) {
|
|
209
|
+
const sec = opts.securityDir || defaultSecurityDir();
|
|
210
|
+
if (!fs.existsSync(sec)) return [];
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const name of fs.readdirSync(sec)) {
|
|
213
|
+
if (name.endsWith('.md') && !name.startsWith('.')) {
|
|
214
|
+
out.push(name.replace(/\.md$/, ''));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
writePartial,
|
|
222
|
+
loadPartial,
|
|
223
|
+
listPartials,
|
|
224
|
+
PARTIALS_SUBDIR
|
|
225
|
+
};
|