@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/README.md +30 -5
- package/docs/rest-api.md +185 -35
- package/docs/scanner.md +13 -9
- package/index.js +5 -0
- package/lib/badge.js +94 -0
- package/lib/jobs.js +53 -0
- package/lib/plugin.js +308 -0
- package/lib/remediator.js +8 -3
- package/lib/scanner.js +1 -1
- package/lib/server.js +57 -100
- package/package.json +4 -1
- package/prompts/auto-audit.md +109 -0
- package/prompts/hardening-phase.md +91 -0
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}
|
|
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 (
|
|
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
|
|
7
|
-
const { toJson, toSarif
|
|
5
|
+
const { quickScan } = require('./scanner');
|
|
6
|
+
const { toJson, toSarif } = require('./reporter');
|
|
8
7
|
const { loadConfig, parseCliOverrides } = require('./config');
|
|
9
|
-
const { version }
|
|
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
|
-
// ───
|
|
14
|
+
// ─── Auth (kept here for backward compat — SEC-17 reads this file) ────────────
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 (
|
|
48
|
+
// ─── Rate limiter (kept here for backward compat — SEC-14/16/20 read this) ───
|
|
43
49
|
|
|
44
|
-
const
|
|
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
|
-
//
|
|
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
|
|
160
|
-
const t0
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|