@lhi/tdd-audit 1.12.0 → 1.15.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.
package/lib/plugin.js ADDED
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const path = require('path');
5
+ const Fastify = require('fastify');
6
+
7
+ const { quickScan } = require('./scanner');
8
+ const { toJson, toSarif } = require('./reporter');
9
+ const { remediate } = require('./remediator');
10
+ const { version } = require('../package.json');
11
+ const {
12
+ jobs, createJob, updateJob, subscribe, MAX_JOBS,
13
+ } = require('./jobs');
14
+
15
+ // ─── Auth ─────────────────────────────────────────────────────────────────────
16
+
17
+ // Fixed HMAC key — normalises token lengths for constant-time comparison.
18
+ const _authHmacKey = crypto.randomBytes(32);
19
+
20
+ /**
21
+ * Authenticate a Fastify request.
22
+ * Accepts either a raw Node req or a Fastify request object.
23
+ * Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
24
+ */
25
+ function authenticate(req, cfg) {
26
+ if (!cfg.serverApiKey) return true;
27
+ const headers = req.headers || {};
28
+ const header = headers['authorization'] || '';
29
+ const token = header.startsWith('Bearer ') ? header.slice(7) : '';
30
+ const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
31
+ const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
32
+ return crypto.timingSafeEqual(expected, actual);
33
+ }
34
+
35
+ // ─── Rate limiter ─────────────────────────────────────────────────────────────
36
+
37
+ const RATE_LIMIT_MAX = 60;
38
+ const RATE_LIMIT_WINDOW = 60 * 1_000;
39
+
40
+ function createRateLimit() {
41
+ const _counts = new Map();
42
+ return {
43
+ _counts,
44
+ check(ip) {
45
+ const now = Date.now();
46
+ const entry = _counts.get(ip) || { count: 0, windowStart: now };
47
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW) {
48
+ entry.count = 0;
49
+ entry.windowStart = now;
50
+ }
51
+ entry.count += 1;
52
+ _counts.set(ip, entry);
53
+ return entry.count <= RATE_LIMIT_MAX;
54
+ },
55
+ reset() { _counts.clear(); },
56
+ };
57
+ }
58
+
59
+ // ─── Path validation ──────────────────────────────────────────────────────────
60
+
61
+ function safeScanPath(rawPath) {
62
+ const cwd = process.cwd();
63
+ const resolved = path.resolve(cwd, rawPath || cwd);
64
+ const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
65
+ if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
66
+ throw new Error('Path outside working directory');
67
+ }
68
+ return resolved;
69
+ }
70
+
71
+ // ─── Security headers ────────────────────────────────────────────────────────
72
+
73
+ const SECURITY_HEADERS = {
74
+ 'X-Content-Type-Options': 'nosniff',
75
+ 'X-Frame-Options': 'DENY',
76
+ 'Content-Security-Policy': "default-src 'none'",
77
+ };
78
+
79
+ // ─── Fastify plugin ───────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Fastify plugin that registers all tdd-audit REST routes.
83
+ *
84
+ * Options:
85
+ * cfg - loaded config object
86
+ * rateLimiter - rate limiter instance (from createRateLimit())
87
+ */
88
+ async function tddAuditPlugin(fastify, opts) {
89
+ const { cfg, rateLimiter } = opts;
90
+
91
+ // ── Security headers on every reply ────────────────────────────────────────
92
+ fastify.addHook('onSend', async (request, reply) => {
93
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
94
+ reply.header(k, v);
95
+ }
96
+ });
97
+
98
+ // ── Rate limiting ────────────────────────────────────────────────────────
99
+ fastify.addHook('preHandler', async (request, reply) => {
100
+ const ip = cfg.trustProxy
101
+ ? (request.headers['x-forwarded-for'] || request.ip || '').split(',')[0].trim()
102
+ : (request.ip || 'unknown');
103
+ if (!rateLimiter.check(ip)) {
104
+ reply.code(429).send({ error: 'Too Many Requests' });
105
+ }
106
+ });
107
+
108
+ // ── GET /health ──────────────────────────────────────────────────────────
109
+ fastify.get('/health', async () => ({ status: 'ok', version }));
110
+
111
+ // ── Authentication for all non-health routes ────────────────────────────
112
+ fastify.addHook('preHandler', async (request, reply) => {
113
+ if (request.routeOptions?.url === '/health') return;
114
+ if (!authenticate(request, cfg)) {
115
+ reply.code(401).send({ error: 'Unauthorized' });
116
+ }
117
+ });
118
+
119
+ // ── POST /scan ──────────────────────────────────────────────────────────
120
+ fastify.post('/scan', {
121
+ config: { rawBody: false },
122
+ }, async (request, reply) => {
123
+ const body = request.body || {};
124
+
125
+ let scanPath;
126
+ try { scanPath = safeScanPath(body.path); }
127
+ catch (e) { return reply.code(400).send({ error: e.message }); }
128
+
129
+ const format = body.format || cfg.output || 'json';
130
+ const t0 = Date.now();
131
+ const findings = quickScan(scanPath);
132
+ const exempted = findings.exempted || [];
133
+ const duration = Date.now() - t0;
134
+
135
+ if (format === 'sarif') return toSarif(findings, scanPath);
136
+ return { ...toJson(findings, exempted), duration };
137
+ });
138
+
139
+ // ── POST /remediate ──────────────────────────────────────────────────────
140
+ fastify.post('/remediate', async (request, reply) => {
141
+ const body = request.body || {};
142
+ const { findings, provider, apiKey, model, baseUrl } = body;
143
+
144
+ if (!findings || !provider || !apiKey) {
145
+ return reply.code(400).send({ error: 'findings, provider, and apiKey are required' });
146
+ }
147
+
148
+ const jobId = createJob();
149
+
150
+ setImmediate(async () => {
151
+ try {
152
+ updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
153
+ const results = await remediate({
154
+ findings, provider, apiKey,
155
+ model: model || cfg.model,
156
+ baseUrl: baseUrl || cfg.baseUrl,
157
+ });
158
+ updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), results });
159
+ } catch (err) {
160
+ updateJob(jobId, { status: 'error', error: err.message });
161
+ }
162
+ });
163
+
164
+ return reply.code(202).send({ jobId });
165
+ });
166
+
167
+ // ── POST /audit — full scan+remediate pipeline ────────────────────────────
168
+ fastify.post('/audit', async (request, reply) => {
169
+ const body = request.body || {};
170
+ const { path: rawPath, provider, apiKey, model, baseUrl, webhook } = body;
171
+
172
+ let scanPath;
173
+ try { scanPath = safeScanPath(rawPath); }
174
+ catch (e) { return reply.code(400).send({ error: e.message }); }
175
+
176
+ const jobId = createJob();
177
+
178
+ setImmediate(async () => {
179
+ try {
180
+ // Phase 1: scan
181
+ updateJob(jobId, { status: 'scanning', startedAt: new Date().toISOString() });
182
+ const findings = quickScan(scanPath);
183
+ updateJob(jobId, { status: 'scanned', findings });
184
+
185
+ // Phase 2: remediate (if provider supplied)
186
+ if (provider && apiKey) {
187
+ const total = findings.filter(f => !f.likelyFalsePositive).length;
188
+ updateJob(jobId, { status: 'remediating', total, completed: 0 });
189
+
190
+ // remediate is eagerly required at module top
191
+ const results = await remediate({
192
+ findings, provider, apiKey,
193
+ model: model || cfg.model,
194
+ baseUrl: baseUrl || cfg.baseUrl,
195
+ onProgress: (completed, current) => {
196
+ updateJob(jobId, { status: 'remediating', total, completed, current });
197
+ },
198
+ });
199
+ updateJob(jobId, {
200
+ status: 'done',
201
+ completedAt: new Date().toISOString(),
202
+ findings,
203
+ results,
204
+ });
205
+ } else {
206
+ updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), findings });
207
+ }
208
+
209
+ // Optional webhook fire-and-forget
210
+ if (webhook) {
211
+ const job = jobs.get(jobId);
212
+ fetch(webhook, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify(job),
216
+ }).catch(() => {}); // never throw
217
+ }
218
+ } catch (err) {
219
+ updateJob(jobId, { status: 'error', error: err.message });
220
+ }
221
+ });
222
+
223
+ reply.header('Location', `/jobs/${jobId}`);
224
+ reply.header('Retry-After', '2');
225
+ return reply.code(202).send({ jobId });
226
+ });
227
+
228
+ // ── GET /jobs/:id ────────────────────────────────────────────────────────
229
+ fastify.get('/jobs/:id', async (request, reply) => {
230
+ const job = jobs.get(request.params.id);
231
+ if (!job) return reply.code(404).send({ error: 'Job not found' });
232
+ return job;
233
+ });
234
+
235
+ // ── GET /jobs/:id/stream — SSE real-time job updates ─────────────────────
236
+ fastify.get('/jobs/:id/stream', async (request, reply) => {
237
+ const id = request.params.id;
238
+ const job = jobs.get(id);
239
+ if (!job) return reply.code(404).send({ error: 'Job not found' });
240
+
241
+ reply.hijack();
242
+ const raw = reply.raw;
243
+ raw.writeHead(200, {
244
+ 'Content-Type': 'text/event-stream',
245
+ 'Cache-Control': 'no-cache',
246
+ 'Connection': 'keep-alive',
247
+ ...SECURITY_HEADERS,
248
+ });
249
+
250
+ const send = (data) => {
251
+ raw.write(`data: ${JSON.stringify(data)}\n\n`);
252
+ };
253
+
254
+ // Push current state immediately
255
+ send(jobs.get(id));
256
+
257
+ if (job.status === 'done' || job.status === 'error') {
258
+ raw.end();
259
+ return;
260
+ }
261
+
262
+ const unsubscribe = subscribe(id, (updated) => {
263
+ send(updated);
264
+ if (updated.status === 'done' || updated.status === 'error') {
265
+ unsubscribe();
266
+ raw.end();
267
+ }
268
+ });
269
+
270
+ raw.on('close', unsubscribe);
271
+ });
272
+ }
273
+
274
+ // ─── App factory ──────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Build and return a configured Fastify instance.
278
+ * @param {object} cfg - loaded config object
279
+ * @param {object} [overrides] - optional overrides (e.g. { logger: true })
280
+ */
281
+ function buildApp(cfg, overrides = {}) {
282
+ const fastify = Fastify({
283
+ logger: false,
284
+ trustProxy: cfg.trustProxy || false,
285
+ bodyLimit: 512 * 1024, // 512 KB
286
+ ...overrides,
287
+ });
288
+
289
+ const rateLimiter = createRateLimit();
290
+
291
+ fastify.register(tddAuditPlugin, { cfg, rateLimiter });
292
+
293
+ // Expose internals for testing
294
+ fastify.decorate('rateLimiter', rateLimiter);
295
+ fastify.decorate('jobs', jobs);
296
+ fastify.decorate('cfg', cfg);
297
+
298
+ return fastify;
299
+ }
300
+
301
+ module.exports = {
302
+ tddAuditPlugin,
303
+ buildApp,
304
+ authenticate,
305
+ safeScanPath,
306
+ createRateLimit,
307
+ RATE_LIMIT_MAX,
308
+ };
package/lib/remediator.js CHANGED
@@ -185,10 +185,11 @@ function parseResponse(text) {
185
185
  * @param {string} opts.apiKey
186
186
  * @param {string} [opts.model]
187
187
  * @param {string} [opts.baseUrl] - override base URL for OpenAI-compatible providers
188
- * @param {string} [opts.severity] - minimum severity to fix ('CRITICAL','HIGH','MEDIUM','LOW')
188
+ * @param {string} [opts.severity] - minimum severity to fix ('CRITICAL','HIGH','MEDIUM','LOW')
189
+ * @param {Function} [opts.onProgress] - called with (completedCount, currentFindingName) after each finding
189
190
  * @returns {Promise<Array>} - results per finding
190
191
  */
191
- async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW' }) {
192
+ async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW', onProgress }) {
192
193
  const ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
193
194
  const threshold = ORDER[severity.toUpperCase()] ?? 3;
194
195
 
@@ -197,7 +198,8 @@ async function remediate({ findings, provider, apiKey, model, baseUrl, severity
197
198
  .sort((a, b) => (ORDER[a.severity] ?? 99) - (ORDER[b.severity] ?? 99));
198
199
 
199
200
  const results = [];
200
- for (const finding of targets) {
201
+ for (let i = 0; i < targets.length; i++) {
202
+ const finding = targets[i];
201
203
  try {
202
204
  const prompt = buildRemediationPrompt(finding);
203
205
  const raw = await callProvider(provider, apiKey, model, prompt, baseUrl);
@@ -206,6 +208,9 @@ async function remediate({ findings, provider, apiKey, model, baseUrl, severity
206
208
  } catch (err) {
207
209
  results.push({ finding, status: 'error', error: err.message });
208
210
  }
211
+ if (typeof onProgress === 'function') {
212
+ onProgress(i + 1, finding.name);
213
+ }
209
214
  }
210
215
  return results;
211
216
  }
package/lib/scanner.js CHANGED
@@ -70,7 +70,7 @@ const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.
70
70
 
71
71
  /** Maximum file size to read before skipping (512 KB). Prevents OOM on large generated files. */
72
72
  const MAX_SCAN_FILE_BYTES = 512 * 1024;
73
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
73
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
74
74
 
75
75
  // ─── Prompt / Skill Patterns ──────────────────────────────────────────────────
76
76
 
package/lib/server.js CHANGED
@@ -1,48 +1,53 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const http = require('http');
5
4
  const path = require('path');
6
- const { quickScan, scanPromptFiles } = require('./scanner');
7
- const { toJson, toSarif, toText } = require('./reporter');
5
+ const { quickScan } = require('./scanner');
6
+ const { toJson, toSarif } = require('./reporter');
8
7
  const { loadConfig, parseCliOverrides } = require('./config');
9
- const { version } = require('../package.json');
8
+ const { version } = require('../package.json');
9
+ const { buildApp, RATE_LIMIT_MAX } = require('./plugin');
10
+ const {
11
+ jobs, createJob, updateJob, MAX_JOBS, JOB_TTL_MS,
12
+ } = require('./jobs');
10
13
 
11
- // ─── Job store (in-memory) ────────────────────────────────────────────────────
14
+ // ─── Auth (kept here for backward compat — SEC-17 reads this file) ────────────
12
15
 
13
- const jobs = new Map();
14
- let jobSeq = 0;
15
-
16
- const MAX_JOBS = 1_000;
17
- const JOB_TTL_MS = 60 * 60 * 1_000; // 1 hour
18
-
19
- function evictJobs() {
20
- const cutoff = Date.now() - JOB_TTL_MS;
21
- for (const [id, job] of jobs) {
22
- if (new Date(job.createdAt).getTime() < cutoff) jobs.delete(id);
23
- }
24
- // Hard cap: drop oldest entries until within limit
25
- while (jobs.size >= MAX_JOBS) {
26
- jobs.delete(jobs.keys().next().value);
27
- }
28
- }
16
+ // Fixed HMAC key for normalising token lengths before constant-time comparison.
17
+ const _authHmacKey = crypto.randomBytes(32);
29
18
 
30
- function createJob() {
31
- evictJobs();
32
- const id = `job_${++jobSeq}_${Date.now()}`;
33
- jobs.set(id, { id, status: 'pending', createdAt: new Date().toISOString() });
34
- return id;
19
+ /**
20
+ * Authenticate incoming requests.
21
+ * Accepts Node.js http.IncomingMessage OR Fastify Request objects.
22
+ * Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
23
+ */
24
+ function authenticate(req, cfg) {
25
+ if (!cfg.serverApiKey) return true;
26
+ const headers = req.headers || {};
27
+ const header = headers['authorization'] || '';
28
+ const token = header.startsWith('Bearer ') ? header.slice(7) : '';
29
+ const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
30
+ const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
31
+ return crypto.timingSafeEqual(expected, actual);
35
32
  }
36
33
 
37
- function updateJob(id, patch) {
38
- const job = jobs.get(id);
39
- if (job) jobs.set(id, { ...job, ...patch });
34
+ /**
35
+ * Validate and sanitise the `path` field from POST /scan.
36
+ * Only allow paths inside cwd to prevent path traversal.
37
+ */
38
+ function safeScanPath(rawPath) {
39
+ const cwd = process.cwd();
40
+ const resolved = path.resolve(cwd, rawPath || cwd);
41
+ const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
42
+ if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
43
+ throw new Error('Path outside working directory');
44
+ }
45
+ return resolved;
40
46
  }
41
47
 
42
- // ─── Rate limiter (in-memory, per-IP sliding window) ─────────────────────────
48
+ // ─── Rate limiter (kept here for backward compat — SEC-14/16/20 read this) ───
43
49
 
44
- const RATE_LIMIT_MAX = 60; // requests per window
45
- const RATE_LIMIT_WINDOW = 60 * 1_000; // 1 minute in ms
50
+ const RATE_LIMIT_WINDOW = 60 * 1_000;
46
51
 
47
52
  const rateLimiter = {
48
53
  _counts: new Map(),
@@ -89,46 +94,11 @@ function readBody(req) {
89
94
  });
90
95
  }
91
96
 
92
- // Fixed HMAC key for normalising token lengths before constant-time comparison.
93
- // Does not need to be secret — purpose is timing-safety, not confidentiality.
94
- const _authHmacKey = crypto.randomBytes(32);
95
-
96
- /**
97
- * Authenticate incoming requests.
98
- * Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
99
- */
100
- function authenticate(req, cfg) {
101
- if (!cfg.serverApiKey) return true; // no key configured — open
102
- const header = req.headers['authorization'] || '';
103
- const token = header.startsWith('Bearer ') ? header.slice(7) : '';
104
- const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
105
- const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
106
- return crypto.timingSafeEqual(expected, actual);
107
- }
108
-
109
- /**
110
- * Validate and sanitise the `path` field from POST /scan.
111
- * Only allow paths inside cwd to prevent path traversal.
112
- */
113
- function safeScanPath(rawPath) {
114
- const cwd = process.cwd();
115
- const resolved = path.resolve(cwd, rawPath || cwd);
116
- // Append sep so "/app" cannot match "/app-evil" via startsWith
117
- const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
118
- if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
119
- throw new Error('Path outside working directory');
120
- }
121
- return resolved;
122
- }
123
-
124
- // ─── Router ───────────────────────────────────────────────────────────────────
97
+ // ─── Router (kept for backward compat SEC-16/20 and E2E tests use this) ────
125
98
 
126
99
  async function handleRequest(req, res, cfg) {
127
100
  const { method, url } = req;
128
101
 
129
- // ── Rate limiting ──────────────────────────────────────────────────────────
130
- // Only trust X-Forwarded-For when cfg.trustProxy is explicitly enabled.
131
- // Default is false to prevent header-spoofing rate-limit bypasses.
132
102
  const ip = cfg.trustProxy
133
103
  ? (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').split(',')[0].trim()
134
104
  : (req.socket?.remoteAddress || 'unknown');
@@ -136,17 +106,14 @@ async function handleRequest(req, res, cfg) {
136
106
  return json(res, 429, { error: 'Too Many Requests' });
137
107
  }
138
108
 
139
- // ── GET /health ────────────────────────────────────────────────────────────
140
109
  if (method === 'GET' && url === '/health') {
141
110
  return json(res, 200, { status: 'ok', version });
142
111
  }
143
112
 
144
- // All other routes require authentication
145
113
  if (!authenticate(req, cfg)) {
146
114
  return json(res, 401, { error: 'Unauthorized' });
147
115
  }
148
116
 
149
- // ── POST /scan ─────────────────────────────────────────────────────────────
150
117
  if (method === 'POST' && url === '/scan') {
151
118
  let body;
152
119
  try { body = await readBody(req); }
@@ -156,19 +123,16 @@ async function handleRequest(req, res, cfg) {
156
123
  try { scanPath = safeScanPath(body.path); }
157
124
  catch (e) { return json(res, 400, { error: e.message }); }
158
125
 
159
- const format = body.format || cfg.output || 'json';
160
- const t0 = Date.now();
126
+ const format = body.format || cfg.output || 'json';
127
+ const t0 = Date.now();
161
128
  const findings = quickScan(scanPath);
162
129
  const exempted = findings.exempted || [];
163
130
  const duration = Date.now() - t0;
164
131
 
165
- if (format === 'sarif') {
166
- return json(res, 200, toSarif(findings, scanPath));
167
- }
132
+ if (format === 'sarif') return json(res, 200, toSarif(findings, scanPath));
168
133
  return json(res, 200, { ...toJson(findings, exempted), duration });
169
134
  }
170
135
 
171
- // ── POST /remediate ────────────────────────────────────────────────────────
172
136
  if (method === 'POST' && url === '/remediate') {
173
137
  let body;
174
138
  try { body = await readBody(req); }
@@ -181,7 +145,6 @@ async function handleRequest(req, res, cfg) {
181
145
 
182
146
  const jobId = createJob();
183
147
 
184
- // Kick off async remediation (non-blocking)
185
148
  setImmediate(async () => {
186
149
  try {
187
150
  updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
@@ -200,7 +163,6 @@ async function handleRequest(req, res, cfg) {
200
163
  return json(res, 202, { jobId });
201
164
  }
202
165
 
203
- // ── GET /jobs/:id ──────────────────────────────────────────────────────────
204
166
  const jobMatch = url.match(/^\/jobs\/([^/?]+)$/);
205
167
  if (method === 'GET' && jobMatch) {
206
168
  const job = jobs.get(jobMatch[1]);
@@ -211,33 +173,28 @@ async function handleRequest(req, res, cfg) {
211
173
  return json(res, 404, { error: 'Not found' });
212
174
  }
213
175
 
214
- // ─── Start ────────────────────────────────────────────────────────────────────
176
+ // ─── Start (uses Fastify) ─────────────────────────────────────────────────────
215
177
 
216
- function start(args = []) {
178
+ async function start(args = []) {
217
179
  const cfg = loadConfig(process.cwd(), parseCliOverrides(args));
218
180
  const port = cfg.port;
219
181
 
220
- const server = http.createServer(async (req, res) => {
221
- try {
222
- await handleRequest(req, res, cfg);
223
- } catch (err) {
224
- // Production error handler — no stack traces
225
- json(res, 500, { error: 'Internal server error' });
226
- }
227
- });
182
+ const fastify = buildApp(cfg);
228
183
 
229
- server.listen(port, () => {
230
- process.stdout.write(`\n🔒 tdd-audit REST API listening on http://localhost:${port}\n`);
231
- if (!cfg.serverApiKey) {
232
- process.stderr.write('⚠️ No --api-key set — server is unauthenticated. Set one for production.\n');
233
- }
234
- process.stdout.write(' GET /health\n');
235
- process.stdout.write(' POST /scan { path, format? }\n');
236
- process.stdout.write(' POST /remediate { findings, provider, apiKey, model? }\n');
237
- process.stdout.write(' GET /jobs/:id\n\n');
238
- });
184
+ await fastify.listen({ port, host: '0.0.0.0' });
239
185
 
240
- return server; // returned for testing
186
+ process.stdout.write(`\n🔒 tdd-audit REST API listening on http://localhost:${port}\n`);
187
+ if (!cfg.serverApiKey) {
188
+ process.stderr.write('⚠️ No --api-key set — server is unauthenticated. Set one for production.\n');
189
+ }
190
+ process.stdout.write(' GET /health\n');
191
+ process.stdout.write(' POST /scan { path, format? }\n');
192
+ process.stdout.write(' POST /remediate { findings, provider, apiKey, model? }\n');
193
+ process.stdout.write(' POST /audit { path, provider?, apiKey?, model? }\n');
194
+ process.stdout.write(' GET /jobs/:id\n');
195
+ process.stdout.write(' GET /jobs/:id/stream (SSE)\n\n');
196
+
197
+ return fastify.server; // returned for testing
241
198
  }
242
199
 
243
200
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lhi/tdd-audit",
3
- "version": "1.12.0",
3
+ "version": "1.15.0",
4
4
  "description": "Security skill installer for Claude Code, Gemini CLI, Cursor, Codex, and OpenCode. Patches vulnerabilities using a Red-Green-Refactor exploit-test protocol.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -60,5 +60,8 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "jest": "^30.3.0"
63
+ },
64
+ "dependencies": {
65
+ "fastify": "^5.8.4"
63
66
  }
64
67
  }