@lhi/tdd-audit 1.11.0 → 1.14.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 +33 -7
- package/docs/rest-api.md +185 -35
- package/docs/scanner.md +13 -9
- package/docs/vulnerability-patterns.md +137 -1
- 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 +96 -3
- package/lib/server.js +57 -100
- package/package.json +12 -1
- package/prompts/auto-audit.md +76 -0
package/lib/badge.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Marker embedded in the badge line — used to find and replace it on re-scan.
|
|
7
|
+
const BADGE_MARKER = 'tdd-audit-badge';
|
|
8
|
+
|
|
9
|
+
const NPM_URL = 'https://www.npmjs.com/package/@lhi/tdd-audit';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a shields.io badge markdown line reflecting actual scan results.
|
|
13
|
+
*
|
|
14
|
+
* - 0 critical/high (real) findings → "passing" · brightgreen
|
|
15
|
+
* - ≥1 high (no critical) → "{n} high" · orange
|
|
16
|
+
* - ≥1 critical → "{n} critical" · red
|
|
17
|
+
*
|
|
18
|
+
* likelyFalsePositive findings (test fixtures) are excluded from the count.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array} findings - findings array returned by quickScan()
|
|
21
|
+
* @returns {string} - single-line markdown badge ending with \n
|
|
22
|
+
*/
|
|
23
|
+
function badgeLine(findings) {
|
|
24
|
+
// Exclude test-file findings and likely false positives — badge reflects production code only
|
|
25
|
+
const real = (findings || []).filter(f => !f.likelyFalsePositive && !f.inTestFile);
|
|
26
|
+
const criticals = real.filter(f => f.severity === 'CRITICAL').length;
|
|
27
|
+
const highs = real.filter(f => f.severity === 'HIGH').length;
|
|
28
|
+
|
|
29
|
+
let message, color;
|
|
30
|
+
if (criticals > 0) {
|
|
31
|
+
message = `${criticals}%20critical`;
|
|
32
|
+
color = 'red';
|
|
33
|
+
} else if (highs > 0) {
|
|
34
|
+
message = `${highs}%20high`;
|
|
35
|
+
color = 'orange';
|
|
36
|
+
} else {
|
|
37
|
+
message = 'passing';
|
|
38
|
+
color = 'brightgreen';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const badgeUrl = `https://img.shields.io/badge/tdd--audit-${message}-${color}`;
|
|
42
|
+
// Embed the marker as a hidden HTML comment after the badge so injectBadge()
|
|
43
|
+
// can locate and replace the line on subsequent runs.
|
|
44
|
+
return `[](${NPM_URL}) <!-- ${BADGE_MARKER} -->\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inject or update the tdd-audit badge in the project's README.md.
|
|
49
|
+
*
|
|
50
|
+
* Behaviour:
|
|
51
|
+
* - Searches for README.md / readme.md / README in the project root.
|
|
52
|
+
* - If a badge line (identified by BADGE_MARKER) already exists, replaces it.
|
|
53
|
+
* - Otherwise inserts the badge immediately after the first `# Heading` line.
|
|
54
|
+
* If no heading exists, prepends to the file.
|
|
55
|
+
* - No-ops silently when no README is found.
|
|
56
|
+
* - Idempotent: running twice with the same inputs produces the same output.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} projectDir - absolute path to the project root
|
|
59
|
+
* @param {string} badge - badge markdown line from badgeLine()
|
|
60
|
+
*/
|
|
61
|
+
function injectBadge(projectDir, badge) {
|
|
62
|
+
const candidates = ['README.md', 'readme.md', 'Readme.md', 'README'];
|
|
63
|
+
let readmePath = null;
|
|
64
|
+
for (const name of candidates) {
|
|
65
|
+
const p = path.join(projectDir, name);
|
|
66
|
+
if (fs.existsSync(p)) { readmePath = p; break; }
|
|
67
|
+
}
|
|
68
|
+
if (!readmePath) return;
|
|
69
|
+
|
|
70
|
+
const original = fs.readFileSync(readmePath, 'utf8');
|
|
71
|
+
|
|
72
|
+
// Replace existing badge (idempotent + allows re-scan update)
|
|
73
|
+
if (original.includes(BADGE_MARKER)) {
|
|
74
|
+
const updated = original.replace(/^.*tdd-audit-badge.*$/m, badge.trimEnd());
|
|
75
|
+
fs.writeFileSync(readmePath, updated);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Insert after the first h1 line, or prepend if no h1 exists
|
|
80
|
+
const lines = original.split('\n');
|
|
81
|
+
const h1Idx = lines.findIndex(l => /^#\s/.test(l));
|
|
82
|
+
|
|
83
|
+
let updated;
|
|
84
|
+
if (h1Idx !== -1) {
|
|
85
|
+
lines.splice(h1Idx + 1, 0, badge.trimEnd());
|
|
86
|
+
updated = lines.join('\n');
|
|
87
|
+
} else {
|
|
88
|
+
updated = badge.trimEnd() + '\n' + original;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fs.writeFileSync(readmePath, updated);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { badgeLine, injectBadge, BADGE_MARKER };
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
// ─── Job store (singleton, in-memory) ────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const MAX_JOBS = 1_000;
|
|
8
|
+
const JOB_TTL_MS = 60 * 60 * 1_000; // 1 hour
|
|
9
|
+
|
|
10
|
+
const jobs = new Map();
|
|
11
|
+
let jobSeq = 0;
|
|
12
|
+
|
|
13
|
+
// EventEmitter used to push job updates to SSE subscribers
|
|
14
|
+
const _emitter = new EventEmitter();
|
|
15
|
+
_emitter.setMaxListeners(500);
|
|
16
|
+
|
|
17
|
+
function evictJobs() {
|
|
18
|
+
const cutoff = Date.now() - JOB_TTL_MS;
|
|
19
|
+
for (const [id, job] of jobs) {
|
|
20
|
+
if (new Date(job.createdAt).getTime() < cutoff) jobs.delete(id);
|
|
21
|
+
}
|
|
22
|
+
while (jobs.size >= MAX_JOBS) {
|
|
23
|
+
jobs.delete(jobs.keys().next().value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createJob() {
|
|
28
|
+
evictJobs();
|
|
29
|
+
const id = `job_${++jobSeq}_${Date.now()}`;
|
|
30
|
+
jobs.set(id, { id, status: 'pending', createdAt: new Date().toISOString() });
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateJob(id, patch) {
|
|
35
|
+
const job = jobs.get(id);
|
|
36
|
+
if (!job) return;
|
|
37
|
+
const updated = { ...job, ...patch };
|
|
38
|
+
jobs.set(id, updated);
|
|
39
|
+
_emitter.emit(id, updated);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to live updates for a job.
|
|
44
|
+
* @param {string} id - job id
|
|
45
|
+
* @param {Function} fn - called with the updated job object on every change
|
|
46
|
+
* @returns {Function} - call to unsubscribe
|
|
47
|
+
*/
|
|
48
|
+
function subscribe(id, fn) {
|
|
49
|
+
_emitter.on(id, fn);
|
|
50
|
+
return () => _emitter.off(id, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { jobs, createJob, updateJob, subscribe, evictJobs, MAX_JOBS, JOB_TTL_MS };
|
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
|
@@ -42,19 +42,42 @@ const VULN_PATTERNS = [
|
|
|
42
42
|
{ name: 'JWT Alg None', severity: 'CRITICAL', pattern: /algorithm\s*:\s*['"]none['"]/i },
|
|
43
43
|
{ name: 'Timing-Unsafe Comparison',severity: 'HIGH', pattern: /\b(?:token|password|secret|hash|digest|hmac|signature|api.?key)\w*\s*={2,3}\s*\w|(?:req\.(?:headers?|body|query|params)\.\w+)\s*={2,3}/i },
|
|
44
44
|
{ name: 'ReDoS', severity: 'HIGH', pattern: /new\s+RegExp\s*\(\s*req\.(?:query|body|params)\./i },
|
|
45
|
+
// ── AI / LLM Security ───────────────────────────────────────────────────────
|
|
46
|
+
{ name: 'LLM Prompt Injection', severity: 'CRITICAL', pattern: /\{\s*role\s*:\s*['"](?:user|system)['"]\s*,\s*content\s*:\s*req\.(body|query|params)|messages\b[^;\n]{0,100}push\s*\([^)]*req\.(body|query|params)/i },
|
|
47
|
+
{ name: 'LLM Output Execution', severity: 'CRITICAL', pattern: /\beval\s*\(\s*(?:await\s+)?(?:response|result|output|completion|generated|llmResult|aiResult)\b/i },
|
|
48
|
+
{ name: 'LangChain ShellTool', severity: 'CRITICAL', pattern: /\bShellTool\s*\(\)|LLMMathChain\.from_llm\s*\(|PALChain\.from_llm\s*\(/i },
|
|
49
|
+
{ name: 'Dynamic Require', severity: 'CRITICAL', pattern: /\brequire\s*\(\s*req\.(query|body|params)\./i },
|
|
50
|
+
{ name: 'VM Code Injection', severity: 'CRITICAL', pattern: /\bvm\.(runInNewContext|runInContext|runInThisContext)\s*\(\s*req\.(body|query|params)/i },
|
|
51
|
+
{ name: 'node-serialize RCE', severity: 'CRITICAL', pattern: /require\s*\(\s*['"]node-serialize['"]\s*\)/ },
|
|
52
|
+
{ name: 'Electron nodeIntegration', severity: 'CRITICAL', pattern: /\bnodeIntegration\s*:\s*true\b/ },
|
|
53
|
+
{ name: 'Electron webSecurity Off', severity: 'CRITICAL', pattern: /\bwebSecurity\s*:\s*false\b/ },
|
|
54
|
+
{ name: 'GitHub Actions Injection', severity: 'CRITICAL', pattern: /\$\{\{\s*github\.(event\.(pull_request\.(title|body)|issue\.(title|body)|comment\.body|review\.body)|head_ref)\s*\}\}/ },
|
|
55
|
+
{ name: 'Hardcoded OpenAI Key', severity: 'CRITICAL', skipInTests: true, pattern: /['"]sk-(?:proj-[A-Za-z0-9_\-]{40,}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9_\-]{20,})['"]/ },
|
|
56
|
+
{ name: 'Hardcoded Anthropic Key', severity: 'CRITICAL', skipInTests: true, pattern: /['"]sk-ant-api03-[A-Za-z0-9_\-]{10,}['"]/ },
|
|
57
|
+
// ── HIGH — web / protocol / AI ──────────────────────────────────────────────
|
|
58
|
+
{ name: 'Header Injection', severity: 'HIGH', pattern: /res\.(?:setHeader|set)\s*\([^,]+,\s*req\.(body|query|params)\b/i },
|
|
59
|
+
{ name: 'XPath Injection', severity: 'HIGH', pattern: /xpath\.(?:select|evaluate|selectNodes?)\s*\([^)]*req\.(query|body|params)/i },
|
|
60
|
+
{ name: 'Insecure Cookie', severity: 'HIGH', pattern: /\bhttpOnly\s*:\s*false\b/ },
|
|
61
|
+
{ name: 'Credentials in AI Prompt', severity: 'HIGH', pattern: /(?:system_prompt|systemPrompt|system|prompt|instruction)\s*[=:+][^;\n]{0,120}(?:mongodb(?:\+srv)?|postgresql?|mysql|redis):\/\/[a-zA-Z0-9_\-]+:[^@\s]{3,}@/ },
|
|
62
|
+
{ name: 'LangChain Experimental', severity: 'HIGH', pattern: /from\s+langchain_experimental\b|from\s+['"]langchain\/experimental['"]/i },
|
|
63
|
+
{ name: 'Hardcoded HuggingFace Token',severity: 'HIGH', skipInTests: true, pattern: /['"]hf_[A-Za-z0-9]{30,}['"]/ },
|
|
64
|
+
{ name: 'NEXT_PUBLIC Secret', severity: 'HIGH', skipInTests: true, pattern: /\bNEXT_PUBLIC_\w*(?:SECRET|PRIVATE|API_KEY|TOKEN|PASSWORD|CREDENTIAL)\w*/i },
|
|
65
|
+
{ name: 'Electron contextIsolation Off', severity: 'HIGH', pattern: /\bcontextIsolation\s*:\s*false\b/ },
|
|
66
|
+
{ name: 'Trojan Source', severity: 'HIGH', pattern: /[\u202A-\u202E\u2066-\u2069]/ },
|
|
45
67
|
];
|
|
46
68
|
|
|
47
|
-
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart']);
|
|
69
|
+
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart', '.yml', '.yaml']);
|
|
48
70
|
|
|
49
71
|
/** Maximum file size to read before skipping (512 KB). Prevents OOM on large generated files. */
|
|
50
72
|
const MAX_SCAN_FILE_BYTES = 512 * 1024;
|
|
51
|
-
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']);
|
|
52
74
|
|
|
53
75
|
// ─── Prompt / Skill Patterns ──────────────────────────────────────────────────
|
|
54
76
|
|
|
55
77
|
const PROMPT_PATTERNS = [
|
|
56
78
|
{ name: 'Deprecated CSRF Package', severity: 'CRITICAL', pattern: /\bcsurf\b/, skipCommentLine: true },
|
|
57
79
|
{ name: 'Unpinned npx MCP Server', severity: 'HIGH', pattern: /"command"\s*:\s*"npx"/ },
|
|
80
|
+
{ name: 'MCP Tool Poisoning', severity: 'HIGH', pattern: /"description"\s*:\s*"[^"]*(?:ignore (?:previous|all)|override (?:previous )?instructions?|disregard|forget (?:all )?(?:previous )?instructions?|you are now|new instructions?)/i },
|
|
58
81
|
{ name: 'Cleartext URL in Prompt', severity: 'MEDIUM', pattern: /\bhttp:\/\/(?!localhost|127\.0\.0\.1|169\.254\.)[a-zA-Z0-9]/ },
|
|
59
82
|
];
|
|
60
83
|
|
|
@@ -355,6 +378,74 @@ function scanAndroidManifest(projectDir) {
|
|
|
355
378
|
return findings;
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Scan package.json for supply-chain exfiltration: postinstall/preinstall scripts
|
|
383
|
+
* that shell out to curl/wget, which can silently steal data at install time.
|
|
384
|
+
* @param {string} projectDir - project root
|
|
385
|
+
* @returns {Array}
|
|
386
|
+
*/
|
|
387
|
+
function scanPackageJson(projectDir) {
|
|
388
|
+
const findings = [];
|
|
389
|
+
const filePath = path.join(projectDir, 'package.json');
|
|
390
|
+
if (!fs.existsSync(filePath)) return findings;
|
|
391
|
+
let lines;
|
|
392
|
+
try { lines = fs.readFileSync(filePath, 'utf8').split('\n'); } catch { return findings; }
|
|
393
|
+
const supplyChainRe = /["'](?:postinstall|preinstall)["']\s*:\s*["'][^"']*(?:curl|wget)\s+https?:\/\//i;
|
|
394
|
+
for (let i = 0; i < lines.length; i++) {
|
|
395
|
+
if (supplyChainRe.test(lines[i])) {
|
|
396
|
+
findings.push({
|
|
397
|
+
severity: 'CRITICAL',
|
|
398
|
+
name: 'Supply Chain Exfiltration',
|
|
399
|
+
file: 'package.json',
|
|
400
|
+
line: i + 1,
|
|
401
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
402
|
+
inTestFile: false,
|
|
403
|
+
likelyFalsePositive: false,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return findings;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Scan .env files for NEXT_PUBLIC_ variables containing secrets.
|
|
412
|
+
* NEXT_PUBLIC_ variables are inlined into the client-side JS bundle at build
|
|
413
|
+
* time, so any secret stored with this prefix is exposed to all browsers.
|
|
414
|
+
* @param {string} projectDir - project root
|
|
415
|
+
* @returns {Array}
|
|
416
|
+
*/
|
|
417
|
+
function scanEnvFiles(projectDir) {
|
|
418
|
+
const findings = [];
|
|
419
|
+
const candidates = ['.env', '.env.local', '.env.development', '.env.production', '.env.test', '.env.staging'];
|
|
420
|
+
const nextPublicSecretRe = /^NEXT_PUBLIC_\w*(?:SECRET|PRIVATE|API_KEY|TOKEN|PASSWORD|CREDENTIAL)\w*\s*=/i;
|
|
421
|
+
for (const name of candidates) {
|
|
422
|
+
const filePath = path.join(projectDir, name);
|
|
423
|
+
if (!fs.existsSync(filePath)) continue;
|
|
424
|
+
let content;
|
|
425
|
+
try {
|
|
426
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
427
|
+
if (content.length > MAX_SCAN_FILE_BYTES) continue;
|
|
428
|
+
} catch { continue; }
|
|
429
|
+
const lines = content.split('\n');
|
|
430
|
+
for (let i = 0; i < lines.length; i++) {
|
|
431
|
+
const line = lines[i];
|
|
432
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
433
|
+
if (nextPublicSecretRe.test(line)) {
|
|
434
|
+
findings.push({
|
|
435
|
+
severity: 'HIGH',
|
|
436
|
+
name: 'NEXT_PUBLIC Secret',
|
|
437
|
+
file: name,
|
|
438
|
+
line: i + 1,
|
|
439
|
+
snippet: line.trim().slice(0, 80),
|
|
440
|
+
inTestFile: false,
|
|
441
|
+
likelyFalsePositive: false,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return findings;
|
|
447
|
+
}
|
|
448
|
+
|
|
358
449
|
// ─── Quick Scan ───────────────────────────────────────────────────────────────
|
|
359
450
|
|
|
360
451
|
/**
|
|
@@ -396,7 +487,7 @@ function quickScan(projectDir) {
|
|
|
396
487
|
}
|
|
397
488
|
}
|
|
398
489
|
}
|
|
399
|
-
return [...findings, ...scanAppConfig(projectDir), ...scanAndroidManifest(projectDir), ...scanPromptFiles(projectDir)];
|
|
490
|
+
return [...findings, ...scanAppConfig(projectDir), ...scanAndroidManifest(projectDir), ...scanPromptFiles(projectDir), ...scanPackageJson(projectDir), ...scanEnvFiles(projectDir)];
|
|
400
491
|
}
|
|
401
492
|
|
|
402
493
|
// ─── Print Findings ───────────────────────────────────────────────────────────
|
|
@@ -462,6 +553,8 @@ module.exports = {
|
|
|
462
553
|
hasSafeAuditStatus,
|
|
463
554
|
scanAppConfig,
|
|
464
555
|
scanAndroidManifest,
|
|
556
|
+
scanPackageJson,
|
|
557
|
+
scanEnvFiles,
|
|
465
558
|
scanPromptFiles,
|
|
466
559
|
quickScan,
|
|
467
560
|
printFindings,
|