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.
@@ -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
- const parsedUrl = new URL(page.url);
81
- const params = [...parsedUrl.searchParams.keys()];
82
- if (params.length === 0) continue;
83
-
84
- for (const param of params) {
85
- for (const { name, payload } of SQLiProber.SQL_PAYLOADS.slice(0, 4)) {
86
- const testUrl = new URL(page.url);
87
- testUrl.searchParams.set(param, payload);
88
-
89
- try {
90
- const resp = await fetch(testUrl.toString(), {
91
- signal: AbortSignal.timeout(10000),
92
- redirect: 'follow',
93
- });
94
- const body = await resp.text();
95
-
96
- const errorMatch = this._detectSQLError(body);
97
- if (errorMatch) {
98
- this.findings.push(createFinding({
99
- module: 'security',
100
- title: `SQL Injection: ${param} parameter (${name})`,
101
- severity: 'critical',
102
- affected_surface: page.url,
103
- 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}`,
104
- reproduction: [
105
- `1. Navigate to: ${testUrl.toString()}`,
106
- `2. Observe database error message in the response`,
107
- `3. Error signature: ${errorMatch}`,
108
- ],
109
- evidence: JSON.stringify({ param, payload: name, errorSignature: errorMatch, responseSnippet: body.substring(0, 300) }),
110
- remediation: 'Use parameterized queries (prepared statements) for all database operations. Never concatenate user input into SQL strings. Implement input validation and WAF rules.',
111
- references: ['https://owasp.org/www-community/attacks/SQL_Injection', 'CWE-89'],
112
- }));
113
- break; // One finding per param
114
- }
115
- } catch {
116
- // Request failed
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
- // URL parsing failed
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
- // Test with a canary value in common parameter names
59
- const testParams = ['q', 'search', 'query', 'keyword', 's', 'term', 'name', 'id', 'page', 'redirect', 'url', 'return', 'next', 'callback'];
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: '1.0.2',
42
- module: 'qa',
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 += `**Module:** Quality Assurance & Functional Testing \n`;
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
- if (f.remediation) {
155
- md += `**Remediation:** ${f.remediation}\n\n`;
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>Module: QA & Functional Testing</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>