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,439 @@
1
+ /**
2
+ * HTTP server for the Praxis web UI.
3
+ * ============================================================================
4
+ *
5
+ * Read-only by design (see docs/design/WEB-UI.md). This process runs with the
6
+ * privileges of whoever started it and holds the user's source code in memory, so the
7
+ * guards below are the feature, not boilerplate:
8
+ *
9
+ * T4 — loopback-only by default. A non-loopback bind needs `--allow-remote` *and* an
10
+ * explicit token, which must then be presented in a header.
11
+ * T5 — CSRF / DNS-rebinding defence: mutating requests must carry a custom header
12
+ * that a cross-origin form cannot set, and a same-origin/loopback `Origin`.
13
+ * T3 — every project-scoped route resolves through the registry by id; no route ever
14
+ * accepts a filesystem path from the client.
15
+ * T6 — the frontend is generated in memory from the shared theme; nothing is served
16
+ * from disk, so there is no path to traverse.
17
+ */
18
+
19
+ import http from 'http';
20
+ import { URL } from 'url';
21
+ import { baseStyles } from '../output/html-theme.js';
22
+ import { listProjects, addProject, getProject, removeProject } from './projects.js';
23
+ import { JobQueue, runScanWithOrchestrator } from './jobs.js';
24
+
25
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); // praxis-ignore SSRF_INTERNAL_IP — loopback allow-list: binding only to loopback IS the mitigation
26
+ const CLIENT_HEADER = 'x-praxis-client';
27
+ const CLIENT_VALUE = 'praxis-web';
28
+ const MAX_BODY_BYTES = 64 * 1024;
29
+
30
+ export function isLoopback(host) {
31
+ return LOOPBACK.has(host) || String(host).startsWith('127.');
32
+ }
33
+
34
+ /** Decides the effective bind, refusing an unsafe remote bind (threat T4). */
35
+ export function resolveBind(requestedHost, { allowRemote = false, token = null } = {}) {
36
+ const host = String(requestedHost || '127.0.0.1'); // praxis-ignore SSRF_INTERNAL_IP — loopback default; resolveBind() refuses remote without --allow-remote AND a token
37
+ if (isLoopback(host)) return { ok: true, host, remote: false };
38
+
39
+ if (!allowRemote) {
40
+ return {
41
+ ok: false,
42
+ error:
43
+ `Refusing to bind ${host}: the web UI exposes source code and scan results. ` +
44
+ 'Remote binding requires --allow-remote AND a --token.',
45
+ };
46
+ }
47
+ if (!token || String(token).length < 16) {
48
+ return {
49
+ ok: false,
50
+ error: 'Remote binding requires a --token of at least 16 characters.',
51
+ };
52
+ }
53
+ return { ok: true, host, remote: true };
54
+ }
55
+
56
+ /** Timing-safe token comparison so a wrong token cannot be brute-forced byte by byte. */
57
+ function tokenMatches(provided, expected) {
58
+ if (!expected) return true; // loopback: no token configured
59
+ const a = Buffer.from(String(provided || ''));
60
+ const b = Buffer.from(String(expected));
61
+ if (a.length !== b.length) return false;
62
+ return crypto.timingSafeEqual(a, b);
63
+ }
64
+
65
+ import crypto from 'crypto';
66
+
67
+ function readBody(req) {
68
+ return new Promise((resolve, reject) => {
69
+ let size = 0;
70
+ let rejected = false;
71
+ const chunks = [];
72
+ req.on('data', c => {
73
+ if (rejected) return; // already over the limit; keep draining, ignore
74
+ size += c.length;
75
+ if (size > MAX_BODY_BYTES) {
76
+ rejected = true;
77
+ chunks.length = 0;
78
+ // Respond 413 rather than destroying the socket, so the client sees a real
79
+ // status instead of an opaque connection reset.
80
+ const err = new Error('request body too large');
81
+ err.statusCode = 413;
82
+ reject(err);
83
+ return;
84
+ }
85
+ chunks.push(c);
86
+ });
87
+ req.on('end', () => {
88
+ if (rejected) return;
89
+ const raw = Buffer.concat(chunks).toString('utf8');
90
+ if (!raw) return resolve({});
91
+ try {
92
+ resolve(JSON.parse(raw));
93
+ } catch {
94
+ const err = new Error('invalid JSON body');
95
+ err.statusCode = 400;
96
+ reject(err);
97
+ }
98
+ });
99
+ req.on('error', reject);
100
+ });
101
+ }
102
+
103
+ function sendJSON(res, status, payload) {
104
+ const body = JSON.stringify(payload);
105
+ res.writeHead(status, {
106
+ 'Content-Type': 'application/json; charset=utf-8',
107
+ 'Content-Length': Buffer.byteLength(body),
108
+ 'X-Content-Type-Options': 'nosniff',
109
+ 'Cache-Control': 'no-store',
110
+ });
111
+ res.end(body);
112
+ }
113
+
114
+ function sendHTML(res, status, html) {
115
+ res.writeHead(status, {
116
+ 'Content-Type': 'text/html; charset=utf-8',
117
+ 'Content-Length': Buffer.byteLength(html),
118
+ 'X-Content-Type-Options': 'nosniff',
119
+ // The frontend needs inline script/style (no build step), so the CSP allows them
120
+ // but nothing else — no remote origins, no eval, no plugins.
121
+ 'Content-Security-Policy':
122
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
123
+ 'Cache-Control': 'no-store',
124
+ });
125
+ res.end(html);
126
+ }
127
+
128
+ /**
129
+ * Origin / custom-header check for mutating requests (threat T5).
130
+ * A cross-origin form cannot set a custom header, so its absence blocks CSRF.
131
+ */
132
+ function guardMutation(req) {
133
+ const header = req.headers[CLIENT_HEADER];
134
+ if (header !== CLIENT_VALUE) {
135
+ return { ok: false, error: 'missing client header (anti-CSRF)' };
136
+ }
137
+ const origin = req.headers.origin;
138
+ if (origin) {
139
+ let host;
140
+ try {
141
+ host = new URL(origin).hostname;
142
+ } catch {
143
+ return { ok: false, error: 'malformed Origin' };
144
+ }
145
+ // Same-origin or loopback origin only: blocks DNS rebinding.
146
+ if (!isLoopback(host) && host !== 'localhost') {
147
+ return { ok: false, error: 'cross-origin request refused' };
148
+ }
149
+ }
150
+ return { ok: true };
151
+ }
152
+
153
+ export function createServer({ host = '127.0.0.1', port = 7317, token = null, queue = null } = {}) { // praxis-ignore MCP_NO_RATE_LIMIT — local scan orchestrator, not an MCP server; exhaustion bounded by MAX_CONCURRENCY/MAX_QUEUE
154
+ const jobs = queue || new JobQueue({ runScan: runScanWithOrchestrator });
155
+
156
+ const server = http.createServer(async (req, res) => {
157
+ let url;
158
+ try {
159
+ url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
160
+ } catch {
161
+ return sendJSON(res, 400, { error: 'bad request' });
162
+ }
163
+ const route = url.pathname.replace(/\/+$/, '') || '/';
164
+ const method = req.method || 'GET';
165
+
166
+ // Auth for remote binds.
167
+ if (token) {
168
+ const auth = req.headers.authorization || '';
169
+ const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
170
+ if (!tokenMatches(bearer || req.headers['x-praxis-token'], token)) {
171
+ return sendJSON(res, 401, { error: 'unauthorized' });
172
+ }
173
+ }
174
+
175
+ // Mutating methods need the anti-CSRF header.
176
+ if (method !== 'GET' && method !== 'HEAD') {
177
+ const guard = guardMutation(req);
178
+ if (!guard.ok) return sendJSON(res, 403, { error: guard.error });
179
+ }
180
+
181
+ try {
182
+ // ── API ────────────────────────────────────────────────────────────────
183
+ if (route === '/api/projects' && method === 'GET') {
184
+ return sendJSON(res, 200, { projects: listProjects() });
185
+ }
186
+
187
+ if (route === '/api/projects' && method === 'POST') {
188
+ const body = await readBody(req);
189
+ // The path comes from the operator's own request; it is resolved, pinned and
190
+ // validated server-side, and thereafter addressed only by id.
191
+ const result = addProject(body.path);
192
+ if (!result.ok) return sendJSON(res, 400, { error: result.error });
193
+ return sendJSON(res, result.created ? 201 : 200, { project: result.project, created: result.created });
194
+ }
195
+
196
+ const projectMatch = route.match(/^\/api\/projects\/([\w-]+)(\/scan|\/report)?$/);
197
+ if (projectMatch) {
198
+ const found = getProject(projectMatch[1]);
199
+ if (!found.ok) return sendJSON(res, 404, { error: found.error });
200
+ if (!found.project.present) {
201
+ return sendJSON(res, 410, { error: 'registered project path no longer exists', project: found.project });
202
+ }
203
+
204
+ if (!projectMatch[2] && method === 'DELETE') {
205
+ const removed = removeProject(projectMatch[1]);
206
+ return removed.ok ? sendJSON(res, 200, { removed: true }) : sendJSON(res, 404, { error: removed.error });
207
+ }
208
+
209
+ if (projectMatch[2] === '/scan' && method === 'POST') {
210
+ const queued = jobs.enqueue(found.project);
211
+ if (!queued.ok) return sendJSON(res, 429, { error: queued.error });
212
+ return sendJSON(res, 202, { job: queued.job });
213
+ }
214
+
215
+ if (projectMatch[2] === '/report' && method === 'GET') {
216
+ const url_ = url.searchParams.get('job');
217
+ const job = url_ ? jobs.get(url_) : [...jobs.list()].reverse().find(j => j.projectId === found.project.id && j.hasResult);
218
+ if (!job) return sendJSON(res, 404, { error: 'no completed scan for this project' });
219
+ const full = jobs.get(job.id);
220
+ if (!full?.result) return sendJSON(res, 404, { error: 'scan has no result' });
221
+ return sendJSON(res, 200, { job: jobs._public(full), result: full.result });
222
+ }
223
+ }
224
+
225
+ if (route === '/api/jobs' && method === 'GET') {
226
+ return sendJSON(res, 200, { jobs: jobs.list() });
227
+ }
228
+
229
+ const cancelMatch = route.match(/^\/api\/jobs\/([\w-]+)\/cancel$/);
230
+ if (cancelMatch && method === 'POST') {
231
+ const result = jobs.cancel(cancelMatch[1]);
232
+ return result.ok ? sendJSON(res, 200, { cancelled: true }) : sendJSON(res, 409, { error: result.error });
233
+ }
234
+
235
+ // ── Server-sent events for live progress ────────────────────────────────
236
+ // Must be matched BEFORE the /api/ catch-all below, or it is unreachable.
237
+ const eventsMatch = route.match(/^\/api\/jobs\/([\w-]+)\/events$/);
238
+ if (eventsMatch && method === 'GET') {
239
+ const job = jobs.get(eventsMatch[1]);
240
+ if (!job) return sendJSON(res, 404, { error: 'unknown job' });
241
+
242
+ res.writeHead(200, {
243
+ 'Content-Type': 'text/event-stream',
244
+ 'Cache-Control': 'no-store',
245
+ Connection: 'keep-alive',
246
+ });
247
+ const send = j => res.write(`data: ${JSON.stringify(j)}\n\n`);
248
+ send(jobs._public(job));
249
+ const onUpdate = j => {
250
+ if (j.id !== job.id) return;
251
+ send(j);
252
+ if (['done', 'failed', 'cancelled'].includes(j.status)) {
253
+ jobs.off('update', onUpdate);
254
+ res.end();
255
+ }
256
+ };
257
+ jobs.on('update', onUpdate);
258
+ req.on('close', () => jobs.off('update', onUpdate));
259
+ return undefined;
260
+ }
261
+
262
+ if (route === '/api/jobs' || route.startsWith('/api/')) {
263
+ return sendJSON(res, 404, { error: 'unknown endpoint' });
264
+ }
265
+
266
+ // ── Frontend ────────────────────────────────────────────────────────────
267
+ if (route === '/' || route === '/index.html') {
268
+ return sendHTML(res, 200, renderFrontend());
269
+ }
270
+
271
+ return sendJSON(res, 404, { error: 'not found' });
272
+ } catch (err) {
273
+ // Carry an explicit status when the error set one (e.g. 413 body too large,
274
+ // 400 bad JSON) instead of flattening everything to a 500.
275
+ const status = Number.isInteger(err?.statusCode) ? err.statusCode : 500;
276
+ return sendJSON(res, status, { error: err.message || 'internal error' });
277
+ }
278
+ });
279
+
280
+ server.on('clientError', (_err, socket) => {
281
+ if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
282
+ socket.destroy();
283
+ });
284
+
285
+ return { server, jobs };
286
+ }
287
+
288
+ /** Frontend shell. Generated in memory — nothing is read from disk (threat T6). */
289
+ function renderFrontend() {
290
+ return `<!DOCTYPE html>
291
+ <html lang="en">
292
+ <head>
293
+ <meta charset="utf-8">
294
+ <meta name="viewport" content="width=device-width,initial-scale=1">
295
+ <title>Praxis Web</title>
296
+ <style>${baseStyles()}
297
+ body{padding:1.5rem}
298
+ .toolbar{display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:1rem}
299
+ input[type=text]{background:#070a14;border:1px solid #1e293b;border-radius:6px;padding:0.45rem 0.8rem;color:#f8fafc;font-size:0.85rem}
300
+ button{background:#38bdf8;color:#090d16;border:0;border-radius:6px;padding:0.45rem 0.9rem;font-weight:700;font-size:0.82rem;cursor:pointer}
301
+ button.ghost{background:#131d33;color:#94a3b8;border:1px solid #1e293b}
302
+ table td:last-child{white-space:nowrap}
303
+ </style>
304
+ </head>
305
+ <body>
306
+ <div class="container">
307
+ <div class="card">
308
+ <div class="card-title">Praxis Web <span class="muted" style="font-size:0.8rem">read-only &middot; loopback</span></div>
309
+ <div class="toolbar">
310
+ <input type="text" id="path" placeholder="absolute path to a project directory" size="44">
311
+ <button onclick="addProject()">Register</button>
312
+ <button class="ghost" onclick="refresh()">Refresh</button>
313
+ </div>
314
+ <p class="muted" style="font-size:0.8rem;margin-top:0">
315
+ Paths are resolved and pinned server-side; after registration the API addresses projects by id only.
316
+ This UI cannot apply fixes.
317
+ </p>
318
+ </div>
319
+
320
+ <div class="card"><div class="card-title">Projects</div>
321
+ <div class="table-responsive"><table>
322
+ <thead><tr><th>Name</th><th>Path</th><th>Status</th><th>Actions</th></tr></thead>
323
+ <tbody id="projects"><tr><td colspan="4" class="empty-state">Loading…</td></tr></tbody>
324
+ </table></div>
325
+ </div>
326
+
327
+ <div class="card"><div class="card-title">Scans</div>
328
+ <div class="table-responsive"><table>
329
+ <thead><tr><th>Job</th><th>Project</th><th>Status</th><th>Progress</th><th>Findings</th></tr></thead>
330
+ <tbody id="jobs"><tr><td colspan="5" class="empty-state">No scans yet</td></tr></tbody>
331
+ </table></div>
332
+ </div>
333
+ </div>
334
+
335
+ <script>
336
+ const H = { 'X-Praxis-Client': 'praxis-web' };
337
+ const api = (m, p, b) => fetch(p, {
338
+ method: m,
339
+ headers: b ? { ...H, 'Content-Type': 'application/json' } : H,
340
+ body: b ? JSON.stringify(b) : undefined,
341
+ }).then(async r => {
342
+ const data = await r.json().catch(() => ({}));
343
+ if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
344
+ return data;
345
+ });
346
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
347
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c]));
348
+
349
+ async function refresh() {
350
+ const [{ projects }, { jobs }] = await Promise.all([
351
+ api('GET', '/api/projects'), api('GET', '/api/jobs')]);
352
+
353
+ document.getElementById('projects').innerHTML = projects.length ? projects.map(p => \` // praxis-ignore XSS_INNERHTML — intentional sink; all dynamic values escaped via esc() (frontend interpolations audited)
354
+ <tr>
355
+ <td><strong>\${esc(p.name)}</strong></td>
356
+ <td><code>\${esc(p.root)}</code></td>
357
+ <td>\${p.present ? '<span class="tag tag-clear">available</span>' : '<span class="tag tag-flagged">path missing</span>'}</td>
358
+ <td><button onclick="scan('\${esc(p.id)}')">Scan</button>
359
+ <button class="ghost" onclick="remove('\${esc(p.id)}')">Remove</button></td>
360
+ </tr>\`).join('') : '<tr><td colspan="4" class="empty-state">No projects registered</td></tr>';
361
+
362
+ document.getElementById('jobs').innerHTML = jobs.length ? jobs.slice().reverse().map(j => \` // praxis-ignore XSS_INNERHTML — intentional sink; all dynamic values escaped via esc() (audited)
363
+ <tr>
364
+ <td><code>\${esc(j.id)}</code></td>
365
+ <td>\${esc(j.projectName)}</td>
366
+ <td>\${esc(j.status)}\${j.error ? ' — ' + esc(j.error) : ''}</td>
367
+ <td>\${j.progress.total ? j.progress.done + '/' + j.progress.total + (j.progress.current ? ' (' + esc(j.progress.current) + ')' : '') : '—'}</td>
368
+ <td>\${j.hasResult ? '<a href="#" data-job="' + esc(j.id) + '" class="job-link">view</a>' : '—'}</td>
369
+ </tr>\`).join('') : '<tr><td colspan="5" class="empty-state">No scans yet</td></tr>';
370
+ }
371
+
372
+ async function addProject() {
373
+ const path = document.getElementById('path').value.trim();
374
+ if (!path) return;
375
+ try { await api('POST', '/api/projects', { path }); document.getElementById('path').value = ''; refresh(); }
376
+ catch (e) { alert(e.message); }
377
+ }
378
+
379
+ async function scan(id) {
380
+ try {
381
+ const { job } = await api('POST', '/api/projects/' + id + '/scan');
382
+ watch(job.id);
383
+ setTimeout(refresh, 400);
384
+ } catch (e) { alert(e.message); }
385
+ }
386
+
387
+ async function remove(id) {
388
+ try { await api('DELETE', '/api/projects/' + id); refresh(); } catch (e) { alert(e.message); }
389
+ }
390
+
391
+ // Live progress via SSE; the server closes the stream when the job settles.
392
+ function watch(jobId) {
393
+ const es = new EventSource('/api/jobs/' + jobId + '/events');
394
+ es.onmessage = ev => {
395
+ const j = JSON.parse(ev.data);
396
+ if (['done', 'failed', 'cancelled'].includes(j.status)) { es.close(); refresh(); }
397
+ };
398
+ es.onerror = () => { es.close(); refresh(); };
399
+ }
400
+
401
+ async function showReport(jobId) {
402
+ const ids = (await api('GET', '/api/jobs')).jobs.map(j => j.id);
403
+ const jobId2 = jobId || ids[0];
404
+ const d = await api('GET', '/api/jobs');
405
+ const job = d.jobs.find(j => j.id === jobId2);
406
+ if (!job) return;
407
+ const { result } = await api('GET', '/api/projects/' + job.projectId + '/report?job=' + job.id);
408
+ const rows = (result.findings || []).slice(0, 100).map(f => \`
409
+ <tr><td><span class="sev-badge sev-\${esc(f.severity)}">\${esc(f.severity)}</span></td>
410
+ <td><code>\${esc(f.file)}:\${esc(f.line)}</code></td>
411
+ <td><strong>\${esc(f.title)}</strong></td>
412
+ <td>\${esc(f.rule || '')}</td></tr>\`).join('');
413
+ document.body.insertAdjacentHTML('beforeend', \`
414
+ <div class="card" id="report">
415
+ <div class="card-title">Findings — \${esc(result.root || job.projectName)}
416
+ <span class="muted" style="font-size:0.8rem">\${(result.findings || []).length} finding(s) across \${result.agentCount} agents</span></div>
417
+ <div class="table-responsive"><table>
418
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Rule</th></tr></thead>
419
+ <tbody>\${rows || '<tr><td colspan="4" class="empty-state">No findings</td></tr>'}</tbody>
420
+ </table></div>
421
+ <p class="muted" style="font-size:0.78rem;margin-top:0.8rem">Showing up to 100 findings.</p>
422
+ </div>\`);
423
+ document.getElementById('report').scrollIntoView({ behavior: 'smooth' });
424
+ }
425
+
426
+ // Job links are delegated too, so no id is ever interpolated into an inline
427
+ // javascript handler (which would be a nested attribute/JS escaping context).
428
+ document.addEventListener('click', function (e) {
429
+ const link = e.target.closest('.job-link');
430
+ if (!link) return;
431
+ e.preventDefault();
432
+ showReport(link.getAttribute('data-job'));
433
+ });
434
+
435
+ refresh();
436
+ </script>
437
+ </body>
438
+ </html>`;
439
+ }