praxis-sec 1.0.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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/ai-defense/cost-protection.md +292 -0
  4. package/ai-defense/llm-security-checklist.md +324 -0
  5. package/ai-defense/prompt-injection-patterns.js +283 -0
  6. package/ai-defense/system-prompt-armor.md +327 -0
  7. package/checklists/launch-day.md +168 -0
  8. package/cli/agents/abom-generator.js +225 -0
  9. package/cli/agents/agent-attestation-agent.js +318 -0
  10. package/cli/agents/agent-config-scanner.js +787 -0
  11. package/cli/agents/agent-telemetry-agent.js +415 -0
  12. package/cli/agents/agentic-security-agent.js +296 -0
  13. package/cli/agents/agentic-supply-chain-agent.js +463 -0
  14. package/cli/agents/ai-infra-inventory-agent.js +449 -0
  15. package/cli/agents/api-fuzzer.js +345 -0
  16. package/cli/agents/auth-bypass-agent.js +348 -0
  17. package/cli/agents/base-agent.js +280 -0
  18. package/cli/agents/cicd-scanner.js +300 -0
  19. package/cli/agents/config-auditor.js +757 -0
  20. package/cli/agents/deep-analyzer.js +776 -0
  21. package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
  22. package/cli/agents/exception-handler-agent.js +187 -0
  23. package/cli/agents/git-history-scanner.js +169 -0
  24. package/cli/agents/governance-audits.js +138 -0
  25. package/cli/agents/hermes-security-agent.js +536 -0
  26. package/cli/agents/html-reporter.js +1125 -0
  27. package/cli/agents/index.js +147 -0
  28. package/cli/agents/injection-tester.js +502 -0
  29. package/cli/agents/legal-risk-agent.js +328 -0
  30. package/cli/agents/llm-redteam.js +199 -0
  31. package/cli/agents/managed-agent-scanner.js +333 -0
  32. package/cli/agents/mcp-security-agent.js +588 -0
  33. package/cli/agents/memory-poisoning-agent.js +305 -0
  34. package/cli/agents/mobile-scanner.js +231 -0
  35. package/cli/agents/model-file-scanner.js +259 -0
  36. package/cli/agents/orchestrator.js +355 -0
  37. package/cli/agents/pii-compliance-agent.js +301 -0
  38. package/cli/agents/policy-engine.js +229 -0
  39. package/cli/agents/prompt-injection-prober.js +224 -0
  40. package/cli/agents/rag-security-agent.js +204 -0
  41. package/cli/agents/recon-agent.js +207 -0
  42. package/cli/agents/sbom-generator.js +265 -0
  43. package/cli/agents/scoring-engine.js +273 -0
  44. package/cli/agents/ssrf-prober.js +130 -0
  45. package/cli/agents/stateful-watcher.js +238 -0
  46. package/cli/agents/supabase-rls-agent.js +154 -0
  47. package/cli/agents/supply-chain-agent.js +857 -0
  48. package/cli/agents/swarm-orchestrator.js +200 -0
  49. package/cli/agents/verifier-agent.js +303 -0
  50. package/cli/agents/vibe-coding-agent.js +250 -0
  51. package/cli/bin/praxis.js +866 -0
  52. package/cli/commands/abom.js +73 -0
  53. package/cli/commands/agent-fix.js +1245 -0
  54. package/cli/commands/audit.js +1180 -0
  55. package/cli/commands/autofix.js +383 -0
  56. package/cli/commands/baseline.js +193 -0
  57. package/cli/commands/benchmark.js +327 -0
  58. package/cli/commands/checklist.js +223 -0
  59. package/cli/commands/ci.js +403 -0
  60. package/cli/commands/deps.js +516 -0
  61. package/cli/commands/diff.js +200 -0
  62. package/cli/commands/doctor.js +195 -0
  63. package/cli/commands/env-audit.js +349 -0
  64. package/cli/commands/fix.js +218 -0
  65. package/cli/commands/guard.js +396 -0
  66. package/cli/commands/hooks.js +278 -0
  67. package/cli/commands/init.js +514 -0
  68. package/cli/commands/legal.js +158 -0
  69. package/cli/commands/live-advisories.js +241 -0
  70. package/cli/commands/mcp.js +660 -0
  71. package/cli/commands/openclaw.js +386 -0
  72. package/cli/commands/red-team.js +350 -0
  73. package/cli/commands/redteam.js +78 -0
  74. package/cli/commands/remediate.js +797 -0
  75. package/cli/commands/rotate.js +768 -0
  76. package/cli/commands/rules.js +196 -0
  77. package/cli/commands/scan-mcp.js +534 -0
  78. package/cli/commands/scan-skill.js +588 -0
  79. package/cli/commands/scan-standard.js +251 -0
  80. package/cli/commands/scan.js +524 -0
  81. package/cli/commands/score.js +449 -0
  82. package/cli/commands/shell.js +514 -0
  83. package/cli/commands/team-report.js +398 -0
  84. package/cli/commands/undo.js +161 -0
  85. package/cli/commands/update-intel.js +126 -0
  86. package/cli/commands/vibe-check.js +276 -0
  87. package/cli/commands/watch.js +757 -0
  88. package/cli/commands/web.js +63 -0
  89. package/cli/core/ast/guardrail-detector.js +141 -0
  90. package/cli/core/ast/index.js +22 -0
  91. package/cli/core/ast/parser.js +676 -0
  92. package/cli/core/ast/scope-tree.js +287 -0
  93. package/cli/core/ast/taint-tracker.js +158 -0
  94. package/cli/core/branding.js +37 -0
  95. package/cli/core/env.js +38 -0
  96. package/cli/core/errors.js +61 -0
  97. package/cli/core/fs.js +62 -0
  98. package/cli/core/output/compliance.js +90 -0
  99. package/cli/core/output/html-theme.js +158 -0
  100. package/cli/core/output/index.js +57 -0
  101. package/cli/core/output/json.js +48 -0
  102. package/cli/core/output/sarif.js +240 -0
  103. package/cli/core/version.js +67 -0
  104. package/cli/core/web/jobs.js +183 -0
  105. package/cli/core/web/projects.js +146 -0
  106. package/cli/core/web/server.js +439 -0
  107. package/cli/data/atlas-knowledge.json +5640 -0
  108. package/cli/data/eaa-catalog.json +39 -0
  109. package/cli/data/known-mcps.json +26 -0
  110. package/cli/data/probes/prompt-injection-corpus.json +271 -0
  111. package/cli/data/threat-intel.json +85 -0
  112. package/cli/data/threatpacks/latest.json +41 -0
  113. package/cli/hooks/patterns.js +313 -0
  114. package/cli/hooks/post-tool-use.js +140 -0
  115. package/cli/hooks/pre-tool-use.js +186 -0
  116. package/cli/index.js +90 -0
  117. package/cli/providers/llm-provider.js +766 -0
  118. package/cli/utils/autofix-rules.js +74 -0
  119. package/cli/utils/cache-manager.js +310 -0
  120. package/cli/utils/compliance-map.js +191 -0
  121. package/cli/utils/entropy.js +132 -0
  122. package/cli/utils/fix-ledger.js +127 -0
  123. package/cli/utils/hermes-tool-registry.js +252 -0
  124. package/cli/utils/intel/cache.js +61 -0
  125. package/cli/utils/intel/http.js +88 -0
  126. package/cli/utils/intel/index.js +235 -0
  127. package/cli/utils/intel/merge.js +229 -0
  128. package/cli/utils/intel/sources/epss.js +54 -0
  129. package/cli/utils/intel/sources/ghsa.js +81 -0
  130. package/cli/utils/intel/sources/gitguardian.js +40 -0
  131. package/cli/utils/intel/sources/gitleaks.js +101 -0
  132. package/cli/utils/intel/sources/kev.js +38 -0
  133. package/cli/utils/intel/sources/nvd.js +84 -0
  134. package/cli/utils/intel/sources/osv.js +132 -0
  135. package/cli/utils/intel/sources/phylum.js +44 -0
  136. package/cli/utils/intel/sources/snyk.js +46 -0
  137. package/cli/utils/intel/sources/socket.js +69 -0
  138. package/cli/utils/intel/sources/sonatype.js +84 -0
  139. package/cli/utils/intel/sources/threatpack.js +69 -0
  140. package/cli/utils/mcp-trust.js +60 -0
  141. package/cli/utils/output.js +251 -0
  142. package/cli/utils/patterns.js +1130 -0
  143. package/cli/utils/pdf-generator.js +94 -0
  144. package/cli/utils/plugin-loader.js +364 -0
  145. package/cli/utils/rule-import.js +228 -0
  146. package/cli/utils/rule-registry.js +426 -0
  147. package/cli/utils/scan-fingerprint.js +109 -0
  148. package/cli/utils/scan-playbook.js +312 -0
  149. package/cli/utils/score-history.js +119 -0
  150. package/cli/utils/secrets-verifier.js +247 -0
  151. package/cli/utils/security-memory.js +296 -0
  152. package/cli/utils/standards/atlas-knowledge.js +87 -0
  153. package/cli/utils/standards/index.js +127 -0
  154. package/cli/utils/standards/sources/avid.js +45 -0
  155. package/cli/utils/standards/sources/eu-ai-act.js +89 -0
  156. package/cli/utils/standards/sources/google-saif.js +39 -0
  157. package/cli/utils/standards/sources/iso-42001.js +94 -0
  158. package/cli/utils/standards/sources/mitre-atlas.js +54 -0
  159. package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
  160. package/cli/utils/standards/sources/owasp-llm.js +45 -0
  161. package/cli/utils/standards/sources/owasp-ml.js +45 -0
  162. package/cli/utils/threat-intel.js +265 -0
  163. package/configs/firebase/firestore-rules.txt +215 -0
  164. package/configs/firebase/security-checklist.md +236 -0
  165. package/configs/firebase/storage-rules.txt +206 -0
  166. package/configs/gitignore-template +258 -0
  167. package/configs/nextjs-security-headers.js +220 -0
  168. package/configs/praxisignore-template +50 -0
  169. package/configs/supabase/secure-client.ts +225 -0
  170. package/configs/supabase/security-checklist.md +278 -0
  171. package/docs/THIRD_PARTY_NOTICES.md +26 -0
  172. package/docs/THREAT_INTEL.md +292 -0
  173. package/docs/USAGE.md +1205 -0
  174. package/docs/design/WEB-UI.md +82 -0
  175. package/package.json +71 -0
  176. package/scripts/check-determinism.mjs +119 -0
  177. package/snippets/README.md +122 -0
  178. package/snippets/api-security/api-security-checklist.md +412 -0
  179. package/snippets/api-security/cors-config.ts +322 -0
  180. package/snippets/api-security/input-validation.ts +430 -0
  181. package/snippets/auth/jwt-checklist.md +322 -0
  182. package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
  183. package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Model File Scanner
3
+ * ===================
4
+ *
5
+ * Finds machine-learning model artifacts in the repository and flags risky
6
+ * formats. Pickle-based formats (.pkl/.pt/.pth/.ckpt/pytorch_model.bin)
7
+ * execute arbitrary code on load — adversaries embed `os.system` payloads
8
+ * via `__reduce__` to gain RCE. SafeTensors and GGUF are safer but still
9
+ * benefit from a model-card / origin check.
10
+ *
11
+ * Risk classes:
12
+ * - pickle-based (.pkl, .pt, .pth, .ckpt, pytorch_model.bin) → critical/high
13
+ * - safetensors (.safetensors) → low/informational
14
+ * - gguf, onnx, h5, pb → low
15
+ * - missing MODEL_CARD.md / README.md alongside weights → low
16
+ *
17
+ * References: ProtectAI ModelScan, Trail of Bits "PyTorch Pickle Risks".
18
+ *
19
+ * Maps to: OWASP LLM03 / LLM04, MITRE ATLAS AML.T0010 / AML.T0018,
20
+ * OWASP ML Top 10 ML06 / ML10.
21
+ */
22
+
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+ import fg from 'fast-glob';
26
+ import { BaseAgent, createFinding } from './base-agent.js';
27
+
28
+ const MODEL_GLOBS = [
29
+ '**/*.pkl', '**/*.pickle',
30
+ '**/*.pt', '**/*.pth',
31
+ '**/*.ckpt',
32
+ '**/*.safetensors',
33
+ '**/*.gguf',
34
+ '**/*.onnx',
35
+ '**/*.h5', '**/*.hdf5',
36
+ '**/*.pb',
37
+ '**/pytorch_model.bin',
38
+ '**/model.bin',
39
+ '**/consolidated.*.bin',
40
+ ];
41
+
42
+ const PICKLE_EXTS = new Set(['.pkl', '.pickle', '.pt', '.pth', '.ckpt']);
43
+ const SAFE_EXTS = new Set(['.safetensors']);
44
+ const NEUTRAL_EXTS = new Set(['.gguf', '.onnx', '.h5', '.hdf5', '.pb']);
45
+
46
+ // Strings that, when present in a pickle byte stream, indicate the file
47
+ // imports a dangerous module during deserialization. These are not
48
+ // definitive RCE proofs (Python's pickle stores module names as ASCII), but
49
+ // in practice their presence in a model artifact is anomalous.
50
+ const PICKLE_DANGEROUS_MODULES = [
51
+ 'posix', 'nt', 'os.system', 'os.popen', 'os.exec',
52
+ 'subprocess', 'subprocess.Popen', 'subprocess.call', 'subprocess.run',
53
+ 'commands.getoutput',
54
+ 'builtins.eval', 'builtins.exec', 'builtins.compile',
55
+ '__builtin__.eval', '__builtin__.exec',
56
+ 'shutil.rmtree',
57
+ 'pty.spawn',
58
+ 'socket.socket',
59
+ 'requests.get', 'urllib.request',
60
+ '__reduce__', '__reduce_ex__',
61
+ ];
62
+
63
+ const PICKLE_OPCODE_GLOBAL = 0x63; // 'c' — GLOBAL (push module.attr onto stack)
64
+ const PICKLE_OPCODE_REDUCE = 0x52; // 'R' — call callable with args (the dangerous one)
65
+ const PICKLE_OPCODE_INST = 0x69; // 'i' — INST (also reduce-like)
66
+ const PICKLE_OPCODE_STACK_GLOBAL = 0x93; // STACK_GLOBAL (proto 4)
67
+
68
+ const MAX_BYTES_TO_SCAN = 5 * 1024 * 1024; // 5MB head — more than enough to see opcodes
69
+
70
+ export class ModelFileScanner extends BaseAgent {
71
+ constructor() {
72
+ super(
73
+ 'ModelFileScanner',
74
+ 'Detects risky ML model artifacts (pickle, safetensors, gguf) and missing model cards',
75
+ 'llm'
76
+ );
77
+ }
78
+
79
+ shouldRun(recon) {
80
+ if (recon?.hasModelFiles) return true;
81
+ const langs = recon?.languages;
82
+ if (langs && (langs instanceof Set ? langs.has('python') : langs.includes?.('python'))) {
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ async analyze(context) {
89
+ const { rootPath } = context;
90
+ const findings = [];
91
+
92
+ let modelFiles = [];
93
+ try {
94
+ modelFiles = await fg(MODEL_GLOBS, {
95
+ cwd: rootPath,
96
+ absolute: true,
97
+ onlyFiles: true,
98
+ dot: false,
99
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
100
+ });
101
+ } catch {
102
+ return findings;
103
+ }
104
+
105
+ if (modelFiles.length === 0) return findings;
106
+
107
+ const directoriesWithModels = new Set();
108
+
109
+ for (const file of modelFiles) {
110
+ directoriesWithModels.add(path.dirname(file));
111
+ const ext = path.extname(file).toLowerCase();
112
+ const relPath = path.relative(rootPath, file).replace(/\\/g, '/');
113
+
114
+ if (PICKLE_EXTS.has(ext) || /pytorch_model\.bin$|consolidated\..*\.bin$|^model\.bin$/.test(path.basename(file))) {
115
+ findings.push(...this._inspectPickle(file, relPath));
116
+ } else if (SAFE_EXTS.has(ext)) {
117
+ findings.push(this._informational(file, relPath, 'safetensors',
118
+ 'SafeTensors is the recommended safe format. Verify the file came from a trusted source and matches a published checksum.'));
119
+ } else if (NEUTRAL_EXTS.has(ext)) {
120
+ findings.push(this._informational(file, relPath, ext.replace('.', ''),
121
+ 'Non-pickle model artifact. Verify origin and checksums; some loaders (e.g. ONNX custom ops) can still execute attacker-controlled code.'));
122
+ }
123
+ }
124
+
125
+ for (const dir of directoriesWithModels) {
126
+ const sample = modelFiles.find(f => path.dirname(f) === dir);
127
+ if (!sample) continue;
128
+ if (!this._hasModelCard(dir)) {
129
+ findings.push(createFinding({
130
+ file: sample,
131
+ line: 0,
132
+ severity: 'low',
133
+ category: 'llm',
134
+ rule: 'MODEL_FILE_NO_CARD',
135
+ title: 'Model artifact without a model card',
136
+ description: 'No MODEL_CARD.md / README.md / model_card.* alongside the model artifact. Model cards document training data, intended use, and limitations.',
137
+ matched: path.relative(rootPath, dir).replace(/\\/g, '/'),
138
+ confidence: 'medium',
139
+ owasp: 'ASI04',
140
+ fix: 'Add a MODEL_CARD.md describing source, training data, intended use, license, and known limitations.',
141
+ }));
142
+ }
143
+ }
144
+
145
+ return findings;
146
+ }
147
+
148
+ _inspectPickle(filePath, relPath) {
149
+ const findings = [];
150
+ let buf;
151
+ try {
152
+ const fd = fs.openSync(filePath, 'r');
153
+ const stat = fs.fstatSync(fd);
154
+ const size = Math.min(stat.size, MAX_BYTES_TO_SCAN);
155
+ buf = Buffer.alloc(size);
156
+ fs.readSync(fd, buf, 0, size, 0);
157
+ fs.closeSync(fd);
158
+ } catch {
159
+ return findings;
160
+ }
161
+
162
+ const isZip = buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4B;
163
+
164
+ findings.push(createFinding({
165
+ file: filePath,
166
+ line: 0,
167
+ severity: 'high',
168
+ category: 'llm',
169
+ rule: 'MODEL_FILE_PICKLE_FORMAT',
170
+ title: 'Pickle-based model format detected',
171
+ description: 'Pickle-based model artifacts execute arbitrary Python on load via `__reduce__`. Loading a malicious file is equivalent to running its code. Treat any unverified pickle as untrusted.',
172
+ matched: relPath,
173
+ confidence: 'high',
174
+ cwe: 'CWE-502',
175
+ owasp: 'ASI04',
176
+ fix: 'Convert to SafeTensors (`safetensors.torch.save_file`). If pickle is unavoidable, verify a SHA-256 you control before loading and load only inside a sandbox.',
177
+ }));
178
+
179
+ const text = buf.toString('binary');
180
+ const hits = [];
181
+ for (const mod of PICKLE_DANGEROUS_MODULES) {
182
+ const idx = text.indexOf(mod);
183
+ if (idx !== -1) hits.push({ mod, idx });
184
+ }
185
+
186
+ let hasReduce = false;
187
+ let hasGlobal = false;
188
+ if (!isZip) {
189
+ for (let i = 0; i < buf.length; i++) {
190
+ const b = buf[i];
191
+ if (b === PICKLE_OPCODE_REDUCE) hasReduce = true;
192
+ else if (b === PICKLE_OPCODE_GLOBAL || b === PICKLE_OPCODE_STACK_GLOBAL || b === PICKLE_OPCODE_INST) hasGlobal = true;
193
+ if (hasReduce && hasGlobal) break;
194
+ }
195
+ }
196
+
197
+ if (hits.length > 0) {
198
+ findings.push(createFinding({
199
+ file: filePath,
200
+ line: 0,
201
+ severity: 'critical',
202
+ category: 'llm',
203
+ rule: 'MODEL_FILE_PICKLE_DANGEROUS_IMPORT',
204
+ title: 'Pickle artifact imports a dangerous module',
205
+ description: `Pickle stream references known-dangerous symbols: ${hits.slice(0, 5).map(h => h.mod).join(', ')}. Loading this file with torch.load / pickle.load may execute code.`,
206
+ matched: hits.slice(0, 5).map(h => h.mod).join(', '),
207
+ confidence: 'high',
208
+ cwe: 'CWE-502',
209
+ owasp: 'ASI04',
210
+ fix: 'Do not load this file. Re-source the model from a trusted publisher and verify checksums. Convert to SafeTensors before reuse.',
211
+ }));
212
+ } else if (hasReduce && hasGlobal) {
213
+ findings.push(createFinding({
214
+ file: filePath,
215
+ line: 0,
216
+ severity: 'medium',
217
+ category: 'llm',
218
+ rule: 'MODEL_FILE_PICKLE_REDUCE',
219
+ title: 'Pickle artifact contains REDUCE opcodes',
220
+ description: 'Pickle stream contains GLOBAL + REDUCE opcodes — typical for callable invocations during unpickling. Without a known-good source, this is the same shape malicious pickles use.',
221
+ matched: 'pickle GLOBAL + REDUCE opcodes present',
222
+ confidence: 'medium',
223
+ cwe: 'CWE-502',
224
+ owasp: 'ASI04',
225
+ fix: 'Verify the artifact origin and checksum, or scan with a dedicated pickle scanner (picklescan / modelscan) before loading.',
226
+ }));
227
+ }
228
+
229
+ return findings;
230
+ }
231
+
232
+ _informational(filePath, relPath, label, description) {
233
+ return createFinding({
234
+ file: filePath,
235
+ line: 0,
236
+ severity: 'low',
237
+ category: 'llm',
238
+ rule: `MODEL_FILE_${label.toUpperCase()}`,
239
+ title: `Model artifact detected (${label})`,
240
+ description,
241
+ matched: relPath,
242
+ confidence: 'high',
243
+ owasp: 'ASI04',
244
+ fix: 'Document the model origin and license. Pin a checksum in your repo so future loads can be verified.',
245
+ });
246
+ }
247
+
248
+ _hasModelCard(dir) {
249
+ const candidates = ['MODEL_CARD.md', 'model_card.md', 'modelcard.md', 'MODELCARD.md', 'README.md', 'README'];
250
+ for (const name of candidates) {
251
+ try {
252
+ if (fs.existsSync(path.join(dir, name))) return true;
253
+ } catch { /* ignore */ }
254
+ }
255
+ return false;
256
+ }
257
+ }
258
+
259
+ export default ModelFileScanner;
@@ -0,0 +1,355 @@
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 { ReconAgent } from './recon-agent.js';
22
+ import { VerifierAgent } from './verifier-agent.js';
23
+ import { DeepAnalyzer } from './deep-analyzer.js';
24
+
25
+ // =============================================================================
26
+ // CONSTANTS
27
+ // =============================================================================
28
+
29
+ const DEFAULT_TIMEOUT = 30_000; // 30s per agent
30
+ const DEFAULT_CONCURRENCY = 6;
31
+
32
+ // =============================================================================
33
+ // ORCHESTRATOR
34
+ // =============================================================================
35
+
36
+ export class Orchestrator {
37
+ constructor() {
38
+ /** @type {import('./base-agent.js').BaseAgent[]} */
39
+ this.agents = [];
40
+ this.reconAgent = new ReconAgent();
41
+ this.verifierAgent = new VerifierAgent();
42
+ }
43
+
44
+ /**
45
+ * Register an agent for execution.
46
+ */
47
+ register(agent) {
48
+ this.agents.push(agent);
49
+ return this;
50
+ }
51
+
52
+ /**
53
+ * Register multiple agents at once.
54
+ */
55
+ registerAll(agents) {
56
+ for (const agent of agents) {
57
+ this.register(agent);
58
+ }
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Run a single agent with a timeout.
64
+ */
65
+ async runAgent(agent, context, timeout) {
66
+ return Promise.race([
67
+ agent.analyze(context),
68
+ new Promise((_, reject) => {
69
+ setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
70
+ }),
71
+ ]);
72
+ }
73
+
74
+ /**
75
+ * Run all registered agents against the codebase.
76
+ *
77
+ * @param {string} rootPath — Absolute path to the project root
78
+ * @param {object} options — { verbose, agents[], categories[], timeout, concurrency }
79
+ * @returns {Promise<object>} — { recon, findings[], agentResults[] }
80
+ */
81
+ async runAll(rootPath, options = {}) {
82
+ const absolutePath = path.resolve(rootPath);
83
+ const timeout = options.timeout || DEFAULT_TIMEOUT;
84
+ const concurrency = options.concurrency || DEFAULT_CONCURRENCY;
85
+
86
+ // ── 1. Recon — map the attack surface ─────────────────────────────────────
87
+ const quiet = options.quiet || false;
88
+ const reconSpinner = quiet ? null : ora({ text: 'Mapping attack surface...', color: 'cyan' }).start();
89
+ const recon = await this.reconAgent.analyze({ rootPath: absolutePath, options }); // praxis-ignore AGENT_ESCALATED_PERMISSIONS — calling ReconAgent.analyze(); no permission escalation
90
+ if (reconSpinner) reconSpinner.succeed(chalk.green('Attack surface mapped'));
91
+
92
+ // ── 2. Discover files once (shared across agents) ─────────────────────────
93
+ const files = await this.reconAgent.discoverFiles(absolutePath);
94
+
95
+ // ── 3. Filter agents if specific ones requested ───────────────────────────
96
+ let agentsToRun = this.agents;
97
+ if (options.agents && options.agents.length > 0) {
98
+ const requested = options.agents.map(a => a.toLowerCase());
99
+ agentsToRun = this.agents.filter(a => {
100
+ const name = a.name.toLowerCase();
101
+ const cat = a.category.toLowerCase();
102
+ return requested.some(r => name === r || name.includes(r) || cat === r);
103
+ });
104
+ }
105
+ if (options.categories && options.categories.length > 0) {
106
+ const requested = new Set(options.categories.map(c => c.toLowerCase()));
107
+ agentsToRun = agentsToRun.filter(a => requested.has(a.category.toLowerCase()));
108
+ }
109
+
110
+ // ── 4. Build shared context ─────────────────────────────────────────────
111
+ // sharedFindings allows cross-agent awareness: later agents can see
112
+ // what earlier agents found (e.g., secrets agent finds a key,
113
+ // supply-chain agent can check if it's committed to a public repo).
114
+ const sharedFindings = [];
115
+ const context = { rootPath: absolutePath, files, recon, options, sharedFindings };
116
+ if (options.changedFiles) {
117
+ context.changedFiles = options.changedFiles;
118
+ }
119
+
120
+ // ── 5. Run agents in parallel (chunked by concurrency) ──────────────────
121
+ const agentResults = [];
122
+ let allFindings = [];
123
+
124
+ const spinner = quiet ? null : ora({
125
+ text: `Running ${agentsToRun.length} agents in parallel...`,
126
+ color: 'cyan'
127
+ }).start();
128
+
129
+ // Filter agents by framework relevance (shouldRun check)
130
+ const relevantAgents = agentsToRun.filter(a => {
131
+ if (typeof a.shouldRun === 'function') {
132
+ return a.shouldRun(recon);
133
+ }
134
+ return true;
135
+ });
136
+ const skippedAgents = agentsToRun.length - relevantAgents.length;
137
+
138
+ for (let i = 0; i < relevantAgents.length; i += concurrency) {
139
+ const chunk = relevantAgents.slice(i, i + concurrency);
140
+ const settled = await Promise.allSettled(
141
+ chunk.map(agent => this.runAgent(agent, context, timeout)) // praxis-ignore AGENT_RECURSIVE_INVOCATION — one agent invoking another by design, not self-recursion
142
+ );
143
+
144
+ for (let j = 0; j < chunk.length; j++) {
145
+ const agent = chunk[j];
146
+ const result = settled[j];
147
+
148
+ if (result.status === 'fulfilled') {
149
+ const findings = result.value;
150
+ agentResults.push({
151
+ agent: agent.name,
152
+ category: agent.category,
153
+ findingCount: findings.length,
154
+ success: true,
155
+ });
156
+ allFindings = allFindings.concat(findings);
157
+ // Share findings with subsequent agents
158
+ sharedFindings.push(...findings);
159
+ // Optional progress hook: fires once per agent as it settles, so callers
160
+ // that stream progress (e.g. the web UI) report real work rather than a
161
+ // placeholder percentage. Optional so existing callers are unaffected.
162
+ if (typeof options.onProgress === 'function') {
163
+ try {
164
+ options.onProgress({
165
+ agent: agent.name,
166
+ category: agent.category,
167
+ done: agentResults.length,
168
+ total: relevantAgents.length,
169
+ findingCount: findings.length,
170
+ });
171
+ } catch { /* a progress listener must never break a scan */ }
172
+ }
173
+ } else {
174
+ agentResults.push({
175
+ agent: agent.name,
176
+ category: agent.category,
177
+ findingCount: 0,
178
+ success: false,
179
+ error: result.reason.message,
180
+ });
181
+ }
182
+ }
183
+ }
184
+
185
+ // Show results summary
186
+ if (spinner) {
187
+ const succeeded = agentResults.filter(a => a.success).length;
188
+ const failed = agentResults.filter(a => !a.success).length;
189
+ const totalFindings = allFindings.length;
190
+
191
+ const skipNote = skippedAgents > 0 ? `, ${skippedAgents} skipped (not relevant)` : '';
192
+ if (failed > 0) {
193
+ spinner.warn(chalk.yellow(
194
+ `${succeeded}/${relevantAgents.length} agents completed, ${failed} failed, ${totalFindings} finding(s)${skipNote}`
195
+ ));
196
+ } else {
197
+ spinner.succeed(
198
+ totalFindings === 0
199
+ ? chalk.green(`${succeeded} agents: clean${skipNote}`)
200
+ : chalk.yellow(`${succeeded} agents: ${totalFindings} finding(s)${skipNote}`)
201
+ );
202
+ }
203
+ }
204
+
205
+ // Show per-agent results when not in quiet mode
206
+ if (!quiet) {
207
+ for (const r of agentResults) {
208
+ if (r.success) {
209
+ const icon = r.findingCount === 0 ? chalk.green(' ✔') : chalk.yellow(' ⚠');
210
+ const msg = r.findingCount === 0
211
+ ? chalk.green(`${r.agent}: clean`)
212
+ : chalk.yellow(`${r.agent}: ${r.findingCount} finding(s)`);
213
+ console.log(`${icon} ${msg}`);
214
+ } else {
215
+ console.log(chalk.red(` ✗ ${r.agent}: ${r.error}`));
216
+ }
217
+ }
218
+ }
219
+
220
+ // ── 6. Deduplicate ────────────────────────────────────────────────────────
221
+ allFindings = this.deduplicate(allFindings);
222
+
223
+ // ── 7. Second-pass verification (confirms or downgrades findings) ───────
224
+ if (!options.skipVerifier) {
225
+ const verifySpinner = quiet ? null : ora({ text: 'Verifying findings...', color: 'cyan' }).start();
226
+ allFindings = this.verifierAgent.verify(allFindings, options);
227
+ const verified = allFindings.filter(f => f.verified === true).length;
228
+ const downgraded = allFindings.filter(f => f.verified === false).length;
229
+ if (verifySpinner) {
230
+ verifySpinner.succeed(chalk.green(
231
+ `Verified: ${verified} confirmed, ${downgraded} downgraded`
232
+ ));
233
+ }
234
+ }
235
+
236
+ // ── 8. Deep LLM analysis (optional, --deep flag) ───────────────────────
237
+ if (options.deep) {
238
+ const analyzer = DeepAnalyzer.create(absolutePath, {
239
+ local: options.local,
240
+ model: options.model,
241
+ budgetCents: options.budget ?? 50,
242
+ verbose: options.verbose,
243
+ });
244
+
245
+ if (analyzer) {
246
+ const deepSpinner = quiet ? null : ora({ text: `Deep analysis with ${analyzer.provider.name}...`, color: 'cyan' }).start();
247
+ try {
248
+ allFindings = await analyzer.analyze(allFindings, { rootPath: absolutePath, recon });
249
+ const stats = analyzer.getStats();
250
+ if (deepSpinner) {
251
+ if (stats.multiTier) {
252
+ const providerName = analyzer.provider?.name || 'unknown';
253
+ const cascade = stats.isAnthropic !== false ? 'Haiku→Sonnet→Opus' : `${providerName} (3-tier)`;
254
+ const tierNote = stats.tier3Count > 0
255
+ ? `, ${stats.tier3Count} escalated to tier-3`
256
+ : stats.tier2Count > 0 ? `, ${stats.tier2Count} via tier-2` : '';
257
+ const skipNote = stats.skippedCount > 0 ? `, ${stats.skippedCount} triaged away` : '';
258
+ deepSpinner.succeed(chalk.green(
259
+ `Deep analysis (${cascade}): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
260
+ ));
261
+ } else {
262
+ deepSpinner.succeed(chalk.green(
263
+ `Deep analysis: ${stats.analyzedCount} findings analyzed (${stats.spentCents}¢)`
264
+ ));
265
+ }
266
+ }
267
+ } catch (err) {
268
+ if (deepSpinner) deepSpinner.fail(chalk.yellow(`Deep analysis failed: ${err.message}`));
269
+ }
270
+ } else if (!quiet) {
271
+ console.log(chalk.gray(' Deep analysis: no LLM provider found (set ANTHROPIC_API_KEY, MOONSHOT_API_KEY, or use --local)'));
272
+ }
273
+ }
274
+
275
+ // ── 9. Context-aware confidence tuning ──────────────────────────────────
276
+ allFindings = this.tuneConfidence(allFindings);
277
+
278
+ // ── 9.5 Governance absence-audits (no-human-oversight, no-observability)
279
+ try {
280
+ const { runGovernanceAudits } = await import('./governance-audits.js');
281
+ const governance = runGovernanceAudits({ rootPath: absolutePath, files, findings: allFindings });
282
+ allFindings = allFindings.concat(governance);
283
+ } catch { /* governance audits are additive — never break a scan */ }
284
+
285
+ // ── 10. Sort by severity ──────────────────────────────────────────────────
286
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
287
+ allFindings.sort((a, b) =>
288
+ (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4)
289
+ );
290
+
291
+ return { recon, findings: allFindings, agentResults };
292
+ }
293
+
294
+ /**
295
+ * Run only agents matching a specific category.
296
+ */
297
+ async runCategory(category, rootPath, options = {}) {
298
+ return this.runAll(rootPath, { ...options, categories: [category] });
299
+ }
300
+
301
+ /**
302
+ * Downgrade confidence for findings in test files, comments, docs, or examples.
303
+ * Reduces false-positive noise since ScoringEngine applies confidence multipliers.
304
+ */
305
+ tuneConfidence(findings) {
306
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
307
+ const DOC_EXT = new Set(['.md', '.txt', '.rst', '.adoc', '.rdoc']);
308
+ const EXAMPLE_PATH = /(?:\/examples?\/|\/samples?\/|\/demos?\/|\/fixtures?\/|\/mocks?\/)/i;
309
+ const COMMENT_LINE = /^\s*(?:\/\/|#|\/?\*|<!--)/;
310
+
311
+ for (const f of findings) {
312
+ const ext = (f.file || '').match(/\.[^.]+$/)?.[0]?.toLowerCase() || '';
313
+
314
+ // Findings in documentation files
315
+ if (DOC_EXT.has(ext)) {
316
+ f.confidence = 'low';
317
+ continue;
318
+ }
319
+
320
+ // Findings in test files
321
+ if (TEST_PATH.test(f.file || '')) {
322
+ f.confidence = 'low';
323
+ continue;
324
+ }
325
+
326
+ // Findings in example/sample/demo paths: high → medium
327
+ if (EXAMPLE_PATH.test(f.file || '') && f.confidence === 'high') {
328
+ f.confidence = 'medium';
329
+ continue;
330
+ }
331
+
332
+ // Findings on comment lines
333
+ if (f.matched && COMMENT_LINE.test(f.matched)) {
334
+ f.confidence = 'low';
335
+ }
336
+ }
337
+
338
+ return findings;
339
+ }
340
+
341
+ /**
342
+ * Remove duplicate findings (same file + line + rule).
343
+ */
344
+ deduplicate(findings) {
345
+ const seen = new Set();
346
+ return findings.filter(f => {
347
+ const key = `${f.file}:${f.line}:${f.rule}`;
348
+ if (seen.has(key)) return false;
349
+ seen.add(key);
350
+ return true;
351
+ });
352
+ }
353
+ }
354
+
355
+ export default Orchestrator;