praxis-sec 1.2.1 → 1.2.2
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/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- package/cli/agents/html-reporter.js +7 -7
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/orchestrator.js +375 -360
- package/cli/commands/audit.js +18 -14
- package/cli/commands/baseline.js +2 -1
- package/cli/commands/ci.js +5 -4
- package/cli/commands/diff.js +2 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +2 -1
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +2 -1
- package/cli/commands/scan-standard.js +3 -6
- package/cli/commands/vibe-check.js +2 -1
- package/cli/commands/watch.js +6 -5
- package/cli/core/output/json.js +56 -48
- package/cli/core/paths.js +91 -0
- package/cli/utils/cache-manager.js +2 -1
- package/docs/USAGE.md +1 -1
- package/package.json +81 -80
|
@@ -1,360 +1,375 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent Orchestrator
|
|
3
|
-
* ==================
|
|
4
|
-
*
|
|
5
|
-
* Coordinates all security agents, deduplicates findings,
|
|
6
|
-
* and produces a unified report.
|
|
7
|
-
*
|
|
8
|
-
* Features:
|
|
9
|
-
* - Per-agent timeouts (default 30s, configurable via --timeout)
|
|
10
|
-
* - Parallel execution with configurable concurrency (default 6)
|
|
11
|
-
*
|
|
12
|
-
* USAGE:
|
|
13
|
-
* const orchestrator = new Orchestrator();
|
|
14
|
-
* orchestrator.register(new InjectionTester());
|
|
15
|
-
* const results = await orchestrator.runAll(rootPath, options);
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import path from 'path';
|
|
19
|
-
import ora from 'ora';
|
|
20
|
-
import chalk from 'chalk';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* @param {
|
|
84
|
-
* @
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Agent Orchestrator
|
|
3
|
+
* ==================
|
|
4
|
+
*
|
|
5
|
+
* Coordinates all security agents, deduplicates findings,
|
|
6
|
+
* and produces a unified report.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Per-agent timeouts (default 30s, configurable via --timeout)
|
|
10
|
+
* - Parallel execution with configurable concurrency (default 6)
|
|
11
|
+
*
|
|
12
|
+
* USAGE:
|
|
13
|
+
* const orchestrator = new Orchestrator();
|
|
14
|
+
* orchestrator.register(new InjectionTester());
|
|
15
|
+
* const results = await orchestrator.runAll(rootPath, options);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import ora from 'ora';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { normalizeFindingPaths } from '../core/paths.js';
|
|
22
|
+
import { ReconAgent } from './recon-agent.js';
|
|
23
|
+
import { VerifierAgent } from './verifier-agent.js';
|
|
24
|
+
import { DeepAnalyzer } from './deep-analyzer.js';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// CONSTANTS
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMEOUT = 30_000; // 30s per agent
|
|
31
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// ORCHESTRATOR
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
export class Orchestrator {
|
|
38
|
+
constructor() {
|
|
39
|
+
/** @type {import('./base-agent.js').BaseAgent[]} */
|
|
40
|
+
this.agents = [];
|
|
41
|
+
this.reconAgent = new ReconAgent();
|
|
42
|
+
this.verifierAgent = new VerifierAgent();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register an agent for execution.
|
|
47
|
+
*/
|
|
48
|
+
register(agent) {
|
|
49
|
+
this.agents.push(agent);
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register multiple agents at once.
|
|
55
|
+
*/
|
|
56
|
+
registerAll(agents) {
|
|
57
|
+
for (const agent of agents) {
|
|
58
|
+
this.register(agent);
|
|
59
|
+
}
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run a single agent with a timeout.
|
|
65
|
+
*/
|
|
66
|
+
async runAgent(agent, context, timeout) {
|
|
67
|
+
let timer;
|
|
68
|
+
try {
|
|
69
|
+
return await Promise.race([
|
|
70
|
+
Promise.resolve().then(() => agent.analyze(context)),
|
|
71
|
+
new Promise((_, reject) => {
|
|
72
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run all registered agents against the codebase.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} rootPath — Absolute path to the project root
|
|
84
|
+
* @param {object} options — { verbose, agents[], categories[], timeout, concurrency }
|
|
85
|
+
* @returns {Promise<object>} — { recon, findings[], agentResults[] }
|
|
86
|
+
*/
|
|
87
|
+
async runAll(rootPath, options = {}) {
|
|
88
|
+
const absolutePath = path.resolve(rootPath);
|
|
89
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
90
|
+
const concurrency = options.concurrency || DEFAULT_CONCURRENCY;
|
|
91
|
+
|
|
92
|
+
// ── 1. Recon — map the attack surface ─────────────────────────────────────
|
|
93
|
+
const quiet = options.quiet || false;
|
|
94
|
+
const reconSpinner = quiet ? null : ora({ text: 'Mapping attack surface...', color: 'cyan' }).start();
|
|
95
|
+
const recon = await this.reconAgent.analyze({ rootPath: absolutePath, options }); // praxis-ignore AGENT_ESCALATED_PERMISSIONS — calling ReconAgent.analyze(); no permission escalation
|
|
96
|
+
if (reconSpinner) reconSpinner.succeed(chalk.green('Attack surface mapped'));
|
|
97
|
+
|
|
98
|
+
// ── 2. Discover files once (shared across agents) ─────────────────────────
|
|
99
|
+
const files = await this.reconAgent.discoverFiles(absolutePath);
|
|
100
|
+
|
|
101
|
+
// ── 3. Filter agents if specific ones requested ───────────────────────────
|
|
102
|
+
let agentsToRun = this.agents;
|
|
103
|
+
if (options.agents && options.agents.length > 0) {
|
|
104
|
+
const requested = options.agents.map(a => a.toLowerCase());
|
|
105
|
+
agentsToRun = this.agents.filter(a => {
|
|
106
|
+
const name = a.name.toLowerCase();
|
|
107
|
+
const cat = a.category.toLowerCase();
|
|
108
|
+
return requested.some(r => name === r || name.includes(r) || cat === r);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (options.categories && options.categories.length > 0) {
|
|
112
|
+
const requested = new Set(options.categories.map(c => c.toLowerCase()));
|
|
113
|
+
agentsToRun = agentsToRun.filter(a => requested.has(a.category.toLowerCase()));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 4. Build shared context ─────────────────────────────────────────────
|
|
117
|
+
// sharedFindings allows cross-agent awareness: later agents can see
|
|
118
|
+
// what earlier agents found (e.g., secrets agent finds a key,
|
|
119
|
+
// supply-chain agent can check if it's committed to a public repo).
|
|
120
|
+
const sharedFindings = [];
|
|
121
|
+
const context = { rootPath: absolutePath, files, recon, options, sharedFindings };
|
|
122
|
+
if (options.changedFiles) {
|
|
123
|
+
context.changedFiles = options.changedFiles;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 5. Run agents in parallel (chunked by concurrency) ──────────────────
|
|
127
|
+
const agentResults = [];
|
|
128
|
+
let allFindings = [];
|
|
129
|
+
|
|
130
|
+
const spinner = quiet ? null : ora({
|
|
131
|
+
text: `Running ${agentsToRun.length} agents in parallel...`,
|
|
132
|
+
color: 'cyan'
|
|
133
|
+
}).start();
|
|
134
|
+
|
|
135
|
+
// Filter agents by framework relevance (shouldRun check)
|
|
136
|
+
const relevantAgents = agentsToRun.filter(a => {
|
|
137
|
+
if (typeof a.shouldRun === 'function') {
|
|
138
|
+
return a.shouldRun(recon);
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
const skippedAgents = agentsToRun.length - relevantAgents.length;
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < relevantAgents.length; i += concurrency) {
|
|
145
|
+
const chunk = relevantAgents.slice(i, i + concurrency);
|
|
146
|
+
const settled = await Promise.allSettled(
|
|
147
|
+
chunk.map(agent => this.runAgent(agent, context, timeout)) // praxis-ignore AGENT_RECURSIVE_INVOCATION — one agent invoking another by design, not self-recursion
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
151
|
+
const agent = chunk[j];
|
|
152
|
+
const result = settled[j];
|
|
153
|
+
|
|
154
|
+
if (result.status === 'fulfilled') {
|
|
155
|
+
const findings = result.value;
|
|
156
|
+
agentResults.push({
|
|
157
|
+
agent: agent.name,
|
|
158
|
+
category: agent.category,
|
|
159
|
+
findingCount: findings.length,
|
|
160
|
+
success: true,
|
|
161
|
+
});
|
|
162
|
+
allFindings = allFindings.concat(findings);
|
|
163
|
+
// Share findings with subsequent agents
|
|
164
|
+
sharedFindings.push(...findings);
|
|
165
|
+
// Optional progress hook: fires once per agent as it settles, so callers
|
|
166
|
+
// that stream progress (e.g. the web UI) report real work rather than a
|
|
167
|
+
// placeholder percentage. Optional so existing callers are unaffected.
|
|
168
|
+
if (typeof options.onProgress === 'function') {
|
|
169
|
+
try {
|
|
170
|
+
options.onProgress({
|
|
171
|
+
agent: agent.name,
|
|
172
|
+
category: agent.category,
|
|
173
|
+
done: agentResults.length,
|
|
174
|
+
total: relevantAgents.length,
|
|
175
|
+
findingCount: findings.length,
|
|
176
|
+
});
|
|
177
|
+
} catch { /* a progress listener must never break a scan */ }
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
agentResults.push({
|
|
181
|
+
agent: agent.name,
|
|
182
|
+
category: agent.category,
|
|
183
|
+
findingCount: 0,
|
|
184
|
+
success: false,
|
|
185
|
+
error: result.reason.message,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Show results summary
|
|
192
|
+
if (spinner) {
|
|
193
|
+
const succeeded = agentResults.filter(a => a.success).length;
|
|
194
|
+
const failed = agentResults.filter(a => !a.success).length;
|
|
195
|
+
const totalFindings = allFindings.length;
|
|
196
|
+
|
|
197
|
+
const skipNote = skippedAgents > 0 ? `, ${skippedAgents} skipped (not relevant)` : '';
|
|
198
|
+
if (failed > 0) {
|
|
199
|
+
spinner.warn(chalk.yellow(
|
|
200
|
+
`${succeeded}/${relevantAgents.length} agents completed, ${failed} failed, ${totalFindings} finding(s)${skipNote}`
|
|
201
|
+
));
|
|
202
|
+
} else {
|
|
203
|
+
spinner.succeed(
|
|
204
|
+
totalFindings === 0
|
|
205
|
+
? chalk.green(`${succeeded} agents: clean${skipNote}`)
|
|
206
|
+
: chalk.yellow(`${succeeded} agents: ${totalFindings} finding(s)${skipNote}`)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Show per-agent results when not in quiet mode
|
|
212
|
+
if (!quiet) {
|
|
213
|
+
for (const r of agentResults) {
|
|
214
|
+
if (r.success) {
|
|
215
|
+
const icon = r.findingCount === 0 ? chalk.green(' ✔') : chalk.yellow(' ⚠');
|
|
216
|
+
const msg = r.findingCount === 0
|
|
217
|
+
? chalk.green(`${r.agent}: clean`)
|
|
218
|
+
: chalk.yellow(`${r.agent}: ${r.findingCount} finding(s)`);
|
|
219
|
+
console.log(`${icon} ${msg}`);
|
|
220
|
+
} else {
|
|
221
|
+
console.log(chalk.red(` ✗ ${r.agent}: ${r.error}`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 6. Deduplicate ────────────────────────────────────────────────────────
|
|
227
|
+
allFindings = this.deduplicate(allFindings);
|
|
228
|
+
|
|
229
|
+
// ── 7. Second-pass verification (confirms or downgrades findings) ───────
|
|
230
|
+
if (!options.skipVerifier) {
|
|
231
|
+
const verifySpinner = quiet ? null : ora({ text: 'Verifying findings...', color: 'cyan' }).start();
|
|
232
|
+
allFindings = this.verifierAgent.verify(allFindings, options);
|
|
233
|
+
const verified = allFindings.filter(f => f.verified === true).length;
|
|
234
|
+
const downgraded = allFindings.filter(f => f.verified === false).length;
|
|
235
|
+
if (verifySpinner) {
|
|
236
|
+
verifySpinner.succeed(chalk.green(
|
|
237
|
+
`Verified: ${verified} confirmed, ${downgraded} downgraded`
|
|
238
|
+
));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── 8. Deep LLM analysis (optional, --deep flag) ───────────────────────
|
|
243
|
+
if (options.deep) {
|
|
244
|
+
const analyzer = DeepAnalyzer.create(absolutePath, {
|
|
245
|
+
local: options.local,
|
|
246
|
+
model: options.model,
|
|
247
|
+
budgetCents: options.budget ?? 50,
|
|
248
|
+
verbose: options.verbose,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (analyzer) {
|
|
252
|
+
const deepSpinner = quiet ? null : ora({ text: `Deep analysis with ${analyzer.provider.name}...`, color: 'cyan' }).start();
|
|
253
|
+
try {
|
|
254
|
+
allFindings = await analyzer.analyze(allFindings, { rootPath: absolutePath, recon });
|
|
255
|
+
const stats = analyzer.getStats();
|
|
256
|
+
if (deepSpinner) {
|
|
257
|
+
if (stats.multiTier) {
|
|
258
|
+
const providerName = analyzer.provider?.name || 'unknown';
|
|
259
|
+
const cascade = stats.isAnthropic !== false ? 'Haiku→Sonnet→Opus' : `${providerName} (3-tier)`;
|
|
260
|
+
const tierNote = stats.tier3Count > 0
|
|
261
|
+
? `, ${stats.tier3Count} escalated to tier-3`
|
|
262
|
+
: stats.tier2Count > 0 ? `, ${stats.tier2Count} via tier-2` : '';
|
|
263
|
+
const skipNote = stats.skippedCount > 0 ? `, ${stats.skippedCount} triaged away` : '';
|
|
264
|
+
deepSpinner.succeed(chalk.green(
|
|
265
|
+
`Deep analysis (${cascade}): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
|
|
266
|
+
));
|
|
267
|
+
} else {
|
|
268
|
+
deepSpinner.succeed(chalk.green(
|
|
269
|
+
`Deep analysis: ${stats.analyzedCount} findings analyzed (${stats.spentCents}¢)`
|
|
270
|
+
));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (deepSpinner) deepSpinner.fail(chalk.yellow(`Deep analysis failed: ${err.message}`));
|
|
275
|
+
}
|
|
276
|
+
} else if (!quiet) {
|
|
277
|
+
console.log(chalk.gray(' Deep analysis: no LLM provider found (set ANTHROPIC_API_KEY, MOONSHOT_API_KEY, or use --local)'));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── 9. Context-aware confidence tuning ──────────────────────────────────
|
|
282
|
+
allFindings = this.tuneConfidence(allFindings);
|
|
283
|
+
|
|
284
|
+
// ── 9.5 Governance absence-audits (no-human-oversight, no-observability)
|
|
285
|
+
try {
|
|
286
|
+
const { runGovernanceAudits } = await import('./governance-audits.js');
|
|
287
|
+
const governance = runGovernanceAudits({ rootPath: absolutePath, files, findings: allFindings });
|
|
288
|
+
allFindings = allFindings.concat(governance);
|
|
289
|
+
} catch { /* governance audits are additive — never break a scan */ }
|
|
290
|
+
|
|
291
|
+
// ── 10. Sort by severity ──────────────────────────────────────────────────
|
|
292
|
+
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
293
|
+
allFindings.sort((a, b) =>
|
|
294
|
+
(sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4)
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// ── 11. Render every path for display, once ────────────────────────────────
|
|
298
|
+
// The terminal table, the HTML and JSON reports, CI annotations and SARIF all
|
|
299
|
+
// read this same `finding.file`. An agent may legitimately report a file above
|
|
300
|
+
// the scan root — MCP_SHADOW_CONFIG reads the developer's own
|
|
301
|
+
// `~/.cursor/mcp.json`, which is the point of the finding. Left absolute, the
|
|
302
|
+
// terminal printed `../../../../Users/<name>/.cursor/mcp.json` and the reports
|
|
303
|
+
// printed `Users/<name>/.cursor/mcp.json`: the username leaked into output
|
|
304
|
+
// that gets pasted into CI, and the path looked repo-relative while not
|
|
305
|
+
// existing. `displayPath` makes it `~/.cursor/mcp.json` in every surface, and
|
|
306
|
+
// identically on every machine, so a self-scan's finding identities do not
|
|
307
|
+
// shift between developers. `fix` and `remediate` build their own absolute
|
|
308
|
+
// paths, so no write is redirected by this.
|
|
309
|
+
normalizeFindingPaths(allFindings, absolutePath);
|
|
310
|
+
|
|
311
|
+
return { recon, findings: allFindings, agentResults };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Run only agents matching a specific category.
|
|
316
|
+
*/
|
|
317
|
+
async runCategory(category, rootPath, options = {}) {
|
|
318
|
+
return this.runAll(rootPath, { ...options, categories: [category] });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Downgrade confidence for findings in test files, comments, docs, or examples.
|
|
323
|
+
* Reduces false-positive noise since ScoringEngine applies confidence multipliers.
|
|
324
|
+
*/
|
|
325
|
+
tuneConfidence(findings) {
|
|
326
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
|
|
327
|
+
const DOC_EXT = new Set(['.md', '.txt', '.rst', '.adoc', '.rdoc']);
|
|
328
|
+
const EXAMPLE_PATH = /(?:\/examples?\/|\/samples?\/|\/demos?\/|\/fixtures?\/|\/mocks?\/)/i;
|
|
329
|
+
const COMMENT_LINE = /^\s*(?:\/\/|#|\/?\*|<!--)/;
|
|
330
|
+
|
|
331
|
+
for (const f of findings) {
|
|
332
|
+
const ext = (f.file || '').match(/\.[^.]+$/)?.[0]?.toLowerCase() || '';
|
|
333
|
+
|
|
334
|
+
// Findings in documentation files
|
|
335
|
+
if (DOC_EXT.has(ext)) {
|
|
336
|
+
f.confidence = 'low';
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Findings in test files
|
|
341
|
+
if (TEST_PATH.test(f.file || '')) {
|
|
342
|
+
f.confidence = 'low';
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Findings in example/sample/demo paths: high → medium
|
|
347
|
+
if (EXAMPLE_PATH.test(f.file || '') && f.confidence === 'high') {
|
|
348
|
+
f.confidence = 'medium';
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Findings on comment lines
|
|
353
|
+
if (f.matched && COMMENT_LINE.test(f.matched)) {
|
|
354
|
+
f.confidence = 'low';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return findings;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Remove duplicate findings (same file + line + rule).
|
|
363
|
+
*/
|
|
364
|
+
deduplicate(findings) {
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
return findings.filter(f => {
|
|
367
|
+
const key = `${f.file}:${f.line}:${f.rule}`;
|
|
368
|
+
if (seen.has(key)) return false;
|
|
369
|
+
seen.add(key);
|
|
370
|
+
return true;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default Orchestrator;
|