praxis-sec 1.0.0 → 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.
@@ -0,0 +1,135 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "generatedAt": "2026-10-02T18:43:15.311Z",
4
+ "rootPath": "C:\\STUDY\\Github\\CADRE-Platform\\Praxis\\cli\\core\\web",
5
+ "recon": {
6
+ "frameworks": [],
7
+ "languages": [
8
+ "javascript"
9
+ ],
10
+ "apiRoutes": [],
11
+ "authPatterns": [],
12
+ "databases": [],
13
+ "cloudProviders": [],
14
+ "frontendExposure": [],
15
+ "packageManagers": [],
16
+ "cicd": [],
17
+ "hasDockerfile": false,
18
+ "hasTerraform": false,
19
+ "hasKubernetes": false,
20
+ "envFiles": [],
21
+ "configFiles": [],
22
+ "hasModelFiles": false,
23
+ "modelFiles": []
24
+ },
25
+ "fileIndex": {
26
+ "jobs.js": {
27
+ "hash": "91bef088c5f2d713300063ee65b0f6065e51a29432a96a7ac7e101cec05fcd19",
28
+ "size": 7025,
29
+ "lastScanned": "2026-10-02T18:43:15.310Z"
30
+ },
31
+ "projects.js": {
32
+ "hash": "6961f8ee3ea4a1d18620cd4d2ab802ddc5db17db106743990ac91af870b006c5",
33
+ "size": 5118,
34
+ "lastScanned": "2026-10-02T18:43:15.310Z"
35
+ },
36
+ "server.js": {
37
+ "hash": "97f1e8c34d791eaf1c62f8b908ef75025b91474f5beebe4ba2c8ffabe3509cd6",
38
+ "size": 12526,
39
+ "lastScanned": "2026-10-02T18:43:15.310Z"
40
+ },
41
+ "ui.js": {
42
+ "hash": "d1d44980df3ce9b1b7ca344f6f07aa7d3efc63b8e60e3f6dbf561ad2a4ff6c59",
43
+ "size": 75352,
44
+ "lastScanned": "2026-10-02T18:43:15.310Z"
45
+ }
46
+ },
47
+ "lastFindings": {
48
+ "cli/__tests__/ast-engine.test.js": [
49
+ {
50
+ "line": 0,
51
+ "column": 0,
52
+ "severity": "high",
53
+ "category": "history",
54
+ "rule": "GIT_HISTORY_SECRET",
55
+ "title": "Historical Secret: Generic API Key Assignment",
56
+ "description": "Secret was removed from code but still exists in git history (commit 322700ad27, Tue Aug 18 11:19:47 2026 +0530). Anyone with repo access can retrieve it.",
57
+ "matched": "API_KEY ***123\"",
58
+ "confidence": "low",
59
+ "cwe": null,
60
+ "owasp": null,
61
+ "fix": "Rotate this credential immediately, then clean history: npx bfg --replace-text passwords.txt"
62
+ }
63
+ ],
64
+ "server.js": [
65
+ {
66
+ "line": 0,
67
+ "column": 0,
68
+ "severity": "medium",
69
+ "category": "api",
70
+ "rule": "API_NO_RATE_LIMIT",
71
+ "title": "API: No Rate Limiting Detected",
72
+ "description": "HTTP server detected without any rate-limiting library. APIs without rate limits are vulnerable to brute-force and DoS attacks.",
73
+ "matched": "No rate-limiting middleware found",
74
+ "confidence": "medium",
75
+ "cwe": "CWE-307",
76
+ "owasp": "A07:2021",
77
+ "fix": "Add rate limiting: npm i express-rate-limit && app.use(rateLimit({ windowMs: 15*60*1000, max: 100 }))"
78
+ }
79
+ ],
80
+ "../../../../../../../Users/Ganro/.cursor/mcp.json": [
81
+ {
82
+ "line": 1,
83
+ "column": 0,
84
+ "severity": "medium",
85
+ "category": "llm",
86
+ "rule": "MCP_SHADOW_CONFIG",
87
+ "title": "MCP: 1 Shadow Server(s) in User Config",
88
+ "description": "Found 1 MCP server(s) configured outside the project in C:\\Users\\Ganro\\.cursor\\mcp.json. These operate outside your project's security controls.",
89
+ "matched": "redstrike",
90
+ "confidence": "medium",
91
+ "cwe": "CWE-269",
92
+ "owasp": "A05:2021",
93
+ "fix": "Review shadow MCP servers. Move project-specific servers to the project mcp.json and track in version control."
94
+ }
95
+ ],
96
+ "ui.js": [
97
+ {
98
+ "line": 29,
99
+ "column": 108,
100
+ "severity": "medium",
101
+ "category": "llm",
102
+ "rule": "AGENT_MEMORY_NO_EXPIRY",
103
+ "title": "Agent: Persistent Memory Without Expiration",
104
+ "description": "Agent memory persists without expiration policy. Poisoned memories remain indefinitely.",
105
+ "matched": "memory contexts and session retention for persist",
106
+ "confidence": "low",
107
+ "cwe": "CWE-404",
108
+ "owasp": "A04:2021",
109
+ "fix": "Set TTL or retention policies on agent memory. Implement periodic cleanup of stale entries."
110
+ }
111
+ ],
112
+ "observability": [
113
+ {
114
+ "line": 1,
115
+ "column": 0,
116
+ "severity": "medium",
117
+ "category": "llm",
118
+ "rule": "NO_OBSERVABILITY",
119
+ "title": "AI in Use Without Observability/Tracing",
120
+ "description": "This project calls LLMs, but no AI tracing/observability wiring (LangSmith, Langfuse, Helicone, OpenTelemetry, ...) was found outside dependency manifests. Without traces you cannot detect prompt-injection events, monitor output quality, or respond to AI incidents — EU AI Act Art. 12 / ISO 42001 A.6.2.6.",
121
+ "matched": "no tracing wiring in 4 evidence file(s)",
122
+ "confidence": "medium",
123
+ "cwe": "CWE-778",
124
+ "owasp": "ASI03",
125
+ "fix": "Wire an AI tracing/observability stack (e.g., Langfuse, Helicone, or OpenTelemetry) and log prompts, tool calls, and outputs with retention."
126
+ }
127
+ ]
128
+ },
129
+ "stats": {
130
+ "totalFiles": 4,
131
+ "totalFindings": 5,
132
+ "lastScore": 89.2,
133
+ "lastGrade": "B"
134
+ }
135
+ }
@@ -0,0 +1,93 @@
1
+ [
2
+ {
3
+ "timestamp": "2026-10-02T18:42:07.164Z",
4
+ "score": 89.2,
5
+ "grade": "B",
6
+ "totalFindings": 5,
7
+ "totalDepVulns": 0,
8
+ "categoryScores": {
9
+ "secrets": {
10
+ "deduction": 4.5,
11
+ "counts": {
12
+ "critical": 0,
13
+ "high": 1,
14
+ "medium": 0,
15
+ "low": 0
16
+ }
17
+ },
18
+ "injection": {
19
+ "deduction": 0,
20
+ "counts": {
21
+ "critical": 0,
22
+ "high": 0,
23
+ "medium": 0,
24
+ "low": 0
25
+ }
26
+ },
27
+ "deps": {
28
+ "deduction": 0,
29
+ "counts": {
30
+ "critical": 0,
31
+ "high": 0,
32
+ "medium": 0,
33
+ "low": 0
34
+ }
35
+ },
36
+ "auth": {
37
+ "deduction": 0,
38
+ "counts": {
39
+ "critical": 0,
40
+ "high": 0,
41
+ "medium": 0,
42
+ "low": 0
43
+ }
44
+ },
45
+ "config": {
46
+ "deduction": 0,
47
+ "counts": {
48
+ "critical": 0,
49
+ "high": 0,
50
+ "medium": 0,
51
+ "low": 0
52
+ }
53
+ },
54
+ "supply-chain": {
55
+ "deduction": 0,
56
+ "counts": {
57
+ "critical": 0,
58
+ "high": 0,
59
+ "medium": 0,
60
+ "low": 0
61
+ }
62
+ },
63
+ "api": {
64
+ "deduction": 1.7999999999999998,
65
+ "counts": {
66
+ "critical": 0,
67
+ "high": 0,
68
+ "medium": 1,
69
+ "low": 0
70
+ }
71
+ },
72
+ "llm": {
73
+ "deduction": 4.5,
74
+ "counts": {
75
+ "critical": 0,
76
+ "high": 0,
77
+ "medium": 3,
78
+ "low": 0
79
+ }
80
+ }
81
+ },
82
+ "suppressions": {
83
+ "total": 6,
84
+ "rules": {
85
+ "AGENT_MEMORY_NO_EXPIRY": 1,
86
+ "AGENT_ESCALATED_PERMISSIONS": 1,
87
+ "SSRF_INTERNAL_IP": 2,
88
+ "MCP_NO_RATE_LIMIT": 1,
89
+ "XSS_INNERHTML": 1
90
+ }
91
+ }
92
+ }
93
+ ]
@@ -0,0 +1 @@
1
+ {}
@@ -91,7 +91,7 @@ export class JobQueue extends EventEmitter {
91
91
  }
92
92
 
93
93
  _public(job) {
94
- const { result, root, ...rest } = job;
94
+ const { result, root: _root, ...rest } = job;
95
95
  return { ...rest, hasResult: Boolean(result) };
96
96
  }
97
97
 
@@ -155,7 +155,7 @@ export class JobQueue extends EventEmitter {
155
155
 
156
156
  /**
157
157
  * Default scan runner: drives the real Orchestrator so the UI cannot drift from the CLI.
158
- * Returns the same shape the CLI's JSON output produces.
158
+ * Returns the same shape the CLI's JSON output produces, enriched with score and grade.
159
159
  */
160
160
  export async function runScanWithOrchestrator(rootPath, onProgress) {
161
161
  const { buildOrchestratorAsync } = await import('../../agents/index.js');
@@ -172,6 +172,24 @@ export async function runScanWithOrchestrator(rootPath, onProgress) {
172
172
  },
173
173
  });
174
174
 
175
+ let score = 100;
176
+ let grade = 'A';
177
+ let categories = {};
178
+ let standardsSummary = null;
179
+ try {
180
+ const { ScoringEngine } = await import('../../agents/scoring-engine.js');
181
+ const engine = new ScoringEngine();
182
+ const computed = engine.compute(findings || []);
183
+ if (computed) {
184
+ score = computed.score ?? 100;
185
+ grade = computed.grade?.letter || (typeof computed.grade === 'string' ? computed.grade : 'A');
186
+ categories = computed.categories || {};
187
+ standardsSummary = computed.standardsSummary || null;
188
+ }
189
+ } catch {
190
+ // fallback gracefully
191
+ }
192
+
175
193
  return {
176
194
  root: path.basename(rootPath),
177
195
  agentCount: (agentResults || []).length,
@@ -179,5 +197,10 @@ export async function runScanWithOrchestrator(rootPath, onProgress) {
179
197
  agents: agentResults || [],
180
198
  recon: recon || null,
181
199
  findings: findings || [],
200
+ score,
201
+ grade,
202
+ categories,
203
+ standardsSummary,
204
+ scannedAt: new Date().toISOString(),
182
205
  };
183
206
  }
@@ -18,9 +18,9 @@
18
18
 
19
19
  import http from 'http';
20
20
  import { URL } from 'url';
21
- import { baseStyles } from '../output/html-theme.js';
22
21
  import { listProjects, addProject, getProject, removeProject } from './projects.js';
23
22
  import { JobQueue, runScanWithOrchestrator } from './jobs.js';
23
+ import { renderFrontend, AGENT_ROSTER } from './ui.js';
24
24
 
25
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
26
  const CLIENT_HEADER = 'x-praxis-client';
@@ -150,10 +150,58 @@ function guardMutation(req) {
150
150
  return { ok: true };
151
151
  }
152
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
153
+ /**
154
+ * In-memory sliding-window rate limiter (API_NO_RATE_LIMIT defense).
155
+ * Bounds request frequency per IP to prevent resource exhaustion and local/remote DoS.
156
+ */
157
+ export function createRateLimiter({ windowMs = 60 * 1000, max = 180 } = {}) {
158
+ const hits = new Map();
159
+
160
+ const timer = setInterval(() => {
161
+ const now = Date.now();
162
+ for (const [ip, timestamps] of hits.entries()) {
163
+ const valid = timestamps.filter(t => now - t < windowMs);
164
+ if (valid.length === 0) hits.delete(ip);
165
+ else hits.set(ip, valid);
166
+ }
167
+ }, windowMs);
168
+ if (timer.unref) timer.unref();
169
+
170
+ return function rateLimit(req, res) {
171
+ const ip = req.socket?.remoteAddress || '127.0.0.1'; // praxis-ignore SSRF_INTERNAL_IP — fallback client ip for local rate limiter
172
+ const now = Date.now();
173
+ const timestamps = hits.get(ip) || [];
174
+ const valid = timestamps.filter(t => now - t < windowMs);
175
+
176
+ if (valid.length >= max) {
177
+ const oldest = valid[0];
178
+ const retryAfter = Math.ceil((windowMs - (now - oldest)) / 1000) || 1;
179
+ res.writeHead(429, {
180
+ 'Content-Type': 'application/json; charset=utf-8',
181
+ 'Retry-After': String(retryAfter),
182
+ 'X-Content-Type-Options': 'nosniff',
183
+ 'Cache-Control': 'no-store',
184
+ });
185
+ res.end(JSON.stringify({ error: 'Too Many Requests', retryAfter }));
186
+ return false;
187
+ }
188
+
189
+ valid.push(now);
190
+ hits.set(ip, valid);
191
+ return true;
192
+ };
193
+ }
194
+
195
+ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, token = null, queue = null, rateLimiter = null } = {}) { // praxis-ignore MCP_NO_RATE_LIMIT — local scan orchestrator, not an MCP server; exhaustion bounded by MAX_CONCURRENCY/MAX_QUEUE
154
196
  const jobs = queue || new JobQueue({ runScan: runScanWithOrchestrator });
197
+ const checkRateLimit = rateLimiter || createRateLimiter();
155
198
 
156
199
  const server = http.createServer(async (req, res) => {
200
+ // Sliding-window rate limit defense against brute-force & flood DoS
201
+ if (checkRateLimit && !checkRateLimit(req, res)) {
202
+ return;
203
+ }
204
+
157
205
  let url;
158
206
  try {
159
207
  url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
@@ -226,12 +274,31 @@ export function createServer({ host = '127.0.0.1', port = 7317, token = null, qu
226
274
  return sendJSON(res, 200, { jobs: jobs.list() });
227
275
  }
228
276
 
277
+ const jobReportMatch = route.match(/^\/api\/jobs\/([\w-]+)\/report$/);
278
+ if (jobReportMatch && method === 'GET') {
279
+ const full = jobs.get(jobReportMatch[1]);
280
+ if (!full) return sendJSON(res, 404, { error: 'unknown job' });
281
+ if (!full.result) return sendJSON(res, 404, { error: 'scan has no result' });
282
+ return sendJSON(res, 200, { job: jobs._public(full), result: full.result });
283
+ }
284
+
285
+ const jobMatch = route.match(/^\/api\/jobs\/([\w-]+)$/);
286
+ if (jobMatch && method === 'GET') {
287
+ const full = jobs.get(jobMatch[1]);
288
+ if (!full) return sendJSON(res, 404, { error: 'unknown job' });
289
+ return sendJSON(res, 200, { job: jobs._public(full) });
290
+ }
291
+
229
292
  const cancelMatch = route.match(/^\/api\/jobs\/([\w-]+)\/cancel$/);
230
293
  if (cancelMatch && method === 'POST') {
231
294
  const result = jobs.cancel(cancelMatch[1]);
232
295
  return result.ok ? sendJSON(res, 200, { cancelled: true }) : sendJSON(res, 409, { error: result.error });
233
296
  }
234
297
 
298
+ if (route === '/api/agents' && method === 'GET') {
299
+ return sendJSON(res, 200, { agents: AGENT_ROSTER });
300
+ }
301
+
235
302
  // ── Server-sent events for live progress ────────────────────────────────
236
303
  // Must be matched BEFORE the /api/ catch-all below, or it is unreachable.
237
304
  const eventsMatch = route.match(/^\/api\/jobs\/([\w-]+)\/events$/);
@@ -283,157 +350,4 @@ export function createServer({ host = '127.0.0.1', port = 7317, token = null, qu
283
350
  });
284
351
 
285
352
  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
353
  }