wize-dev-kit 0.4.1 → 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 +49 -0
- package/package.json +1 -1
- package/src/core-skills/wize-customize/skill.md +114 -0
- package/src/core-skills/wize-editorial-review-prose/skill.md +92 -0
- package/src/core-skills/wize-editorial-review-structure/skill.md +97 -0
- package/src/core-skills/wize-index-docs/skill.md +117 -0
- package/src/core-skills/wize-review-edge-case-hunter/skill.md +112 -0
- package/src/method-skills/2-plan-workflows/wize-edit-prd/workflow.md +108 -0
- package/src/method-skills/3-solutioning/wize-project-context/workflow.md +118 -0
- package/src/method-skills/4-implementation/wize-checkpoint-preview/workflow.md +115 -0
- package/src/method-skills/4-implementation/wize-correct-course/workflow.md +89 -0
- package/src/method-skills/4-implementation/wize-investigate/workflow.md +121 -0
- package/src/method-skills/4-implementation/wize-sprint-planning/workflow.md +58 -71
- package/src/method-skills/4-implementation/wize-sprint-status/workflow.md +29 -82
- package/src/orchestrator-skills/wize-onboarding/workflow.md +76 -14
- 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/src/tea-skills/wize-qa-generate-e2e-tests/workflow.md +119 -0
- package/tools/installer/onboarding.js +1 -0
- package/tools/installer/render-shared.js +50 -3
- package/tools/installer/wize-cli.js +72 -5
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// preflight.js — detects the host environment (OS/arch/package manager)
|
|
4
|
+
// and which tools from data/tool-allowlist.json are installed.
|
|
5
|
+
//
|
|
6
|
+
// Test hook: when WIZE_SEC_PREFLIGHT_OS, WIZE_SEC_PREFLIGHT_PM, and
|
|
7
|
+
// WIZE_SEC_PREFLIGHT_TOOLS (JSON) env vars are set, the real probes
|
|
8
|
+
// are skipped and the values are returned directly. This lets tests
|
|
9
|
+
// simulate Mac/Linux/Windows-WSL without actually running `which` etc.
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
15
|
+
|
|
16
|
+
const ALLOWLIST_PATH = path.join(__dirname, '..', 'data', 'tool-allowlist.json');
|
|
17
|
+
|
|
18
|
+
function readToolNames() {
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
|
|
21
|
+
// Skip _schema and any non-array fields.
|
|
22
|
+
return Object.keys(data).filter(k => k !== '_schema' && Array.isArray(data[k]));
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function detectOS() {
|
|
29
|
+
if (process.env.WIZE_SEC_PREFLIGHT_OS) return process.env.WIZE_SEC_PREFLIGHT_OS;
|
|
30
|
+
const platform = os.platform();
|
|
31
|
+
if (platform === 'linux') {
|
|
32
|
+
// Detect WSL by reading /proc/version for "Microsoft" or "WSL".
|
|
33
|
+
try {
|
|
34
|
+
const v = fs.readFileSync('/proc/version', 'utf8');
|
|
35
|
+
if (/microsoft|wsl/i.test(v)) return 'wsl';
|
|
36
|
+
} catch (_) { /* not linux */ }
|
|
37
|
+
return 'linux';
|
|
38
|
+
}
|
|
39
|
+
if (platform === 'darwin') return 'darwin';
|
|
40
|
+
if (platform === 'win32') return 'win32';
|
|
41
|
+
return 'linux';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectArch() {
|
|
45
|
+
if (process.env.WIZE_SEC_PREFLIGHT_ARCH) return process.env.WIZE_SEC_PREFLIGHT_ARCH;
|
|
46
|
+
return process.arch; // 'x64' | 'arm64' | 'ia32' etc.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectPackageManager(os) {
|
|
50
|
+
if (process.env.WIZE_SEC_PREFLIGHT_PM) return process.env.WIZE_SEC_PREFLIGHT_PM;
|
|
51
|
+
// Check for the most common PMs in order.
|
|
52
|
+
const candidates = {
|
|
53
|
+
linux: ['apt', 'dnf', 'pacman', 'zypper', 'apk'],
|
|
54
|
+
wsl: ['apt', 'dnf', 'pacman'],
|
|
55
|
+
darwin: ['brew'],
|
|
56
|
+
win32: ['scoop', 'chocolatey']
|
|
57
|
+
};
|
|
58
|
+
const list = candidates[os] || [];
|
|
59
|
+
for (const pm of list) {
|
|
60
|
+
const cmds = { apt: 'apt', dnf: 'dnf', pacman: 'pacman', zypper: 'zypper', apk: 'apk', brew: 'brew', scoop: 'scoop', chocolatey: 'choco' }[pm];
|
|
61
|
+
try {
|
|
62
|
+
execFileSync(cmds, ['--version'], { stdio: 'ignore' });
|
|
63
|
+
return pm;
|
|
64
|
+
} catch (_) { /* not present */ }
|
|
65
|
+
}
|
|
66
|
+
return 'none';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function whichCommand(os) {
|
|
70
|
+
// 'command -v' on POSIX, 'where' on Windows.
|
|
71
|
+
if (os === 'win32') return 'where';
|
|
72
|
+
return 'command';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function whichArg(os) {
|
|
76
|
+
if (os === 'win32') return [];
|
|
77
|
+
return ['-v'];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function probeWhich(name, os) {
|
|
81
|
+
try {
|
|
82
|
+
const r = spawnSync(whichCommand(os), [...whichArg(os), name], { encoding: 'utf8', timeout: 2000 });
|
|
83
|
+
if (r.status === 0 && r.stdout) {
|
|
84
|
+
return r.stdout.split('\n')[0].trim();
|
|
85
|
+
}
|
|
86
|
+
} catch (_) { /* not present */ }
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function probeVersion(binPath, os) {
|
|
91
|
+
// Try common version flags. Each is a separate probe; failure is non-fatal.
|
|
92
|
+
const flags = ['--version', '-version', '-V', 'version'];
|
|
93
|
+
for (const f of flags) {
|
|
94
|
+
try {
|
|
95
|
+
const out = execFileSync(binPath, [f], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
96
|
+
const first = (out || '').split('\n')[0].trim();
|
|
97
|
+
if (first) return first.slice(0, 120);
|
|
98
|
+
} catch (_) { /* try next */ }
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectTools(os) {
|
|
104
|
+
const names = readToolNames();
|
|
105
|
+
const tools = {};
|
|
106
|
+
// Initialize all tools as missing.
|
|
107
|
+
for (const n of names) tools[n] = { present: false };
|
|
108
|
+
// Test hook: parse the JSON env var. The hook marks the listed tools as
|
|
109
|
+
// present; everything else stays missing.
|
|
110
|
+
if (process.env.WIZE_SEC_PREFLIGHT_TOOLS) {
|
|
111
|
+
try {
|
|
112
|
+
const m = JSON.parse(process.env.WIZE_SEC_PREFLIGHT_TOOLS);
|
|
113
|
+
for (const [name, path] of Object.entries(m)) {
|
|
114
|
+
if (!(name in tools)) tools[name] = { present: !!path, path: path || undefined, version: null };
|
|
115
|
+
else {
|
|
116
|
+
tools[name] = { present: !!path, path: path || undefined, version: null };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (_) { /* fall through to real detection */ }
|
|
120
|
+
return tools;
|
|
121
|
+
}
|
|
122
|
+
// Real detection.
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
const p = probeWhich(name, os);
|
|
125
|
+
if (!p) {
|
|
126
|
+
tools[name] = { present: false };
|
|
127
|
+
} else {
|
|
128
|
+
tools[name] = { present: true, path: p, version: probeVersion(p, os) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return tools;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runPreflight(opts = {}) {
|
|
135
|
+
const os_ = detectOS();
|
|
136
|
+
const arch = detectArch();
|
|
137
|
+
const pm = detectPackageManager(os_);
|
|
138
|
+
const tools = detectTools(os_);
|
|
139
|
+
const missing = Object.entries(tools).filter(([, v]) => !v.present).map(([k]) => k);
|
|
140
|
+
return {
|
|
141
|
+
os: os_,
|
|
142
|
+
arch,
|
|
143
|
+
packageManager: pm,
|
|
144
|
+
tools,
|
|
145
|
+
missing,
|
|
146
|
+
node: process.version,
|
|
147
|
+
nodePath: process.execPath
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatReport(p) {
|
|
152
|
+
const present = Object.entries(p.tools).filter(([, v]) => v.present);
|
|
153
|
+
const lines = [];
|
|
154
|
+
lines.push(`OS: ${p.os} (${p.arch})`);
|
|
155
|
+
lines.push(`Package manager: ${p.packageManager || 'none'}`);
|
|
156
|
+
lines.push(`Node: ${p.node}`);
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push(`Tools: ${present.length} present, ${p.missing.length} missing`);
|
|
159
|
+
if (present.length) {
|
|
160
|
+
lines.push(' present:');
|
|
161
|
+
for (const [name, info] of present) {
|
|
162
|
+
const v = info.version ? ` — ${info.version}` : '';
|
|
163
|
+
lines.push(` ✓ ${name}${v}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (p.missing.length) {
|
|
167
|
+
lines.push(' missing:');
|
|
168
|
+
for (const name of p.missing) lines.push(` ✗ ${name}`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('Run the install script (see .wize/security/install-pentest-tools.sh) to add the missing tools.');
|
|
171
|
+
}
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { runPreflight, formatReport, readToolNames };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scope-gate.js — single point that decides whether an offensive tool may run
|
|
4
|
+
// against a given target. ADR-001: this module is THE gate; skills must call
|
|
5
|
+
// assertTargetInScope before any execFile.
|
|
6
|
+
//
|
|
7
|
+
// Refusals (returns false) are logged to .wize/security/.refusals.log with
|
|
8
|
+
// ISO-8601 timestamp + target + reason. Scope validation errors (ScopeError)
|
|
9
|
+
// propagate — they signal an invalid scope, not a refused action.
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
parseScope,
|
|
16
|
+
validateScope,
|
|
17
|
+
ScopeError
|
|
18
|
+
} = require('./scope-parser.js');
|
|
19
|
+
|
|
20
|
+
const REFSUAL_LOG_FILENAME = '.refusals.log';
|
|
21
|
+
|
|
22
|
+
// --- body parsing --------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// Parse the body of a scope.md into a structured allowlist. We accept the
|
|
25
|
+
// shape defined in ADR-002:
|
|
26
|
+
//
|
|
27
|
+
// ## allowlist
|
|
28
|
+
// hosts:
|
|
29
|
+
// - localhost
|
|
30
|
+
// - 127.0.0.1
|
|
31
|
+
// urls:
|
|
32
|
+
// - https://staging.example.internal/api/
|
|
33
|
+
// paths:
|
|
34
|
+
// - /api
|
|
35
|
+
//
|
|
36
|
+
// Zero-dep: each list block is matched line-by-line under its `## allowlist`
|
|
37
|
+
// heading until the next `##` heading or EOF. Empty / missing blocks yield
|
|
38
|
+
// empty arrays (which makes EVERY target fail the allowlist — fail-closed).
|
|
39
|
+
function parseAllowlist(body) {
|
|
40
|
+
const out = { hosts: [], urls: [], paths: [] };
|
|
41
|
+
const lines = String(body || '').split('\n');
|
|
42
|
+
let section = null;
|
|
43
|
+
let key = null;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const h = line.match(/^##\s+([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
46
|
+
if (h) {
|
|
47
|
+
section = h[1] === 'allowlist' ? 'allowlist' : null;
|
|
48
|
+
key = null;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (section !== 'allowlist') continue;
|
|
52
|
+
const km = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/);
|
|
53
|
+
if (km) {
|
|
54
|
+
const k = km[1];
|
|
55
|
+
if (k === 'hosts' || k === 'urls' || k === 'paths') key = k;
|
|
56
|
+
else key = null;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const lm = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
60
|
+
if (lm && key) {
|
|
61
|
+
out[key].push(lm[1]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- matching ------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function matchHost(allowlist, host) {
|
|
70
|
+
return allowlist.hosts.some(h => h === host);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function matchUrl(allowlist, url) {
|
|
74
|
+
// Normalize by stripping trailing slashes so 'http://x/' and 'http://x'
|
|
75
|
+
// match the same prefix.
|
|
76
|
+
const norm = s => String(s || '').replace(/\/+$/, '');
|
|
77
|
+
const target = norm(url);
|
|
78
|
+
return allowlist.urls.some(prefix => target.startsWith(norm(prefix)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function matchPath(allowlist, p) {
|
|
82
|
+
// The path must equal or start with one of the allowlisted paths.
|
|
83
|
+
return allowlist.paths.some(ap => p === ap || p.startsWith(ap.endsWith('/') ? ap : ap + '/'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- public api ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// loadScope(scopePath) — load + parse + validate. On error: log refusal
|
|
89
|
+
// (best-effort) and rethrow. The caller (a skill) aborts on throw.
|
|
90
|
+
function loadScope(scopePath) {
|
|
91
|
+
if (!fs.existsSync(scopePath)) {
|
|
92
|
+
// Cannot log refusal without a known scope directory; rely on caller.
|
|
93
|
+
throw new ScopeError('MISSING_FILE', null,
|
|
94
|
+
`scope.md ausente em ${scopePath} — crie e assine em .wize/security/scope.md antes de rodar o pipeline`);
|
|
95
|
+
}
|
|
96
|
+
const text = fs.readFileSync(scopePath, 'utf8');
|
|
97
|
+
const scope = parseScope(text);
|
|
98
|
+
validateScope(scope);
|
|
99
|
+
return scope;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// assertTargetInScope(scope, target, { refusalsDir }) -> boolean.
|
|
103
|
+
// On refusal, writes a line to <refusalsDir>/.refusals.log and returns false.
|
|
104
|
+
// Throws ScopeError if `scope` itself is invalid (HASH_MISMATCH, etc.) —
|
|
105
|
+
// callers should treat that as abort-the-pipeline.
|
|
106
|
+
function assertTargetInScope(scope, target, opts = {}) {
|
|
107
|
+
const refusalsDir = opts.refusalsDir || path.join(process.cwd(), '.wize', 'security');
|
|
108
|
+
|
|
109
|
+
// Validate the scope up front so any tampering is surfaced loudly. We
|
|
110
|
+
// also log the attempt before re-throwing — an invalid scope is itself
|
|
111
|
+
// a refusal event that must appear in the audit trail.
|
|
112
|
+
try {
|
|
113
|
+
validateScope(scope);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err && err.code) {
|
|
116
|
+
logRefusal(refusalsDir, target || {}, `${err.code}: ${String(err.message || '').slice(0, 200)}`);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const allowlist = parseAllowlist(scope.body);
|
|
122
|
+
|
|
123
|
+
// Evaluate each provided dimension of the target. A dimension is "in scope"
|
|
124
|
+
// iff it matches an allowlist entry. If the caller provides multiple
|
|
125
|
+
// dimensions (e.g. {host, url}), all of them must match.
|
|
126
|
+
const checks = [];
|
|
127
|
+
if (target.host) checks.push({ ok: matchHost(allowlist, target.host), why: 'host not in allowlist' });
|
|
128
|
+
if (target.url) checks.push({ ok: matchUrl(allowlist, target.url), why: 'url not in allowlist' });
|
|
129
|
+
if (target.path) checks.push({ ok: matchPath(allowlist, target.path), why: 'path not in allowlist' });
|
|
130
|
+
|
|
131
|
+
if (checks.length === 0) {
|
|
132
|
+
// Defensive: refusing a target we can't classify is the safe default.
|
|
133
|
+
logRefusal(refusalsDir, target, 'no target dimension provided');
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const allIn = checks.every(c => c.ok);
|
|
138
|
+
if (!allIn) {
|
|
139
|
+
const reason = checks.filter(c => !c.ok).map(c => c.why).join('; ');
|
|
140
|
+
logRefusal(refusalsDir, target, reason);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// logRefusal(refusalsDir, target, reason) — append a YAML line to
|
|
147
|
+
// .wize/security/.refusals.log. Best-effort: never throws (a logging failure
|
|
148
|
+
// must not mask the refusal that caused it).
|
|
149
|
+
function logRefusal(refusalsDir, target, reason) {
|
|
150
|
+
try {
|
|
151
|
+
fs.mkdirSync(refusalsDir, { recursive: true });
|
|
152
|
+
const file = path.join(refusalsDir, REFSUAL_LOG_FILENAME);
|
|
153
|
+
const entry = [
|
|
154
|
+
'-',
|
|
155
|
+
` timestamp: ${new Date().toISOString()}`,
|
|
156
|
+
...Object.entries(target || {}).map(([k, v]) => ` ${k}: ${String(v).replace(/\n/g, ' ')}`),
|
|
157
|
+
` reason: ${String(reason || 'unspecified').replace(/\n/g, ' ')}`
|
|
158
|
+
].join('\n') + '\n';
|
|
159
|
+
fs.appendFileSync(file, entry, 'utf8');
|
|
160
|
+
} catch (_) {
|
|
161
|
+
// Intentionally swallow — refusing to crash the caller is more important
|
|
162
|
+
// than refusing to log. The gate decision (return false) is what matters.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
loadScope,
|
|
168
|
+
assertTargetInScope,
|
|
169
|
+
logRefusal,
|
|
170
|
+
parseAllowlist,
|
|
171
|
+
ScopeError
|
|
172
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scope-parser.js — file-first parser/validator for `.wize/security/scope.md`.
|
|
4
|
+
//
|
|
5
|
+
// Format (ADR-002):
|
|
6
|
+
// ---
|
|
7
|
+
// accepted_by: <string>
|
|
8
|
+
// accepted_at: <ISO-8601>
|
|
9
|
+
// scope_sha256: <hex SHA-256 of the body below>
|
|
10
|
+
// ---
|
|
11
|
+
//
|
|
12
|
+
// ## allowlist
|
|
13
|
+
// ...
|
|
14
|
+
//
|
|
15
|
+
// This module is the single source of truth for parsing + validating the
|
|
16
|
+
// scope. Skills that touch offensive tools must call loadScope() and abort
|
|
17
|
+
// on any ScopeError.
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const crypto = require('node:crypto');
|
|
21
|
+
|
|
22
|
+
class ScopeError extends Error {
|
|
23
|
+
constructor(code, field, message) {
|
|
24
|
+
super(message || `${code}${field ? ` (${field})` : ''}`);
|
|
25
|
+
this.name = 'ScopeError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.field = field || null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REQUIRED_FIELDS = ['accepted_by', 'accepted_at', 'scope_sha256'];
|
|
32
|
+
|
|
33
|
+
// Split a `scope.md` text into { frontmatter, body }.
|
|
34
|
+
// The frontmatter is the YAML block delimited by `---` lines at the very top.
|
|
35
|
+
// We do NOT use a YAML library (zero-dep); we accept a flat `key: value` shape
|
|
36
|
+
// only, which is the entire scope of this overlay's frontmatter (ADR-002).
|
|
37
|
+
function parseScope(mdText) {
|
|
38
|
+
if (typeof mdText !== 'string' || !mdText.startsWith('---\n') && mdText !== '---') {
|
|
39
|
+
throw new ScopeError('INVALID_FORMAT', null,
|
|
40
|
+
'scope.md must start with a YAML frontmatter block (--- on line 1)');
|
|
41
|
+
}
|
|
42
|
+
// Normalize: accept either "---\n...\n---\n\nbody" or "---\n...\n---\nbody".
|
|
43
|
+
// The trailing \n after the closing --- is part of the separator, NOT the body.
|
|
44
|
+
// This is what the user signs when they run --sign-scope (and what they
|
|
45
|
+
// expect when the hash is computed on the body they wrote).
|
|
46
|
+
const fmMatch = mdText.match(/^---\n([\s\S]*?)\n---\n/);
|
|
47
|
+
if (!fmMatch) {
|
|
48
|
+
throw new ScopeError('INVALID_FORMAT', null,
|
|
49
|
+
'scope.md frontmatter is missing or not terminated by a second --- line');
|
|
50
|
+
}
|
|
51
|
+
const fmText = fmMatch[1];
|
|
52
|
+
const body = mdText.slice(fmMatch[0].length);
|
|
53
|
+
|
|
54
|
+
const frontmatter = {};
|
|
55
|
+
for (const line of fmText.split('\n')) {
|
|
56
|
+
const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
frontmatter[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); // strip wrapping quotes
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { frontmatter, body };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Validate a parsed scope. Returns true on success; throws ScopeError otherwise.
|
|
65
|
+
// opts: { now?: Date } — for future `accepted_at` warning. Currently unused but
|
|
66
|
+
// reserved so we can add a warn() channel without breaking callers.
|
|
67
|
+
function validateScope(scope, opts = {}) {
|
|
68
|
+
if (!scope || typeof scope !== 'object') {
|
|
69
|
+
throw new ScopeError('INVALID_FORMAT', null, 'scope is not an object');
|
|
70
|
+
}
|
|
71
|
+
const fm = scope.frontmatter || {};
|
|
72
|
+
for (const field of REQUIRED_FIELDS) {
|
|
73
|
+
if (!fm[field] || typeof fm[field] !== 'string' || fm[field].trim() === '') {
|
|
74
|
+
throw new ScopeError('MISSING_FIELDS', field,
|
|
75
|
+
`scope.md frontmatter is missing required field "${field}" — ` +
|
|
76
|
+
`crie e assine com "wize-sec-pentest --sign-scope" ou preencha manualmente`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Hash integrity. Compute SHA-256 of the body and compare to scope_sha256.
|
|
81
|
+
// We re-parse the body as-is (already stripped of frontmatter) — the original
|
|
82
|
+
// signing was done on the raw body bytes, so byte-equality matters.
|
|
83
|
+
const expected = computeScopeSha256(scope.body || '');
|
|
84
|
+
if (expected !== fm.scope_sha256) {
|
|
85
|
+
throw new ScopeError('HASH_MISMATCH', 'scope_sha256',
|
|
86
|
+
`scope.md body was modified after acceptance (expected ${expected.slice(0, 12)}…, ` +
|
|
87
|
+
`got ${String(fm.scope_sha256).slice(0, 12)}…) — re-assine com "wize-sec-pentest --sign-scope"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Soft check: accepted_at in the future is a warning, not an error.
|
|
91
|
+
// We don't surface the warning through this function (no logger plumbed
|
|
92
|
+
// here); consumers can inspect frontmatter.accepted_at themselves.
|
|
93
|
+
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function computeScopeSha256(bodyText) {
|
|
98
|
+
return crypto.createHash('sha256').update(String(bodyText || ''), 'utf8').digest('hex');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read + parse + validate a scope.md file. The single entry point used by
|
|
102
|
+
// every offensive skill (AC-E02-1, AC-E02-3).
|
|
103
|
+
function loadScope(scopePath) {
|
|
104
|
+
if (!fs.existsSync(scopePath)) {
|
|
105
|
+
throw new ScopeError('MISSING_FILE', null,
|
|
106
|
+
`scope.md ausente em ${scopePath} — crie e assine em .wize/security/scope.md antes de rodar o pipeline`);
|
|
107
|
+
}
|
|
108
|
+
const text = fs.readFileSync(scopePath, 'utf8');
|
|
109
|
+
const scope = parseScope(text);
|
|
110
|
+
validateScope(scope);
|
|
111
|
+
return scope;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
parseScope,
|
|
116
|
+
validateScope,
|
|
117
|
+
computeScopeSha256,
|
|
118
|
+
loadScope,
|
|
119
|
+
ScopeError
|
|
120
|
+
};
|