@lhi/tdd-audit 1.8.4 → 1.10.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 +81 -2
- package/docs/ai-remediation.md +182 -0
- package/docs/rest-api.md +230 -0
- package/index.js +52 -7
- package/lib/config.js +116 -0
- package/lib/github.js +93 -0
- package/lib/remediator.js +181 -0
- package/lib/reporter.js +164 -0
- package/lib/server.js +247 -0
- package/package.json +1 -1
package/lib/server.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { quickScan, scanPromptFiles } = require('./scanner');
|
|
7
|
+
const { toJson, toSarif, toText } = require('./reporter');
|
|
8
|
+
const { loadConfig, parseCliOverrides } = require('./config');
|
|
9
|
+
const { version } = require('../package.json');
|
|
10
|
+
|
|
11
|
+
// ─── Job store (in-memory) ────────────────────────────────────────────────────
|
|
12
|
+
|
|
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
|
+
}
|
|
29
|
+
|
|
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;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function updateJob(id, patch) {
|
|
38
|
+
const job = jobs.get(id);
|
|
39
|
+
if (job) jobs.set(id, { ...job, ...patch });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Rate limiter (in-memory, per-IP sliding window) ─────────────────────────
|
|
43
|
+
|
|
44
|
+
const RATE_LIMIT_MAX = 60; // requests per window
|
|
45
|
+
const RATE_LIMIT_WINDOW = 60 * 1_000; // 1 minute in ms
|
|
46
|
+
|
|
47
|
+
const rateLimiter = {
|
|
48
|
+
_counts: new Map(),
|
|
49
|
+
check(ip) {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const entry = this._counts.get(ip) || { count: 0, windowStart: now };
|
|
52
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW) {
|
|
53
|
+
entry.count = 0;
|
|
54
|
+
entry.windowStart = now;
|
|
55
|
+
}
|
|
56
|
+
entry.count += 1;
|
|
57
|
+
this._counts.set(ip, entry);
|
|
58
|
+
return entry.count <= RATE_LIMIT_MAX;
|
|
59
|
+
},
|
|
60
|
+
reset() { this._counts.clear(); },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function json(res, status, body) {
|
|
66
|
+
const payload = JSON.stringify(body);
|
|
67
|
+
res.writeHead(status, {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
70
|
+
'X-Content-Type-Options': 'nosniff',
|
|
71
|
+
'X-Frame-Options': 'DENY',
|
|
72
|
+
});
|
|
73
|
+
res.end(payload);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readBody(req) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let data = '';
|
|
79
|
+
req.on('data', chunk => {
|
|
80
|
+
data += chunk;
|
|
81
|
+
if (data.length > 1024 * 512) reject(new Error('Request body too large'));
|
|
82
|
+
});
|
|
83
|
+
req.on('end', () => {
|
|
84
|
+
try { resolve(JSON.parse(data || '{}')); }
|
|
85
|
+
catch { reject(new Error('Invalid JSON body')); }
|
|
86
|
+
});
|
|
87
|
+
req.on('error', reject);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fixed HMAC key for normalising token lengths before constant-time comparison.
|
|
92
|
+
// Does not need to be secret — purpose is timing-safety, not confidentiality.
|
|
93
|
+
const _authHmacKey = crypto.randomBytes(32);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Authenticate incoming requests.
|
|
97
|
+
* Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
|
|
98
|
+
*/
|
|
99
|
+
function authenticate(req, cfg) {
|
|
100
|
+
if (!cfg.serverApiKey) return true; // no key configured — open
|
|
101
|
+
const header = req.headers['authorization'] || '';
|
|
102
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
103
|
+
const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
|
|
104
|
+
const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
|
|
105
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate and sanitise the `path` field from POST /scan.
|
|
110
|
+
* Only allow paths inside cwd to prevent path traversal.
|
|
111
|
+
*/
|
|
112
|
+
function safeScanPath(rawPath) {
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
const resolved = path.resolve(cwd, rawPath || cwd);
|
|
115
|
+
// Append sep so "/app" cannot match "/app-evil" via startsWith
|
|
116
|
+
const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
117
|
+
if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
|
|
118
|
+
throw new Error('Path outside working directory');
|
|
119
|
+
}
|
|
120
|
+
return resolved;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Router ───────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async function handleRequest(req, res, cfg) {
|
|
126
|
+
const { method, url } = req;
|
|
127
|
+
|
|
128
|
+
// ── Rate limiting ──────────────────────────────────────────────────────────
|
|
129
|
+
// Only trust X-Forwarded-For when cfg.trustProxy is explicitly enabled.
|
|
130
|
+
// Default is false to prevent header-spoofing rate-limit bypasses.
|
|
131
|
+
const ip = cfg.trustProxy
|
|
132
|
+
? (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').split(',')[0].trim()
|
|
133
|
+
: (req.socket?.remoteAddress || 'unknown');
|
|
134
|
+
if (!rateLimiter.check(ip)) {
|
|
135
|
+
return json(res, 429, { error: 'Too Many Requests' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── GET /health ────────────────────────────────────────────────────────────
|
|
139
|
+
if (method === 'GET' && url === '/health') {
|
|
140
|
+
return json(res, 200, { status: 'ok', version });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// All other routes require authentication
|
|
144
|
+
if (!authenticate(req, cfg)) {
|
|
145
|
+
return json(res, 401, { error: 'Unauthorized' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── POST /scan ─────────────────────────────────────────────────────────────
|
|
149
|
+
if (method === 'POST' && url === '/scan') {
|
|
150
|
+
let body;
|
|
151
|
+
try { body = await readBody(req); }
|
|
152
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
153
|
+
|
|
154
|
+
let scanPath;
|
|
155
|
+
try { scanPath = safeScanPath(body.path); }
|
|
156
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
157
|
+
|
|
158
|
+
const format = body.format || cfg.output || 'json';
|
|
159
|
+
const t0 = Date.now();
|
|
160
|
+
const findings = quickScan(scanPath);
|
|
161
|
+
const exempted = findings.exempted || [];
|
|
162
|
+
const duration = Date.now() - t0;
|
|
163
|
+
|
|
164
|
+
if (format === 'sarif') {
|
|
165
|
+
return json(res, 200, toSarif(findings, scanPath));
|
|
166
|
+
}
|
|
167
|
+
return json(res, 200, { ...toJson(findings, exempted), duration });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── POST /remediate ────────────────────────────────────────────────────────
|
|
171
|
+
if (method === 'POST' && url === '/remediate') {
|
|
172
|
+
let body;
|
|
173
|
+
try { body = await readBody(req); }
|
|
174
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
175
|
+
|
|
176
|
+
const { findings, provider, apiKey, model, baseUrl } = body;
|
|
177
|
+
if (!findings || !provider || !apiKey) {
|
|
178
|
+
return json(res, 400, { error: 'findings, provider, and apiKey are required' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const jobId = createJob();
|
|
182
|
+
|
|
183
|
+
// Kick off async remediation (non-blocking)
|
|
184
|
+
setImmediate(async () => {
|
|
185
|
+
try {
|
|
186
|
+
updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
|
|
187
|
+
const { remediate } = require('./remediator');
|
|
188
|
+
const results = await remediate({
|
|
189
|
+
findings, provider, apiKey,
|
|
190
|
+
model: model || cfg.model,
|
|
191
|
+
baseUrl: baseUrl || cfg.baseUrl,
|
|
192
|
+
});
|
|
193
|
+
updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), results });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
updateJob(jobId, { status: 'error', error: err.message });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return json(res, 202, { jobId });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── GET /jobs/:id ──────────────────────────────────────────────────────────
|
|
203
|
+
const jobMatch = url.match(/^\/jobs\/([^/?]+)$/);
|
|
204
|
+
if (method === 'GET' && jobMatch) {
|
|
205
|
+
const job = jobs.get(jobMatch[1]);
|
|
206
|
+
if (!job) return json(res, 404, { error: 'Job not found' });
|
|
207
|
+
return json(res, 200, job);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return json(res, 404, { error: 'Not found' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function start(args = []) {
|
|
216
|
+
const cfg = loadConfig(process.cwd(), parseCliOverrides(args));
|
|
217
|
+
const port = cfg.port;
|
|
218
|
+
|
|
219
|
+
const server = http.createServer(async (req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
await handleRequest(req, res, cfg);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
// Production error handler — no stack traces
|
|
224
|
+
json(res, 500, { error: 'Internal server error' });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
server.listen(port, () => {
|
|
229
|
+
process.stdout.write(`\n🔒 tdd-audit REST API listening on http://localhost:${port}\n`);
|
|
230
|
+
if (!cfg.serverApiKey) {
|
|
231
|
+
process.stderr.write('⚠️ No --api-key set — server is unauthenticated. Set one for production.\n');
|
|
232
|
+
}
|
|
233
|
+
process.stdout.write(' GET /health\n');
|
|
234
|
+
process.stdout.write(' POST /scan { path, format? }\n');
|
|
235
|
+
process.stdout.write(' POST /remediate { findings, provider, apiKey, model? }\n');
|
|
236
|
+
process.stdout.write(' GET /jobs/:id\n\n');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return server; // returned for testing
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
start, handleRequest, authenticate,
|
|
244
|
+
jobs, createJob, updateJob,
|
|
245
|
+
safeScanPath, MAX_JOBS, JOB_TTL_MS,
|
|
246
|
+
rateLimiter, RATE_LIMIT_MAX,
|
|
247
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhi/tdd-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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": {
|