@lhi/tdd-audit 1.16.0 → 1.20.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 +214 -93
- package/SKILL.md +6 -0
- package/docs/ai-remediation.md +114 -42
- package/docs/configuration.md +236 -0
- package/docs/rest-api.md +144 -131
- package/docs/scanner.md +5 -3
- package/docs/vulnerability-patterns.md +241 -1
- package/index.js +37 -26
- package/lib/auditor.js +880 -0
- package/lib/badge.js +34 -7
- package/lib/config.js +50 -1
- package/lib/github.js +1 -1
- package/lib/plugin.js +118 -23
- package/lib/reporter.js +23 -5
- package/lib/scanner.js +29 -0
- package/package.json +1 -1
- package/prompts/ai-security.md +329 -0
- package/prompts/auto-audit.md +462 -17
- package/prompts/node-advanced-security.md +394 -0
- package/prompts/security-test-patterns.md +522 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Node.js Advanced Security Companion — Detection & Repair Guide
|
|
2
|
+
|
|
3
|
+
This guide covers Node.js/Express attack surfaces beyond the OWASP Top 10 basics.
|
|
4
|
+
Apply these patterns during the Explore and Audit phases.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Timing Oracle Attack (Non-Constant-Time String Comparison)
|
|
9
|
+
|
|
10
|
+
**What it is:** Using `===` or `==` to compare tokens, passwords, or HMACs allows an attacker
|
|
11
|
+
to infer the correct value one byte at a time by measuring response latency.
|
|
12
|
+
|
|
13
|
+
**Detection — look for:**
|
|
14
|
+
- `token === req.headers.authorization`
|
|
15
|
+
- `secret == providedKey`
|
|
16
|
+
- `apiKey === process.env.API_KEY`
|
|
17
|
+
- Any `===` comparison where one operand is from `req.*` and the other is a secret
|
|
18
|
+
|
|
19
|
+
**Repair:**
|
|
20
|
+
```javascript
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
function timingSafeEqual(a, b) {
|
|
23
|
+
const ha = crypto.createHmac('sha256', 'cmp').update(String(a)).digest();
|
|
24
|
+
const hb = crypto.createHmac('sha256', 'cmp').update(String(b)).digest();
|
|
25
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
26
|
+
}
|
|
27
|
+
if (!timingSafeEqual(req.headers.authorization, `Bearer ${process.env.API_KEY}`)) {
|
|
28
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Test snippet:**
|
|
33
|
+
```javascript
|
|
34
|
+
test('responds in constant time for wrong vs missing token', async () => {
|
|
35
|
+
const t1 = Date.now(); await request(app).get('/api').set('Authorization', 'Bearer wrong'); const d1 = Date.now() - t1;
|
|
36
|
+
const t2 = Date.now(); await request(app).get('/api').set('Authorization', `Bearer ${'x'.repeat(100)}`); const d2 = Date.now() - t2;
|
|
37
|
+
expect(Math.abs(d1 - d2)).toBeLessThan(50); // within 50 ms
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 2. Host Header Injection
|
|
44
|
+
|
|
45
|
+
**What it is:** `req.headers.host` (or `req.hostname`) is used to construct password-reset
|
|
46
|
+
links, email confirmation URLs, or redirects. An attacker supplies a forged `Host:` header,
|
|
47
|
+
redirecting victims to an attacker-controlled domain.
|
|
48
|
+
|
|
49
|
+
**Detection — look for:**
|
|
50
|
+
- `req.headers['host']`, `req.hostname`, `req.get('host')`
|
|
51
|
+
- Used in string concatenation building a URL for email, redirect, or link
|
|
52
|
+
|
|
53
|
+
**Repair:** Use a hard-coded trusted base URL from config — never trust `Host:` header:
|
|
54
|
+
```javascript
|
|
55
|
+
const BASE_URL = process.env.BASE_URL; // e.g. 'https://app.example.com'
|
|
56
|
+
if (!BASE_URL) throw new Error('BASE_URL env var required');
|
|
57
|
+
const resetLink = `${BASE_URL}/reset?token=${token}`;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 3. Headless Browser SSRF (Puppeteer / Playwright / wkhtmltopdf)
|
|
63
|
+
|
|
64
|
+
**What it is:** A headless browser is instructed to navigate to a URL derived from user input.
|
|
65
|
+
The browser runs server-side, so it can access internal services, cloud metadata endpoints
|
|
66
|
+
(`169.254.169.254`), or local network resources.
|
|
67
|
+
|
|
68
|
+
**Detection — look for:**
|
|
69
|
+
- `page.goto(req.query.url)`, `page.navigate(req.body.url)`
|
|
70
|
+
- `wkhtmltopdf(userUrl, ...)`, `page.goto(url)` where `url` comes from request
|
|
71
|
+
|
|
72
|
+
**Repair:**
|
|
73
|
+
```javascript
|
|
74
|
+
const { URL } = require('url');
|
|
75
|
+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
|
76
|
+
const BLOCKED_HOSTS = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/;
|
|
77
|
+
|
|
78
|
+
function assertSafeUrl(raw) {
|
|
79
|
+
let u;
|
|
80
|
+
try { u = new URL(raw); } catch { throw new Error('Invalid URL'); }
|
|
81
|
+
if (!ALLOWED_PROTOCOLS.has(u.protocol)) throw new Error(`Protocol not allowed: ${u.protocol}`);
|
|
82
|
+
if (BLOCKED_HOSTS.test(u.hostname)) throw new Error(`Blocked host: ${u.hostname}`);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 4. Body Parser DoS (No Size Limit)
|
|
89
|
+
|
|
90
|
+
**What it is:** `express.json()` or `bodyParser.json()` with no `limit` option will buffer
|
|
91
|
+
arbitrarily large payloads into memory, enabling a DoS attack with a single large request.
|
|
92
|
+
|
|
93
|
+
**Detection — look for:**
|
|
94
|
+
- `express.json()` — no argument
|
|
95
|
+
- `express.urlencoded()` — no argument
|
|
96
|
+
- `bodyParser.json()` — no `limit:` property in the options object
|
|
97
|
+
|
|
98
|
+
**Repair:**
|
|
99
|
+
```javascript
|
|
100
|
+
app.use(express.json({ limit: '100kb' }));
|
|
101
|
+
app.use(express.urlencoded({ extended: false, limit: '100kb' }));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. vm2 Deprecated — Sandbox Escape
|
|
107
|
+
|
|
108
|
+
**What it is:** The `vm2` library has been publicly abandoned with unfixed sandbox-escape CVEs
|
|
109
|
+
(CVE-2023-29017, CVE-2023-32314). Any code using `require('vm2')` is vulnerable to full host
|
|
110
|
+
compromise from untrusted code execution.
|
|
111
|
+
|
|
112
|
+
**Detection — look for:**
|
|
113
|
+
- `require('vm2')` or `import ... from 'vm2'`
|
|
114
|
+
|
|
115
|
+
**Repair:** Replace with `isolated-vm` for true V8 isolate sandboxing, or Node's built-in
|
|
116
|
+
`vm.runInNewContext` with a frozen context for limited use cases:
|
|
117
|
+
```javascript
|
|
118
|
+
// Replace vm2 with isolated-vm
|
|
119
|
+
const ivm = require('isolated-vm');
|
|
120
|
+
const isolate = new ivm.Isolate({ memoryLimit: 32 });
|
|
121
|
+
const context = isolate.createContextSync();
|
|
122
|
+
const result = isolate.compileScriptSync(untrustedCode).runSync(context);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 6. Template Engine Raw / Unescaped Output
|
|
128
|
+
|
|
129
|
+
**What it is:** Template engines provide escape bypasses for trusted HTML content. When these
|
|
130
|
+
are used with user-controlled values, they create reflected XSS vulnerabilities.
|
|
131
|
+
|
|
132
|
+
**Detection — look for:**
|
|
133
|
+
- **Pug:** `!{userValue}` (raw unescaped output)
|
|
134
|
+
- **EJS:** `<%-` tag (unescaped)
|
|
135
|
+
- **Handlebars:** `{{{userValue}}}` (triple-stache)
|
|
136
|
+
- **Dust.js:** `{userValue|s}` (safe/unescaped filter)
|
|
137
|
+
- **Vue SSR / v-html:** `v-html="userValue"` (server-rendered)
|
|
138
|
+
|
|
139
|
+
**Repair:** Use the escaped variants:
|
|
140
|
+
```
|
|
141
|
+
Pug: #{userValue} not !{userValue}
|
|
142
|
+
EJS: <%= userValue %> not <%- userValue %>
|
|
143
|
+
Handlebars: {{userValue}} not {{{userValue}}}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 7. postMessage Missing Origin Validation
|
|
149
|
+
|
|
150
|
+
**What it is:** A `message` event listener on `window` does not check `event.origin`, allowing
|
|
151
|
+
any page (including attacker-controlled iframes) to send arbitrary messages.
|
|
152
|
+
|
|
153
|
+
**Detection — look for:**
|
|
154
|
+
- `addEventListener('message', handler)` where `handler` does not reference `event.origin`
|
|
155
|
+
|
|
156
|
+
**Repair:**
|
|
157
|
+
```javascript
|
|
158
|
+
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
|
|
159
|
+
window.addEventListener('message', (event) => {
|
|
160
|
+
if (!ALLOWED_ORIGINS.has(event.origin)) return; // reject unknown origins
|
|
161
|
+
handleMessage(event.data);
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 8. Dynamic import() with User Input
|
|
168
|
+
|
|
169
|
+
**What it is:** `import()` or `require()` with a path derived from user input enables path
|
|
170
|
+
traversal to load arbitrary modules, including `child_process`, `fs`, or native addons.
|
|
171
|
+
|
|
172
|
+
**Detection — look for:**
|
|
173
|
+
- `import(req.query.module)`, `import(userPath)`, `require(req.body.plugin)`
|
|
174
|
+
- `` import(`./plugins/${req.params.name}`) ``
|
|
175
|
+
|
|
176
|
+
**Repair:** Use a static allowlist:
|
|
177
|
+
```javascript
|
|
178
|
+
const ALLOWED_PLUGINS = new Map([
|
|
179
|
+
['csv', () => import('./plugins/csv-parser.js')],
|
|
180
|
+
['json', () => import('./plugins/json-parser.js')],
|
|
181
|
+
]);
|
|
182
|
+
const loader = ALLOWED_PLUGINS.get(req.params.format);
|
|
183
|
+
if (!loader) return res.status(400).json({ error: 'Unknown format' });
|
|
184
|
+
const plugin = await loader();
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 9. JWT — No Revocation Mechanism
|
|
190
|
+
|
|
191
|
+
**What it is:** JWTs with long-lived `expiresIn` values (days/hours) and no server-side
|
|
192
|
+
revocation mechanism cannot be invalidated after issuance. Stolen tokens remain valid until
|
|
193
|
+
natural expiry.
|
|
194
|
+
|
|
195
|
+
**Detection — look for:**
|
|
196
|
+
- `jwt.sign({ ... }, secret, { expiresIn: '7d' })` with no token blocklist or session store
|
|
197
|
+
- No middleware that checks a revocation list before trusting a valid JWT
|
|
198
|
+
|
|
199
|
+
**Repair options:**
|
|
200
|
+
1. **Short TTL + refresh tokens:** Issue 15-minute access tokens, longer refresh tokens stored server-side.
|
|
201
|
+
2. **JTI blocklist:** Store `jti` claims of revoked tokens in Redis with matching TTL:
|
|
202
|
+
```javascript
|
|
203
|
+
async function revokeToken(token) {
|
|
204
|
+
const { jti, exp } = jwt.decode(token);
|
|
205
|
+
const ttl = exp - Math.floor(Date.now() / 1000);
|
|
206
|
+
await redis.set(`revoked:${jti}`, '1', 'EX', ttl);
|
|
207
|
+
}
|
|
208
|
+
async function isRevoked(jti) {
|
|
209
|
+
return !!(await redis.get(`revoked:${jti}`));
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 10. X-Powered-By Header Exposes Framework Fingerprint
|
|
216
|
+
|
|
217
|
+
**What it is:** Express sets `X-Powered-By: Express` by default, advertising the framework
|
|
218
|
+
version to attackers for targeted exploit selection.
|
|
219
|
+
|
|
220
|
+
**Detection — look for:**
|
|
221
|
+
- `express()` app initialisation with no subsequent `app.disable('x-powered-by')`
|
|
222
|
+
- Absence of `helmet()` middleware (which removes this header)
|
|
223
|
+
|
|
224
|
+
**Repair:**
|
|
225
|
+
```javascript
|
|
226
|
+
const helmet = require('helmet');
|
|
227
|
+
app.use(helmet()); // removes X-Powered-By and sets 11 security headers
|
|
228
|
+
// or manually:
|
|
229
|
+
app.disable('x-powered-by');
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 11. Logger Data Leakage
|
|
235
|
+
|
|
236
|
+
**What it is:** User-supplied input is passed directly to `console.log`, `logger.info`,
|
|
237
|
+
`winston.debug`, etc., causing PII, tokens, or secrets to be written to log files or
|
|
238
|
+
shipped to centralised logging systems.
|
|
239
|
+
|
|
240
|
+
**Detection — look for:**
|
|
241
|
+
- `console.log(req.body)`, `logger.info(req.headers)`, `logger.debug(user)`
|
|
242
|
+
- Logging full request objects that include `authorization`, `password`, `token`
|
|
243
|
+
|
|
244
|
+
**Repair:** Sanitise log payloads — log an allowlist of safe fields only:
|
|
245
|
+
```javascript
|
|
246
|
+
function safeLogRequest(req) {
|
|
247
|
+
return { method: req.method, path: req.path, ip: req.ip, userId: req.user?.id };
|
|
248
|
+
}
|
|
249
|
+
logger.info('Request received', safeLogRequest(req));
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 12. GraphQL Introspection Enabled in Production
|
|
255
|
+
|
|
256
|
+
**What it is:** GraphQL introspection allows any client to enumerate the entire schema,
|
|
257
|
+
exposing internal types, field names, and resolver structure — a reconnaissance goldmine.
|
|
258
|
+
|
|
259
|
+
**Detection — look for:**
|
|
260
|
+
- `introspection: true` in ApolloServer config
|
|
261
|
+
- No `NODE_ENV` guard around introspection
|
|
262
|
+
- Missing depth/complexity limiting plugins
|
|
263
|
+
|
|
264
|
+
**Repair:**
|
|
265
|
+
```javascript
|
|
266
|
+
const server = new ApolloServer({
|
|
267
|
+
typeDefs,
|
|
268
|
+
resolvers,
|
|
269
|
+
introspection: process.env.NODE_ENV !== 'production',
|
|
270
|
+
plugins: [
|
|
271
|
+
ApolloServerPluginLandingPageDisabled(), // prod: disable playground
|
|
272
|
+
createDepthLimitPlugin(7),
|
|
273
|
+
createComplexityPlugin({ maxComplexity: 1000 }),
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 13. Prototype Pollution via Bracket Notation
|
|
281
|
+
|
|
282
|
+
**What it is:** `obj[userKey] = userValue` where `userKey` comes from user input allows
|
|
283
|
+
setting `__proto__`, `constructor`, or `prototype` properties, poisoning the Object
|
|
284
|
+
prototype for all objects in the process and potentially enabling privilege escalation.
|
|
285
|
+
|
|
286
|
+
**Detection — look for:**
|
|
287
|
+
- `obj[req.body.key] = req.body.value`
|
|
288
|
+
- `config[userKey]` without key validation
|
|
289
|
+
- `_.merge(target, req.body)` without `_.cloneDeep` or `Object.create(null)` base
|
|
290
|
+
|
|
291
|
+
**Repair:**
|
|
292
|
+
```javascript
|
|
293
|
+
function safeMerge(target, source) {
|
|
294
|
+
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
295
|
+
for (const [k, v] of Object.entries(source)) {
|
|
296
|
+
if (BLOCKED.has(k)) continue;
|
|
297
|
+
target[k] = v;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Or use Object.create(null) as base to avoid prototype chain entirely
|
|
301
|
+
const safeObj = Object.assign(Object.create(null), userInput);
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## 14. Silent Exception Swallow
|
|
307
|
+
|
|
308
|
+
**What it is:** `catch` blocks that contain only a comment or are completely empty silently
|
|
309
|
+
discard errors, hiding security-relevant failures (auth errors, validation failures,
|
|
310
|
+
crypto exceptions) and making incident investigation impossible.
|
|
311
|
+
|
|
312
|
+
**Detection — look for:**
|
|
313
|
+
- `catch (e) { }` — empty catch
|
|
314
|
+
- `catch (e) { // ignore }` — comment-only catch
|
|
315
|
+
- `catch (err) { return; }` — silent return
|
|
316
|
+
|
|
317
|
+
**Repair:** Always log and re-throw or return a safe error:
|
|
318
|
+
```javascript
|
|
319
|
+
try {
|
|
320
|
+
await riskyOperation();
|
|
321
|
+
} catch (err) {
|
|
322
|
+
logger.error({ err, userId: req.user?.id }, 'Operation failed');
|
|
323
|
+
return res.status(500).json({ error: 'Internal error' });
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 15. Sequelize / Knex TLS Disabled on Database Connection
|
|
330
|
+
|
|
331
|
+
**What it is:** `dialectOptions: { ssl: false }` or `ssl: { rejectUnauthorized: false }`
|
|
332
|
+
disables certificate validation on the database connection, exposing credentials to
|
|
333
|
+
man-in-the-middle attacks.
|
|
334
|
+
|
|
335
|
+
**Detection — look for:**
|
|
336
|
+
- `ssl: false` inside `dialectOptions`
|
|
337
|
+
- `ssl: { rejectUnauthorized: false }` in Sequelize/pg/mysql2 config
|
|
338
|
+
- `knex({ client: 'pg', connection: { ssl: false } })`
|
|
339
|
+
|
|
340
|
+
**Repair:**
|
|
341
|
+
```javascript
|
|
342
|
+
new Sequelize(DATABASE_URL, {
|
|
343
|
+
dialectOptions: {
|
|
344
|
+
ssl: {
|
|
345
|
+
require: true,
|
|
346
|
+
rejectUnauthorized: true,
|
|
347
|
+
ca: fs.readFileSync('./certs/ca.pem'),
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 16. Insecure WebSocket URL (ws:// in Production)
|
|
356
|
+
|
|
357
|
+
**What it is:** Connecting to `ws://` (unencrypted WebSocket) transmits data in plaintext,
|
|
358
|
+
enabling credential theft and message injection on any network path.
|
|
359
|
+
|
|
360
|
+
**Detection — look for:**
|
|
361
|
+
- `new WebSocket('ws://')` — hardcoded insecure URL (not localhost)
|
|
362
|
+
- `io.connect('ws://')` (Socket.IO)
|
|
363
|
+
|
|
364
|
+
**Repair:** Always use `wss://` in production:
|
|
365
|
+
```javascript
|
|
366
|
+
const ws = new WebSocket(
|
|
367
|
+
process.env.NODE_ENV === 'production'
|
|
368
|
+
? 'wss://api.example.com/ws'
|
|
369
|
+
: 'ws://localhost:3001'
|
|
370
|
+
);
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Severity Reference
|
|
376
|
+
|
|
377
|
+
| Pattern | CWE | Severity |
|
|
378
|
+
|---|---|---|
|
|
379
|
+
| Headless browser SSRF | CWE-918 | CRITICAL |
|
|
380
|
+
| vm2 deprecated (sandbox escape) | CWE-693 | CRITICAL |
|
|
381
|
+
| Host header injection | CWE-601 | HIGH |
|
|
382
|
+
| Body parser DoS | CWE-400 | HIGH |
|
|
383
|
+
| Template engine raw output (XSS) | CWE-79 | HIGH |
|
|
384
|
+
| Dynamic import with user input | CWE-706 | HIGH |
|
|
385
|
+
| JWT no revocation | CWE-613 | HIGH |
|
|
386
|
+
| GraphQL introspection in prod | CWE-200 | HIGH |
|
|
387
|
+
| Prototype pollution | CWE-1321 | HIGH |
|
|
388
|
+
| Sequelize TLS disabled | CWE-295 | HIGH |
|
|
389
|
+
| Timing oracle (non-constant compare) | CWE-208 | MEDIUM |
|
|
390
|
+
| postMessage no origin check | CWE-346 | MEDIUM |
|
|
391
|
+
| X-Powered-By exposed | CWE-200 | MEDIUM |
|
|
392
|
+
| Logger data leakage | CWE-532 | MEDIUM |
|
|
393
|
+
| Silent exception swallow | CWE-390 | MEDIUM |
|
|
394
|
+
| Insecure WebSocket URL | CWE-311 | MEDIUM |
|