jaku.sh 1.0.2 → 1.2.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/README.md +161 -18
- package/action.yml +32 -1
- package/package.json +2 -1
- package/src/agents/ai-agent.js +47 -1
- package/src/agents/api-agent.js +9 -0
- package/src/agents/logic-agent.js +158 -90
- package/src/agents/orchestrator.js +56 -1
- package/src/agents/security-agent.js +86 -54
- package/src/cli.js +68 -6
- package/src/core/ai/ai-endpoint-detector.js +28 -4
- package/src/core/ai/prompt-injector.js +34 -0
- package/src/core/api/api-key-auditor.js +1 -1
- package/src/core/api/cors-ws-tester.js +1 -1
- package/src/core/crawler.js +22 -1
- package/src/core/llm/augmentations.js +210 -0
- package/src/core/llm/llm-client.js +184 -0
- package/src/core/llm/providers/anthropic-provider.js +46 -0
- package/src/core/llm/providers/base-provider.js +44 -0
- package/src/core/llm/providers/null-provider.js +21 -0
- package/src/core/llm/providers/openai-provider.js +47 -0
- package/src/core/logic/access-boundary-tester.js +1 -1
- package/src/core/logic/business-rule-inferrer.js +50 -1
- package/src/core/security/sqli-prober.js +312 -43
- package/src/core/security/xss-scanner.js +26 -2
- package/src/reporting/report-generator.js +96 -9
- package/src/reporting/sarif-generator.js +81 -5
- package/src/utils/config.js +196 -2
- package/src/utils/finding.js +3 -0
- package/src/utils/logger.js +33 -0
- package/src/utils/param-discovery.js +93 -0
- package/src/utils/safety.js +44 -0
- package/src/utils/version.js +30 -0
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
2
|
import { createFinding } from '../../utils/finding.js';
|
|
3
|
+
import { collectParamNames } from '../../utils/param-discovery.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* SQLi Prober — Tests query-bearing inputs for SQL/NoSQL injection vulnerabilities.
|
|
6
7
|
* SAFETY: Simulation only — no destructive payloads (DROP, DELETE, etc.) are ever sent.
|
|
8
|
+
*
|
|
9
|
+
* Detection strategies:
|
|
10
|
+
* 1. Error-based — diagnostic payloads that surface DB error signatures
|
|
11
|
+
* 2. Boolean blind — compare TRUE vs FALSE condition responses
|
|
12
|
+
* 3. Time blind — measure response delay for a sleep payload
|
|
7
13
|
*/
|
|
8
14
|
export class SQLiProber {
|
|
9
15
|
constructor(logger) {
|
|
10
16
|
this.logger = logger;
|
|
11
17
|
this.findings = [];
|
|
18
|
+
this._candidateParams = [];
|
|
19
|
+
// Budget for expensive time-based probes (each adds ~5s of delay).
|
|
20
|
+
this._timeBudget = 12;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
// SQL injection test payloads — detection-only, non-destructive
|
|
@@ -17,12 +26,33 @@ export class SQLiProber {
|
|
|
17
26
|
{ name: 'Classic OR', payload: "' OR '1'='1", errorPatterns: ['sql', 'syntax', 'query'] },
|
|
18
27
|
{ name: 'Double dash comment', payload: "' -- ", errorPatterns: ['sql', 'syntax'] },
|
|
19
28
|
{ name: 'UNION probe', payload: "' UNION SELECT NULL--", errorPatterns: ['union', 'select', 'column'] },
|
|
20
|
-
{ name: 'Boolean blind true', payload: "' AND '1'='1", errorPatterns: [] },
|
|
21
|
-
{ name: 'Boolean blind false', payload: "' AND '1'='2", errorPatterns: [] },
|
|
22
29
|
{ name: 'Numeric injection', payload: '1 OR 1=1', errorPatterns: ['sql', 'syntax'] },
|
|
23
30
|
{ name: 'Stacked query probe', payload: "'; SELECT 1--", errorPatterns: ['syntax', 'multiple'] },
|
|
24
31
|
];
|
|
25
32
|
|
|
33
|
+
// Fallback guess-list of common query parameter names, used to AUGMENT
|
|
34
|
+
// (never replace) parameters discovered from the actual surface.
|
|
35
|
+
static FALLBACK_PARAMS = [
|
|
36
|
+
'id', 'user', 'user_id', 'uid', 'page', 'item', 'product', 'product_id',
|
|
37
|
+
'category', 'cat', 'q', 'search', 'query', 'name', 'order', 'sort', 'filter',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Boolean-based blind pairs (logically-true vs logically-false conditions).
|
|
41
|
+
static BOOLEAN_PAIRS = [
|
|
42
|
+
{ context: 'string-and', truePayload: "' AND '1'='1", falsePayload: "' AND '1'='2" },
|
|
43
|
+
{ context: 'numeric-and', truePayload: ' AND 1=1', falsePayload: ' AND 1=2' },
|
|
44
|
+
{ context: 'string-or', truePayload: "' OR '1'='1' -- ", falsePayload: "' OR '1'='2' -- " },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Time-based blind payloads (5s sleep across common DB engines).
|
|
48
|
+
static SLEEP_SECONDS = 5;
|
|
49
|
+
static TIME_PAYLOADS = [
|
|
50
|
+
{ db: 'MySQL (string)', payload: "' AND SLEEP(5)-- -" },
|
|
51
|
+
{ db: 'MySQL (numeric)', payload: ' AND SLEEP(5)-- -' },
|
|
52
|
+
{ db: 'PostgreSQL', payload: "'; SELECT pg_sleep(5)-- -" },
|
|
53
|
+
{ db: 'MSSQL', payload: "'; WAITFOR DELAY '0:0:5'-- -" },
|
|
54
|
+
];
|
|
55
|
+
|
|
26
56
|
// NoSQL injection payloads for JSON bodies
|
|
27
57
|
static NOSQL_PAYLOADS = [
|
|
28
58
|
{ name: 'NoSQL $gt operator', payload: { '$gt': '' }, desc: 'MongoDB greater-than operator' },
|
|
@@ -56,6 +86,17 @@ export class SQLiProber {
|
|
|
56
86
|
* Run SQL injection probing on all discovered surfaces.
|
|
57
87
|
*/
|
|
58
88
|
async probe(surfaceInventory) {
|
|
89
|
+
// Derive real candidate params from forms, query strings, and API URLs,
|
|
90
|
+
// then augment with the fallback guess-list (discovered take priority).
|
|
91
|
+
const discovered = collectParamNames(surfaceInventory);
|
|
92
|
+
this._candidateParams = [
|
|
93
|
+
...discovered,
|
|
94
|
+
...SQLiProber.FALLBACK_PARAMS.filter(p => !discovered.includes(p)),
|
|
95
|
+
];
|
|
96
|
+
this.logger?.debug?.(
|
|
97
|
+
`SQLi prober: ${discovered.length} discovered params + ${SQLiProber.FALLBACK_PARAMS.length} fallback`
|
|
98
|
+
);
|
|
99
|
+
|
|
59
100
|
// Test URL parameters
|
|
60
101
|
await this._testURLParams(surfaceInventory);
|
|
61
102
|
|
|
@@ -70,57 +111,285 @@ export class SQLiProber {
|
|
|
70
111
|
}
|
|
71
112
|
|
|
72
113
|
/**
|
|
73
|
-
* Test URL query parameters for SQL injection.
|
|
114
|
+
* Test URL query parameters for SQL injection (error, boolean, and time blind).
|
|
74
115
|
*/
|
|
75
116
|
async _testURLParams(inventory) {
|
|
76
117
|
for (const page of inventory.pages) {
|
|
77
118
|
if (typeof page.status !== 'number') continue;
|
|
78
119
|
|
|
120
|
+
let parsedUrl;
|
|
79
121
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
parsedUrl = new URL(page.url);
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Params present on this URL + discovered candidates (capped).
|
|
128
|
+
const existing = [...parsedUrl.searchParams.keys()];
|
|
129
|
+
const paramsToTest = [...new Set([...existing, ...this._candidateParams])].slice(0, 30);
|
|
130
|
+
if (paramsToTest.length === 0) continue;
|
|
131
|
+
|
|
132
|
+
for (const param of paramsToTest) {
|
|
133
|
+
// 1. Error-based detection
|
|
134
|
+
const errorFinding = await this._errorBasedTest(page.url, param);
|
|
135
|
+
if (errorFinding) {
|
|
136
|
+
this.findings.push(errorFinding);
|
|
137
|
+
continue; // confirmed — no need for blind tests on this param
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2. Boolean-based blind detection
|
|
141
|
+
const boolFinding = await this._booleanBlindTest(page.url, param);
|
|
142
|
+
if (boolFinding) {
|
|
143
|
+
this.findings.push(boolFinding);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Time-based blind detection (budgeted — each adds ~5s)
|
|
148
|
+
if (this._timeBudget > 0) {
|
|
149
|
+
const timeFinding = await this._timeBlindTest(page.url, param);
|
|
150
|
+
if (timeFinding) this.findings.push(timeFinding);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Error-based detection: inject diagnostic payloads and look for DB errors.
|
|
158
|
+
*/
|
|
159
|
+
async _errorBasedTest(baseUrl, param) {
|
|
160
|
+
for (const { name, payload } of SQLiProber.SQL_PAYLOADS.slice(0, 4)) {
|
|
161
|
+
let testUrl;
|
|
162
|
+
try {
|
|
163
|
+
testUrl = new URL(baseUrl);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
testUrl.searchParams.set(param, payload);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const resp = await fetch(testUrl.toString(), {
|
|
171
|
+
signal: AbortSignal.timeout(10000),
|
|
172
|
+
redirect: 'follow',
|
|
173
|
+
});
|
|
174
|
+
const body = await resp.text();
|
|
175
|
+
const errorMatch = this._detectSQLError(body);
|
|
176
|
+
if (errorMatch) {
|
|
177
|
+
return createFinding({
|
|
178
|
+
module: 'security',
|
|
179
|
+
title: `SQL Injection: ${param} parameter (${name})`,
|
|
180
|
+
severity: 'critical',
|
|
181
|
+
affected_surface: baseUrl,
|
|
182
|
+
description: `The URL parameter "${param}" appears vulnerable to SQL injection. The "${name}" payload triggered a database error in the response, indicating unsanitized input is being passed directly to SQL queries.\n\nError signature: ${errorMatch}`,
|
|
183
|
+
reproduction: [
|
|
184
|
+
`1. Navigate to: ${testUrl.toString()}`,
|
|
185
|
+
`2. Observe database error message in the response`,
|
|
186
|
+
`3. Error signature: ${errorMatch}`,
|
|
187
|
+
],
|
|
188
|
+
evidence: JSON.stringify({ param, payload: name, errorSignature: errorMatch, responseSnippet: body.substring(0, 300) }),
|
|
189
|
+
remediation: 'Use parameterized queries (prepared statements) for all database operations. Never concatenate user input into SQL strings. Implement input validation and WAF rules.',
|
|
190
|
+
references: ['https://owasp.org/www-community/attacks/SQL_Injection', 'CWE-89'],
|
|
191
|
+
});
|
|
119
192
|
}
|
|
120
193
|
} catch {
|
|
121
|
-
//
|
|
194
|
+
// Request failed — try next payload
|
|
122
195
|
}
|
|
123
196
|
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Boolean-based blind detection: compare a logically-TRUE condition response
|
|
202
|
+
* against a logically-FALSE one. If TRUE behaves like the baseline while
|
|
203
|
+
* FALSE diverges, the input is being evaluated inside a SQL query.
|
|
204
|
+
*/
|
|
205
|
+
async _booleanBlindTest(baseUrl, param) {
|
|
206
|
+
const baseValue = this._baseValueFor(baseUrl, param);
|
|
207
|
+
|
|
208
|
+
const baseline = await this._fetchVariant(baseUrl, param, baseValue);
|
|
209
|
+
if (!baseline) return null;
|
|
210
|
+
|
|
211
|
+
for (const pair of SQLiProber.BOOLEAN_PAIRS) {
|
|
212
|
+
const trueResp = await this._fetchVariant(baseUrl, param, baseValue + pair.truePayload);
|
|
213
|
+
const falseResp = await this._fetchVariant(baseUrl, param, baseValue + pair.falsePayload);
|
|
214
|
+
if (!trueResp || !falseResp) continue;
|
|
215
|
+
|
|
216
|
+
// Skip if either variant produced a hard error page (covered elsewhere).
|
|
217
|
+
const simTrueBase = this._similarity(trueResp, baseline);
|
|
218
|
+
const simTrueFalse = this._similarity(trueResp, falseResp);
|
|
219
|
+
|
|
220
|
+
const statusDivergence = trueResp.status !== falseResp.status;
|
|
221
|
+
|
|
222
|
+
// TRUE ≈ baseline, but TRUE clearly differs from FALSE → boolean blind.
|
|
223
|
+
const booleanSignal =
|
|
224
|
+
(simTrueBase >= 0.95 && simTrueFalse <= 0.85) || statusDivergence;
|
|
225
|
+
|
|
226
|
+
if (booleanSignal) {
|
|
227
|
+
// Confirm by repeating once to reduce false positives from jitter.
|
|
228
|
+
const trueResp2 = await this._fetchVariant(baseUrl, param, baseValue + pair.truePayload);
|
|
229
|
+
const falseResp2 = await this._fetchVariant(baseUrl, param, baseValue + pair.falsePayload);
|
|
230
|
+
const confirmed = trueResp2 && falseResp2 &&
|
|
231
|
+
((this._similarity(trueResp2, baseline) >= 0.95 && this._similarity(trueResp2, falseResp2) <= 0.85) ||
|
|
232
|
+
trueResp2.status !== falseResp2.status);
|
|
233
|
+
|
|
234
|
+
if (!confirmed) continue;
|
|
235
|
+
|
|
236
|
+
return createFinding({
|
|
237
|
+
module: 'security',
|
|
238
|
+
title: `Blind SQL Injection (Boolean): ${param} parameter`,
|
|
239
|
+
severity: 'critical',
|
|
240
|
+
affected_surface: baseUrl,
|
|
241
|
+
description: `The URL parameter "${param}" appears vulnerable to boolean-based blind SQL injection. A logically-true condition (${pair.truePayload}) returned a response matching the baseline, while a logically-false condition (${pair.falsePayload}) produced a measurably different response — indicating the input is evaluated within a SQL query even though no error is shown.`,
|
|
242
|
+
reproduction: [
|
|
243
|
+
`1. Request with ${param}=${baseValue}${pair.truePayload} (TRUE condition)`,
|
|
244
|
+
`2. Request with ${param}=${baseValue}${pair.falsePayload} (FALSE condition)`,
|
|
245
|
+
`3. Compare responses — TRUE matches baseline, FALSE diverges`,
|
|
246
|
+
],
|
|
247
|
+
evidence: JSON.stringify({
|
|
248
|
+
param,
|
|
249
|
+
context: pair.context,
|
|
250
|
+
truePayload: pair.truePayload,
|
|
251
|
+
falsePayload: pair.falsePayload,
|
|
252
|
+
simTrueVsBaseline: Number(simTrueBase.toFixed(3)),
|
|
253
|
+
simTrueVsFalse: Number(simTrueFalse.toFixed(3)),
|
|
254
|
+
trueStatus: trueResp.status,
|
|
255
|
+
falseStatus: falseResp.status,
|
|
256
|
+
}),
|
|
257
|
+
remediation: 'Use parameterized queries (prepared statements). Boolean-based blind SQLi is exploitable even without visible errors — apply strict input validation and least-privilege DB accounts.',
|
|
258
|
+
references: ['https://owasp.org/www-community/attacks/Blind_SQL_Injection', 'CWE-89'],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Time-based blind detection: inject a sleep payload and measure the delay.
|
|
267
|
+
*/
|
|
268
|
+
async _timeBlindTest(baseUrl, param) {
|
|
269
|
+
const baseValue = this._baseValueFor(baseUrl, param);
|
|
270
|
+
|
|
271
|
+
// Establish a control latency (fastest of two benign requests).
|
|
272
|
+
const c1 = await this._timeVariant(baseUrl, param, baseValue);
|
|
273
|
+
const c2 = await this._timeVariant(baseUrl, param, baseValue);
|
|
274
|
+
if (c1 === null && c2 === null) return null;
|
|
275
|
+
const control = Math.min(...[c1, c2].filter(t => t !== null));
|
|
276
|
+
|
|
277
|
+
const sleepMs = SQLiProber.SLEEP_SECONDS * 1000;
|
|
278
|
+
const threshold = control + sleepMs - 1500; // allow ~1.5s slack
|
|
279
|
+
|
|
280
|
+
for (const { db, payload } of SQLiProber.TIME_PAYLOADS) {
|
|
281
|
+
if (this._timeBudget <= 0) break;
|
|
282
|
+
this._timeBudget--;
|
|
283
|
+
|
|
284
|
+
const delayed = await this._timeVariant(baseUrl, param, baseValue + payload);
|
|
285
|
+
if (delayed === null) continue;
|
|
286
|
+
|
|
287
|
+
if (delayed >= threshold) {
|
|
288
|
+
// Confirm once more to rule out a transient slow response.
|
|
289
|
+
const confirm = await this._timeVariant(baseUrl, param, baseValue + payload);
|
|
290
|
+
if (confirm === null || confirm < threshold) continue;
|
|
291
|
+
|
|
292
|
+
return createFinding({
|
|
293
|
+
module: 'security',
|
|
294
|
+
title: `Blind SQL Injection (Time-based): ${param} parameter`,
|
|
295
|
+
severity: 'critical',
|
|
296
|
+
affected_surface: baseUrl,
|
|
297
|
+
description: `The URL parameter "${param}" appears vulnerable to time-based blind SQL injection. A ${db} sleep payload caused the response to be delayed by ~${SQLiProber.SLEEP_SECONDS}s relative to a ${(control / 1000).toFixed(1)}s control, indicating the injected SQL was executed.`,
|
|
298
|
+
reproduction: [
|
|
299
|
+
`1. Baseline request with ${param}=${baseValue} (~${(control / 1000).toFixed(1)}s)`,
|
|
300
|
+
`2. Inject ${param}=${baseValue}${payload}`,
|
|
301
|
+
`3. Response is delayed by ~${SQLiProber.SLEEP_SECONDS}s (measured ${(delayed / 1000).toFixed(1)}s)`,
|
|
302
|
+
],
|
|
303
|
+
evidence: JSON.stringify({
|
|
304
|
+
param,
|
|
305
|
+
engine: db,
|
|
306
|
+
payload,
|
|
307
|
+
controlMs: control,
|
|
308
|
+
delayedMs: delayed,
|
|
309
|
+
thresholdMs: threshold,
|
|
310
|
+
}),
|
|
311
|
+
remediation: 'Use parameterized queries (prepared statements). Time-based blind SQLi confirms code execution in the database — enforce input validation, query timeouts, and least-privilege DB accounts.',
|
|
312
|
+
references: ['https://owasp.org/www-community/attacks/Blind_SQL_Injection', 'CWE-89'],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Choose a base value to graft payloads onto: the existing param value if
|
|
321
|
+
* present, otherwise a benign numeric default.
|
|
322
|
+
*/
|
|
323
|
+
_baseValueFor(baseUrl, param) {
|
|
324
|
+
try {
|
|
325
|
+
const u = new URL(baseUrl);
|
|
326
|
+
const v = u.searchParams.get(param);
|
|
327
|
+
if (v && v.length > 0) return v;
|
|
328
|
+
} catch {
|
|
329
|
+
/* ignore */
|
|
330
|
+
}
|
|
331
|
+
return '1';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Fetch a URL variant with `param` set to `value`. Returns { status, body }.
|
|
336
|
+
*/
|
|
337
|
+
async _fetchVariant(baseUrl, param, value) {
|
|
338
|
+
let testUrl;
|
|
339
|
+
try {
|
|
340
|
+
testUrl = new URL(baseUrl);
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
testUrl.searchParams.set(param, value);
|
|
345
|
+
try {
|
|
346
|
+
const resp = await fetch(testUrl.toString(), {
|
|
347
|
+
signal: AbortSignal.timeout(10000),
|
|
348
|
+
redirect: 'follow',
|
|
349
|
+
});
|
|
350
|
+
const body = await resp.text();
|
|
351
|
+
return { status: resp.status, body };
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Measure the round-trip time (ms) for a URL variant. Returns null on error.
|
|
359
|
+
*/
|
|
360
|
+
async _timeVariant(baseUrl, param, value) {
|
|
361
|
+
let testUrl;
|
|
362
|
+
try {
|
|
363
|
+
testUrl = new URL(baseUrl);
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
testUrl.searchParams.set(param, value);
|
|
368
|
+
const start = Date.now();
|
|
369
|
+
try {
|
|
370
|
+
const resp = await fetch(testUrl.toString(), {
|
|
371
|
+
// Generous timeout so the sleep payload can complete.
|
|
372
|
+
signal: AbortSignal.timeout((SQLiProber.SLEEP_SECONDS + 8) * 1000),
|
|
373
|
+
redirect: 'follow',
|
|
374
|
+
});
|
|
375
|
+
await resp.text();
|
|
376
|
+
return Date.now() - start;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Similarity between two responses based on status + body length.
|
|
384
|
+
* Returns 0..1 (1 = identical-ish).
|
|
385
|
+
*/
|
|
386
|
+
_similarity(a, b) {
|
|
387
|
+
if (!a || !b) return 0;
|
|
388
|
+
if (a.status !== b.status) return 0;
|
|
389
|
+
const la = a.body?.length || 0;
|
|
390
|
+
const lb = b.body?.length || 0;
|
|
391
|
+
if (la === 0 && lb === 0) return 1;
|
|
392
|
+
return 1 - Math.abs(la - lb) / Math.max(la, lb, 1);
|
|
124
393
|
}
|
|
125
394
|
|
|
126
395
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
2
|
import { createFinding } from '../../utils/finding.js';
|
|
3
|
+
import { collectParamNames } from '../../utils/param-discovery.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* XSS Scanner — Probes all discovered input surfaces for Cross-Site Scripting.
|
|
@@ -10,8 +11,16 @@ export class XSSScanner {
|
|
|
10
11
|
constructor(logger) {
|
|
11
12
|
this.logger = logger;
|
|
12
13
|
this.findings = [];
|
|
14
|
+
this._candidateParams = [];
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
// Fallback guess-list of common reflected-input parameter names. Used to
|
|
18
|
+
// augment (never replace) parameters discovered from the actual surface.
|
|
19
|
+
static FALLBACK_PARAMS = [
|
|
20
|
+
'q', 'search', 'query', 'keyword', 's', 'term', 'name', 'id', 'page',
|
|
21
|
+
'redirect', 'url', 'return', 'next', 'callback',
|
|
22
|
+
];
|
|
23
|
+
|
|
15
24
|
// XSS test payloads — designed for detection, not exploitation
|
|
16
25
|
static PAYLOADS = [
|
|
17
26
|
{ name: 'Basic script tag', payload: '<script>window.__JAKU_XSS_1=1</script>', marker: '__JAKU_XSS_1' },
|
|
@@ -29,6 +38,19 @@ export class XSSScanner {
|
|
|
29
38
|
* Run XSS scanning on all discovered surfaces.
|
|
30
39
|
*/
|
|
31
40
|
async scan(surfaceInventory) {
|
|
41
|
+
// Derive real candidate parameters from forms, query strings on
|
|
42
|
+
// discovered links/pages, and API endpoint URLs — then augment with the
|
|
43
|
+
// fallback guess-list (discovered params take priority).
|
|
44
|
+
const discovered = collectParamNames(surfaceInventory);
|
|
45
|
+
this._candidateParams = [
|
|
46
|
+
...discovered,
|
|
47
|
+
...XSSScanner.FALLBACK_PARAMS.filter(p => !discovered.includes(p)),
|
|
48
|
+
].slice(0, 60);
|
|
49
|
+
this.logger?.debug?.(
|
|
50
|
+
`XSS scanner: ${discovered.length} discovered params + ${XSSScanner.FALLBACK_PARAMS.length} fallback ` +
|
|
51
|
+
`→ ${this._candidateParams.length} candidates`
|
|
52
|
+
);
|
|
53
|
+
|
|
32
54
|
const browser = await chromium.launch({ headless: true });
|
|
33
55
|
const context = await browser.newContext({
|
|
34
56
|
viewport: { width: 1440, height: 900 },
|
|
@@ -55,8 +77,10 @@ export class XSSScanner {
|
|
|
55
77
|
|
|
56
78
|
const page = await context.newPage();
|
|
57
79
|
try {
|
|
58
|
-
//
|
|
59
|
-
const testParams =
|
|
80
|
+
// Candidate params = discovered (forms/query/api) + fallback guesses
|
|
81
|
+
const testParams = this._candidateParams.length > 0
|
|
82
|
+
? this._candidateParams
|
|
83
|
+
: XSSScanner.FALLBACK_PARAMS;
|
|
60
84
|
|
|
61
85
|
for (const param of testParams) {
|
|
62
86
|
// Use a subset of payloads for URL params
|
|
@@ -3,20 +3,40 @@ import path from 'path';
|
|
|
3
3
|
import { sortFindings, filterBySeverity, severitySummary } from '../utils/finding.js';
|
|
4
4
|
import { writeSARIF } from './sarif-generator.js';
|
|
5
5
|
import { DiffReporter } from './diff-reporter.js';
|
|
6
|
+
import { getVersion } from '../utils/version.js';
|
|
7
|
+
import { enhanceRemediation, generateExecutiveSummary } from '../core/llm/augmentations.js';
|
|
8
|
+
|
|
9
|
+
/** Friendly labels for module keys, used in report headings. */
|
|
10
|
+
const MODULE_LABELS = {
|
|
11
|
+
qa: 'QA & Functional Testing',
|
|
12
|
+
security: 'Security Vulnerability Scanning',
|
|
13
|
+
ai: 'Prompt Injection & AI Abuse',
|
|
14
|
+
logic: 'Business Logic Validation',
|
|
15
|
+
api: 'API & Auth Flow Verification',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Build a human-readable label from a list of executed module keys. */
|
|
19
|
+
function formatModules(modules) {
|
|
20
|
+
if (!Array.isArray(modules) || modules.length === 0) return 'Security & Quality';
|
|
21
|
+
return modules.map(m => MODULE_LABELS[m] || m).join(', ');
|
|
22
|
+
}
|
|
6
23
|
|
|
7
24
|
/**
|
|
8
25
|
* Report Generator — Generates structured output in JSON, Markdown, HTML, and SARIF formats.
|
|
9
26
|
*/
|
|
10
27
|
export class ReportGenerator {
|
|
11
|
-
constructor(config, logger) {
|
|
28
|
+
constructor(config, logger, llmClient = null) {
|
|
12
29
|
this.config = config;
|
|
13
30
|
this.logger = logger;
|
|
31
|
+
// Optional LLM client. When null/disabled, all augmentation is skipped and
|
|
32
|
+
// reports render exactly as before (template remediation, no AI summary).
|
|
33
|
+
this.llmClient = llmClient;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
36
|
/**
|
|
17
37
|
* Generate all reports from findings and test results.
|
|
18
38
|
*/
|
|
19
|
-
async generate({ findings, deduplicated, dedupStats, testSummary, surfaceInventory, outputDir }) {
|
|
39
|
+
async generate({ findings, deduplicated, dedupStats, testSummary, surfaceInventory, outputDir, modules }) {
|
|
20
40
|
const reportDir = outputDir || path.join(process.cwd(), 'jaku-reports', this._timestamp());
|
|
21
41
|
if (!fs.existsSync(reportDir)) {
|
|
22
42
|
fs.mkdirSync(reportDir, { recursive: true });
|
|
@@ -35,11 +55,17 @@ export class ReportGenerator {
|
|
|
35
55
|
const summary = severitySummary(filteredFindings);
|
|
36
56
|
const dedupSummary = severitySummary(reportFindings);
|
|
37
57
|
|
|
58
|
+
// Resolve which modules actually ran: explicit arg → config → enabled list.
|
|
59
|
+
const executedModules = (modules && modules.length > 0)
|
|
60
|
+
? modules
|
|
61
|
+
: (this.config.modules_enabled || []);
|
|
62
|
+
|
|
38
63
|
const reportData = {
|
|
39
64
|
meta: {
|
|
40
65
|
agent: 'JAKU',
|
|
41
|
-
version:
|
|
42
|
-
|
|
66
|
+
version: getVersion(),
|
|
67
|
+
modules: executedModules,
|
|
68
|
+
modulesLabel: formatModules(executedModules),
|
|
43
69
|
target: this.config.target_url,
|
|
44
70
|
scannedAt: new Date().toISOString(),
|
|
45
71
|
duration: testSummary?.duration || null,
|
|
@@ -57,6 +83,10 @@ export class ReportGenerator {
|
|
|
57
83
|
rawFindings: filteredFindings,
|
|
58
84
|
};
|
|
59
85
|
|
|
86
|
+
// Optional, additive LLM augmentation (remediation + executive summary).
|
|
87
|
+
// No-op when the client is disabled; reports render unchanged on null.
|
|
88
|
+
await this._augmentWithLLM(reportFindings, reportData);
|
|
89
|
+
|
|
60
90
|
// Generate JSON
|
|
61
91
|
const jsonPath = path.join(reportDir, 'report.json');
|
|
62
92
|
fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2), 'utf-8');
|
|
@@ -82,18 +112,60 @@ export class ReportGenerator {
|
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Phase 0 + Phase 2 LLM augmentation. STRICTLY ADDITIVE and best-effort:
|
|
117
|
+
* - Per-finding remediation (falls back to template when LLM returns null).
|
|
118
|
+
* - One executive-summary paragraph (omitted when null).
|
|
119
|
+
* Tags LLM-derived content so the report can label it.
|
|
120
|
+
*/
|
|
121
|
+
async _augmentWithLLM(findings, reportData) {
|
|
122
|
+
const client = this.llmClient;
|
|
123
|
+
if (!client?.isEnabled?.()) return;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Remediation: only top findings to respect call/token budget.
|
|
127
|
+
const prioritized = findings
|
|
128
|
+
.filter(f => ['critical', 'high', 'medium'].includes(f.severity))
|
|
129
|
+
.slice(0, 20);
|
|
130
|
+
for (const f of prioritized) {
|
|
131
|
+
const text = await enhanceRemediation(client, f);
|
|
132
|
+
if (text) {
|
|
133
|
+
f.remediation_llm = text;
|
|
134
|
+
f.remediation_source = 'llm';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Executive summary (titles + counts only — data minimization).
|
|
139
|
+
const summaryText = await generateExecutiveSummary(client, {
|
|
140
|
+
target: reportData.meta.target,
|
|
141
|
+
summary: reportData.dedupSummary || reportData.summary,
|
|
142
|
+
topTitles: findings.slice(0, 15).map(f => `[${f.severity}] ${f.title}`),
|
|
143
|
+
});
|
|
144
|
+
if (summaryText) {
|
|
145
|
+
reportData.meta.executiveSummary = summaryText;
|
|
146
|
+
reportData.meta.executiveSummarySource = 'llm';
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
this.logger?.debug?.(`[LLM] report augmentation skipped: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
85
153
|
_generateMarkdown(data) {
|
|
86
154
|
const { meta, summary, testSummary, surfaceInventory, findings } = data;
|
|
87
155
|
let md = '';
|
|
88
156
|
|
|
89
157
|
md += `# 呪 JAKU Security & Quality Report\n\n`;
|
|
90
158
|
md += `**Target:** ${meta.target} \n`;
|
|
91
|
-
md += `**
|
|
159
|
+
md += `**Modules:** ${meta.modulesLabel || formatModules(meta.modules)} \n`;
|
|
92
160
|
md += `**Scanned:** ${meta.scannedAt} \n`;
|
|
93
161
|
md += `**Agent Version:** ${meta.version} \n\n`;
|
|
94
162
|
|
|
95
163
|
md += `---\n\n`;
|
|
96
164
|
md += `## Executive Summary\n\n`;
|
|
165
|
+
if (meta.executiveSummary) {
|
|
166
|
+
md += `${meta.executiveSummary}\n\n`;
|
|
167
|
+
md += `*(AI-assisted summary — advisory only)*\n\n`;
|
|
168
|
+
}
|
|
97
169
|
md += `| Metric | Value |\n`;
|
|
98
170
|
md += `|--------|-------|\n`;
|
|
99
171
|
md += `| Total Findings | ${summary.total} |\n`;
|
|
@@ -151,8 +223,15 @@ export class ReportGenerator {
|
|
|
151
223
|
md += `\n`;
|
|
152
224
|
}
|
|
153
225
|
|
|
154
|
-
|
|
155
|
-
|
|
226
|
+
const remediation = f.remediation_llm || f.remediation;
|
|
227
|
+
if (remediation) {
|
|
228
|
+
const tag = f.remediation_llm ? ' (AI-assisted)' : '';
|
|
229
|
+
md += `**Remediation${tag}:** ${remediation}\n\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (f.llm_triage) {
|
|
233
|
+
const conf = f.llm_triage.confidence != null ? ` (confidence ${(f.llm_triage.confidence * 100).toFixed(0)}%)` : '';
|
|
234
|
+
md += `**AI triage (advisory):** ${f.llm_triage.assessment}${conf}${f.llm_triage.note ? ` — ${f.llm_triage.note}` : ''}\n\n`;
|
|
156
235
|
}
|
|
157
236
|
|
|
158
237
|
md += `---\n\n`;
|
|
@@ -256,10 +335,17 @@ export class ReportGenerator {
|
|
|
256
335
|
<h1>呪 JAKU</h1>
|
|
257
336
|
<div class="meta">
|
|
258
337
|
<span>Target: ${meta.target}</span>
|
|
259
|
-
<span>
|
|
338
|
+
<span>Modules: ${this._escapeHtml(meta.modulesLabel || formatModules(meta.modules))}</span>
|
|
260
339
|
<span>Scanned: ${new Date(meta.scannedAt).toLocaleString()}</span>
|
|
261
340
|
</div>
|
|
262
341
|
|
|
342
|
+
${meta.executiveSummary ? `
|
|
343
|
+
<h2>Executive Summary</h2>
|
|
344
|
+
<div class="finding-card" style="border-left-color:var(--accent)">
|
|
345
|
+
<div class="finding-desc">${this._escapeHtml(meta.executiveSummary)}</div>
|
|
346
|
+
<div style="font-size:0.7rem;color:var(--text-dim);margin-top:0.5rem">AI-assisted summary — advisory only</div>
|
|
347
|
+
</div>` : ''}
|
|
348
|
+
|
|
263
349
|
<h2>Severity Breakdown</h2>
|
|
264
350
|
<div class="summary-grid">
|
|
265
351
|
<div class="summary-card sev-critical"><div class="count">${summary.critical}</div><div class="label">Critical</div></div>
|
|
@@ -314,7 +400,8 @@ export class ReportGenerator {
|
|
|
314
400
|
<strong>Affected:</strong> ${this._escapeHtml(f.affected_surface)}
|
|
315
401
|
${f.owasp ? `<span style="margin-left:1rem;padding:2px 6px;border-radius:3px;background:#1a1a25;color:#00ff88;font-size:0.7rem;font-weight:bold">${f.owasp.id} ${this._escapeHtml(f.owasp.name)}</span>` : ''}
|
|
316
402
|
</div>
|
|
317
|
-
${f.remediation ? `<div style="font-size:0.85rem;margin-top:0.5rem;color:var(--accent)"><strong>Fix:</strong> ${this._escapeHtml(f.remediation)}</div>` : ''}
|
|
403
|
+
${(f.remediation_llm || f.remediation) ? `<div style="font-size:0.85rem;margin-top:0.5rem;color:var(--accent)"><strong>Fix${f.remediation_llm ? ' (AI-assisted)' : ''}:</strong> ${this._escapeHtml(f.remediation_llm || f.remediation)}</div>` : ''}
|
|
404
|
+
${f.llm_triage ? `<div style="font-size:0.8rem;margin-top:0.5rem;color:var(--text-dim)"><strong>AI triage (advisory):</strong> ${this._escapeHtml(f.llm_triage.assessment)}${f.llm_triage.confidence != null ? ` (${(f.llm_triage.confidence * 100).toFixed(0)}%)` : ''}${f.llm_triage.note ? ` — ${this._escapeHtml(f.llm_triage.note)}` : ''}</div>` : ''}
|
|
318
405
|
<details class="finding-details">
|
|
319
406
|
<summary>Evidence & Reproduction</summary>
|
|
320
407
|
<pre>${this._escapeHtml(f.reproduction?.join?.('\\n') || '')}</pre>
|