@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
package/lib/badge.js
CHANGED
|
@@ -8,6 +8,24 @@ const BADGE_MARKER = 'tdd-audit-badge';
|
|
|
8
8
|
|
|
9
9
|
const NPM_URL = 'https://www.npmjs.com/package/@lhi/tdd-audit';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Validate that a URL uses http: or https:. Returns the URL if safe, NPM_URL otherwise.
|
|
13
|
+
* Prevents javascript:, data:, file:, and protocol-relative URLs from landing in
|
|
14
|
+
* badge links or SARIF output.
|
|
15
|
+
* @param {string} raw
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function safeSiteUrl(raw) {
|
|
19
|
+
if (!raw || !raw.trim()) return NPM_URL;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(raw.trim());
|
|
22
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return NPM_URL;
|
|
23
|
+
return raw.trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return NPM_URL;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
/**
|
|
12
30
|
* Build a shields.io badge markdown line reflecting actual scan results.
|
|
13
31
|
*
|
|
@@ -22,11 +40,15 @@ const NPM_URL = 'https://www.npmjs.com/package/@lhi/tdd-audit';
|
|
|
22
40
|
* no config file exists — the link falls back to the @lhi/tdd-audit npm page
|
|
23
41
|
* so readers always know where the security tooling came from.
|
|
24
42
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
43
|
+
* The badge label text defaults to "tdd-audit" but can be overridden via
|
|
44
|
+
* `badge_label` in .tdd-audit.json — useful for white-labeled distributions.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array} findings - findings array returned by quickScan()
|
|
47
|
+
* @param {string} [siteUrl] - optional override link (from config.tdd_site)
|
|
48
|
+
* @param {string} [label] - optional badge label (from config.badge_label)
|
|
49
|
+
* @returns {string} - single-line markdown badge ending with \n
|
|
28
50
|
*/
|
|
29
|
-
function badgeLine(findings, siteUrl) {
|
|
51
|
+
function badgeLine(findings, siteUrl, label) {
|
|
30
52
|
// Exclude test-file findings and likely false positives — badge reflects production code only
|
|
31
53
|
const real = (findings || []).filter(f => !f.likelyFalsePositive && !f.inTestFile);
|
|
32
54
|
const criticals = real.filter(f => f.severity === 'CRITICAL').length;
|
|
@@ -44,11 +66,16 @@ function badgeLine(findings, siteUrl) {
|
|
|
44
66
|
color = 'brightgreen';
|
|
45
67
|
}
|
|
46
68
|
|
|
47
|
-
const
|
|
48
|
-
|
|
69
|
+
const rawLabel = (label && label.trim()) ? label.trim() : 'tdd-audit';
|
|
70
|
+
// Strip markdown link-breaking characters before embedding in alt text or URL
|
|
71
|
+
const badgeLabel = rawLabel.replace(/[[\]()]/g, '');
|
|
72
|
+
// Encode the label for use in a shields.io URL (spaces → %20, hyphens → --)
|
|
73
|
+
const encodedLabel = badgeLabel.replace(/ /g, '%20').replace(/-/g, '--');
|
|
74
|
+
const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${message}-${color}`;
|
|
75
|
+
const targetUrl = safeSiteUrl(siteUrl);
|
|
49
76
|
// Embed the marker as a hidden HTML comment after the badge so injectBadge()
|
|
50
77
|
// can locate and replace the line on subsequent runs.
|
|
51
|
-
return `[](${targetUrl}) <!-- ${BADGE_MARKER} -->\n`;
|
|
52
79
|
}
|
|
53
80
|
|
|
54
81
|
/**
|
package/lib/config.js
CHANGED
|
@@ -18,6 +18,47 @@ const DEFAULTS = {
|
|
|
18
18
|
serverApiKey: null, // key required on REST API calls
|
|
19
19
|
trustProxy: false, // trust X-Forwarded-For for rate limiting
|
|
20
20
|
tdd_site: null, // custom URL for the README badge link; falls back to npm page
|
|
21
|
+
|
|
22
|
+
// Branding — for wrapper/rebranded distributions
|
|
23
|
+
org: null, // org name in reports, SECURITY.md, and pattern PRs
|
|
24
|
+
project: null, // project name in reports and pattern contribution branch names
|
|
25
|
+
badge_label: null, // badge label text; defaults to 'tdd-audit'
|
|
26
|
+
security_name: null, // name of the security contact (stamped into SECURITY.md, compliance reports, and webhook payloads)
|
|
27
|
+
security_email: null, // email of the security contact (used as the vulnerability reporting address in SECURITY.md)
|
|
28
|
+
|
|
29
|
+
// Extensibility — both the CLI and the Claude Code skill honour these
|
|
30
|
+
pattern_repos: [], // [{name, url, local_path, namespace}] — RAG-indexed at startup
|
|
31
|
+
extra_skill_dirs: [], // relative paths to extra Claude Code skill directories
|
|
32
|
+
extra_repos: [], // [{url, local_path}] — cloned/pulled for reference
|
|
33
|
+
mcp_services: [], // [{name, cwd, command, args}] — started before first agent turn
|
|
34
|
+
extra_domains: [], // [{name, prompt_file}] — custom audit domains
|
|
35
|
+
|
|
36
|
+
// Policy as code — org-level severity overrides
|
|
37
|
+
// e.g. { "CORS Wildcard": "CRITICAL", "Sensitive Log": "HIGH" }
|
|
38
|
+
severity_overrides: {},
|
|
39
|
+
|
|
40
|
+
// Notifications — fire on scan complete
|
|
41
|
+
webhook_url: null, // POST findings JSON to this URL on scan complete
|
|
42
|
+
slack_webhook: null, // Slack incoming webhook URL for findings summary
|
|
43
|
+
slack_channel: null, // override default channel for the Slack webhook
|
|
44
|
+
|
|
45
|
+
// Workflow integration
|
|
46
|
+
open_pr: false, // open a GitHub PR per finding instead of committing directly
|
|
47
|
+
github_token: null, // token for PR creation; falls back to GITHUB_TOKEN env var
|
|
48
|
+
github_repo: null, // 'owner/repo' for PR creation; auto-detected from git remote if null
|
|
49
|
+
|
|
50
|
+
// Scheduled / CI modes
|
|
51
|
+
schedule: null, // cron expression — used by external schedulers, not the CLI itself
|
|
52
|
+
pr_mode: false, // lightweight scan only (no agents, no RAG) — designed for CI PR gates
|
|
53
|
+
org_scan: null, // GitHub org name — scan all repos in the org
|
|
54
|
+
|
|
55
|
+
// Output additions
|
|
56
|
+
sbom: false, // generate a CycloneDX SBOM alongside the audit report
|
|
57
|
+
report: false, // generate a human-readable compliance report (PDF/markdown)
|
|
58
|
+
watch: false, // re-scan affected files on change (watch mode)
|
|
59
|
+
|
|
60
|
+
// Secret rotation — when a hardcoded key is found, offer to rotate via provider API
|
|
61
|
+
rotate_secrets: false, // prompt to rotate detected secrets via provider API
|
|
21
62
|
};
|
|
22
63
|
|
|
23
64
|
// Provider-specific defaults for `tdd-audit init --provider <name>`
|
|
@@ -117,7 +158,15 @@ function parseCliOverrides(args) {
|
|
|
117
158
|
const baseUrl = get('--base-url'); if (baseUrl) overrides.baseUrl = baseUrl;
|
|
118
159
|
const format = get('--format'); if (format) overrides.output = format;
|
|
119
160
|
const srvKey = get('--api-key'); if (srvKey) overrides.serverApiKey = srvKey;
|
|
120
|
-
|
|
161
|
+
const threshold = get('--threshold'); if (threshold) overrides.severityThreshold = threshold;
|
|
162
|
+
const org = get('--org'); if (org) overrides.org_scan = org;
|
|
163
|
+
if (args.includes('--json')) overrides.output = 'json';
|
|
164
|
+
if (args.includes('--pr')) overrides.pr_mode = true;
|
|
165
|
+
if (args.includes('--open-pr')) overrides.open_pr = true;
|
|
166
|
+
if (args.includes('--sbom')) overrides.sbom = true;
|
|
167
|
+
if (args.includes('--watch')) overrides.watch = true;
|
|
168
|
+
if (args.includes('--report')) overrides.report = true;
|
|
169
|
+
if (args.includes('--rotate-secrets')) overrides.rotate_secrets = true;
|
|
121
170
|
return overrides;
|
|
122
171
|
}
|
|
123
172
|
|
package/lib/github.js
CHANGED
package/lib/plugin.js
CHANGED
|
@@ -4,9 +4,8 @@ const crypto = require('crypto');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const Fastify = require('fastify');
|
|
6
6
|
|
|
7
|
-
const { quickScan }
|
|
8
|
-
const {
|
|
9
|
-
const { remediate } = require('./remediator');
|
|
7
|
+
const { quickScan } = require('./scanner');
|
|
8
|
+
const { remediate } = require('./remediator');
|
|
10
9
|
const { version } = require('../package.json');
|
|
11
10
|
const {
|
|
12
11
|
jobs, createJob, updateJob, subscribe, MAX_JOBS,
|
|
@@ -56,6 +55,26 @@ function createRateLimit() {
|
|
|
56
55
|
};
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
// ─── Webhook URL validation ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const PRIVATE_IP = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|::1$|fc00:|fd)/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a user-supplied webhook URL before calling fetch().
|
|
64
|
+
* Accepts only https:// with a non-private hostname.
|
|
65
|
+
* Throws an Error describing the violation so callers can return 400.
|
|
66
|
+
*/
|
|
67
|
+
function assertSafeWebhook(url) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try { parsed = new URL(url); } catch { throw new Error('Invalid webhook URL'); }
|
|
70
|
+
if (parsed.protocol !== 'https:') {
|
|
71
|
+
throw new Error('webhook must use https://');
|
|
72
|
+
}
|
|
73
|
+
if (PRIVATE_IP.test(parsed.hostname) || parsed.hostname === 'localhost') {
|
|
74
|
+
throw new Error('webhook hostname is not allowed');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
// ─── Path validation ──────────────────────────────────────────────────────────
|
|
60
79
|
|
|
61
80
|
function safeScanPath(rawPath) {
|
|
@@ -116,26 +135,6 @@ async function tddAuditPlugin(fastify, opts) {
|
|
|
116
135
|
}
|
|
117
136
|
});
|
|
118
137
|
|
|
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
138
|
// ── POST /remediate ──────────────────────────────────────────────────────
|
|
140
139
|
fastify.post('/remediate', async (request, reply) => {
|
|
141
140
|
const body = request.body || {};
|
|
@@ -173,6 +172,11 @@ async function tddAuditPlugin(fastify, opts) {
|
|
|
173
172
|
try { scanPath = safeScanPath(rawPath); }
|
|
174
173
|
catch (e) { return reply.code(400).send({ error: e.message }); }
|
|
175
174
|
|
|
175
|
+
if (webhook) {
|
|
176
|
+
try { assertSafeWebhook(webhook); }
|
|
177
|
+
catch (e) { return reply.code(400).send({ error: `Invalid webhook: ${e.message}` }); }
|
|
178
|
+
}
|
|
179
|
+
|
|
176
180
|
const jobId = createJob();
|
|
177
181
|
|
|
178
182
|
setImmediate(async () => {
|
|
@@ -225,6 +229,96 @@ async function tddAuditPlugin(fastify, opts) {
|
|
|
225
229
|
return reply.code(202).send({ jobId });
|
|
226
230
|
});
|
|
227
231
|
|
|
232
|
+
// ── POST /audit/ai — LLM-powered agentic audit ────────────────────────────
|
|
233
|
+
// Accepts { path?, provider?, apiKey?, model?, baseUrl?, scanOnly?, allowWrites? }
|
|
234
|
+
// Falls back to cfg values for provider/apiKey/model/baseUrl when not supplied.
|
|
235
|
+
// Returns 202 { jobId }. Poll GET /jobs/:id or stream GET /jobs/:id/stream.
|
|
236
|
+
fastify.post('/audit/ai', async (request, reply) => {
|
|
237
|
+
const body = request.body || {};
|
|
238
|
+
const {
|
|
239
|
+
path: rawPath,
|
|
240
|
+
provider: bodyProvider,
|
|
241
|
+
apiKey: bodyApiKey,
|
|
242
|
+
model: bodyModel,
|
|
243
|
+
baseUrl: bodyBaseUrl,
|
|
244
|
+
depth = 'tier-1',
|
|
245
|
+
// scanOnly / allowWrites may still be overridden explicitly; depth takes precedence otherwise
|
|
246
|
+
scanOnly = null,
|
|
247
|
+
allowWrites = false,
|
|
248
|
+
// Pre-identified findings from a prior tier-3 report (triggers targeted-apply mode when depth=tier-4)
|
|
249
|
+
findings = null,
|
|
250
|
+
} = body;
|
|
251
|
+
|
|
252
|
+
const provider = bodyProvider || cfg.provider;
|
|
253
|
+
const apiKey = bodyApiKey || cfg.apiKey;
|
|
254
|
+
const model = bodyModel || cfg.model;
|
|
255
|
+
const baseUrl = bodyBaseUrl || cfg.baseUrl;
|
|
256
|
+
|
|
257
|
+
if (!provider || !apiKey) {
|
|
258
|
+
return reply.code(400).send({
|
|
259
|
+
error: 'provider and apiKey are required (supply in body or configure in .tdd-audit.json)',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let scanPath = process.cwd();
|
|
264
|
+
if (rawPath) {
|
|
265
|
+
try { scanPath = safeScanPath(rawPath); }
|
|
266
|
+
catch (e) { return reply.code(400).send({ error: e.message }); }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const jobId = createJob();
|
|
270
|
+
updateJob(jobId, { depth }); // stamp depth on initial pending state
|
|
271
|
+
|
|
272
|
+
setImmediate(async () => {
|
|
273
|
+
try {
|
|
274
|
+
const log = [];
|
|
275
|
+
|
|
276
|
+
updateJob(jobId, { status: 'running', depth, startedAt: new Date().toISOString() });
|
|
277
|
+
|
|
278
|
+
const { runAudit } = require('./auditor');
|
|
279
|
+
let capturedJson = null;
|
|
280
|
+
|
|
281
|
+
await runAudit({
|
|
282
|
+
projectDir: scanPath,
|
|
283
|
+
packageDir: path.join(__dirname, '..'),
|
|
284
|
+
provider,
|
|
285
|
+
apiKey,
|
|
286
|
+
model,
|
|
287
|
+
baseUrl,
|
|
288
|
+
outputFormat: 'json',
|
|
289
|
+
depth,
|
|
290
|
+
scanOnly,
|
|
291
|
+
allowWrites,
|
|
292
|
+
findings,
|
|
293
|
+
onText: (text) => {
|
|
294
|
+
log.push(text);
|
|
295
|
+
updateJob(jobId, { status: 'running', log: log.join(''), startedAt: new Date().toISOString() });
|
|
296
|
+
},
|
|
297
|
+
outputWriter: (jsonStr) => { capturedJson = jsonStr; },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
let result;
|
|
301
|
+
try {
|
|
302
|
+
result = capturedJson ? JSON.parse(capturedJson.trim()) : { log: log.join('') };
|
|
303
|
+
} catch {
|
|
304
|
+
result = { raw: capturedJson, log: log.join('') };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
updateJob(jobId, {
|
|
308
|
+
status: 'done',
|
|
309
|
+
completedAt: new Date().toISOString(),
|
|
310
|
+
result,
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
updateJob(jobId, { status: 'error', error: err.message });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
reply.header('Location', `/jobs/${jobId}`);
|
|
318
|
+
reply.header('Retry-After', '5');
|
|
319
|
+
return reply.code(202).send({ jobId });
|
|
320
|
+
});
|
|
321
|
+
|
|
228
322
|
// ── GET /jobs/:id ────────────────────────────────────────────────────────
|
|
229
323
|
fastify.get('/jobs/:id', async (request, reply) => {
|
|
230
324
|
const job = jobs.get(request.params.id);
|
|
@@ -303,6 +397,7 @@ module.exports = {
|
|
|
303
397
|
buildApp,
|
|
304
398
|
authenticate,
|
|
305
399
|
safeScanPath,
|
|
400
|
+
assertSafeWebhook,
|
|
306
401
|
createRateLimit,
|
|
307
402
|
RATE_LIMIT_MAX,
|
|
308
403
|
};
|
package/lib/reporter.js
CHANGED
|
@@ -8,16 +8,17 @@ const { version } = require('../package.json');
|
|
|
8
8
|
* Return findings as a structured JSON-serialisable object.
|
|
9
9
|
* @param {Array} findings
|
|
10
10
|
* @param {string[]} [exempted=[]]
|
|
11
|
+
* @param {object} [config={}] - loaded config; security_officer stamped when set
|
|
11
12
|
* @returns {object}
|
|
12
13
|
*/
|
|
13
|
-
function toJson(findings, exempted = []) {
|
|
14
|
+
function toJson(findings, exempted = [], config = {}) {
|
|
14
15
|
const real = findings.filter(f => !f.likelyFalsePositive);
|
|
15
16
|
const noisy = findings.filter(f => f.likelyFalsePositive);
|
|
16
17
|
|
|
17
18
|
const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
18
19
|
for (const f of real) summary[f.severity] = (summary[f.severity] || 0) + 1;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
const envelope = {
|
|
21
22
|
version,
|
|
22
23
|
summary,
|
|
23
24
|
findings: real,
|
|
@@ -25,6 +26,9 @@ function toJson(findings, exempted = []) {
|
|
|
25
26
|
exempted,
|
|
26
27
|
scannedAt: new Date().toISOString(),
|
|
27
28
|
};
|
|
29
|
+
if (config.security_name) envelope.security_name = config.security_name;
|
|
30
|
+
if (config.security_email) envelope.security_email = config.security_email;
|
|
31
|
+
return envelope;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
// ─── SARIF ────────────────────────────────────────────────────────────────────
|
|
@@ -60,13 +64,27 @@ const CWE_MAP = {
|
|
|
60
64
|
'Timing-Unsafe Comparison': 'CWE-208',
|
|
61
65
|
};
|
|
62
66
|
|
|
67
|
+
const NPM_URL = 'https://www.npmjs.com/package/@lhi/tdd-audit';
|
|
68
|
+
|
|
69
|
+
function safeSiteUrl(raw) {
|
|
70
|
+
if (!raw || !raw.trim()) return NPM_URL;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = new URL(raw.trim());
|
|
73
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return NPM_URL;
|
|
74
|
+
return raw.trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return NPM_URL;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
/**
|
|
64
81
|
* Return findings as a SARIF 2.1.0 object (GitHub code scanning compatible).
|
|
65
82
|
* @param {Array} findings
|
|
66
83
|
* @param {string} [projectDir=''] - used to build relative artifact URIs
|
|
84
|
+
* @param {object} [config={}] - tdd-audit config (tdd_site, badge_label, org)
|
|
67
85
|
* @returns {object}
|
|
68
86
|
*/
|
|
69
|
-
function toSarif(findings, projectDir = '') {
|
|
87
|
+
function toSarif(findings, projectDir = '', config = {}) {
|
|
70
88
|
const rules = [];
|
|
71
89
|
const ruleIndex = {};
|
|
72
90
|
|
|
@@ -108,9 +126,9 @@ function toSarif(findings, projectDir = '') {
|
|
|
108
126
|
runs: [{
|
|
109
127
|
tool: {
|
|
110
128
|
driver: {
|
|
111
|
-
name: '@lhi/tdd-audit',
|
|
129
|
+
name: config.badge_label || '@lhi/tdd-audit',
|
|
112
130
|
version,
|
|
113
|
-
informationUri:
|
|
131
|
+
informationUri: safeSiteUrl(config.tdd_site),
|
|
114
132
|
rules,
|
|
115
133
|
},
|
|
116
134
|
},
|
package/lib/scanner.js
CHANGED
|
@@ -64,6 +64,35 @@ const VULN_PATTERNS = [
|
|
|
64
64
|
{ name: 'NEXT_PUBLIC Secret', severity: 'HIGH', skipInTests: true, pattern: /\bNEXT_PUBLIC_\w*(?:SECRET|PRIVATE|API_KEY|TOKEN|PASSWORD|CREDENTIAL)\w*/i },
|
|
65
65
|
{ name: 'Electron contextIsolation Off', severity: 'HIGH', pattern: /\bcontextIsolation\s*:\s*false\b/ },
|
|
66
66
|
{ name: 'Trojan Source', severity: 'HIGH', pattern: /[\u202A-\u202E\u2066-\u2069]/ },
|
|
67
|
+
|
|
68
|
+
// ── AI/LLM — deeper coverage (Semgrep ai-best-practices + OWASP LLM Top 10) ─
|
|
69
|
+
{ name: 'Hardcoded Gemini Key', severity: 'CRITICAL', skipInTests: true, pattern: /['"]AIza[A-Za-z0-9_\-]{35}['"]/ },
|
|
70
|
+
{ name: 'Hardcoded Cohere Key', severity: 'CRITICAL', skipInTests: true, pattern: /['"][A-Za-z0-9]{40}['"]\s*[,\n].*cohere|cohere.*['"][A-Za-z0-9]{40}['"]/i },
|
|
71
|
+
{ name: 'Hardcoded Mistral Key', severity: 'CRITICAL', skipInTests: true, pattern: /['"][A-Za-z0-9]{32}['"]\s*[,\n].*mistral|mistral.*['"][A-Za-z0-9]{32}['"]/i },
|
|
72
|
+
{ name: 'LLM Output to exec', severity: 'CRITICAL', pattern: /(?:exec|execSync|spawn|spawnSync)\s*\([^)]*(?:response|result|output|completion|generated|llmResult|aiResult)\b/i },
|
|
73
|
+
{ name: 'Missing max_tokens', severity: 'HIGH', pattern: /(?:messages\.create|chat\.completions\.create|generateContent)\s*\(\s*\{(?![^}]*max_tokens)(?![^}]*maxTokens)/i },
|
|
74
|
+
{ name: 'Missing system message', severity: 'MEDIUM', pattern: /(?:messages\.create|chat\.completions\.create)\s*\(\s*\{[^}]*messages\s*:\s*\[[^\]]*\{[^\]]*role\s*:\s*['"]user['"]/i },
|
|
75
|
+
{ name: 'MCP Credential in Response', severity: 'HIGH', pattern: /(?:tool_result|toolResult|function_result)\s*[=:][^;\n]{0,200}(?:password|secret|token|api.?key|credential)/i },
|
|
76
|
+
{ name: 'Agent Unbounded Loop', severity: 'HIGH', pattern: /while\s*\(\s*true\s*\)[^}]*(?:tool_use|function_call|tool_calls|runAgent|agent\.run)/i },
|
|
77
|
+
{ name: 'Unsafe Model Load', severity: 'HIGH', pattern: /torch\.load\s*\([^)]*(?<!weights_only\s*=\s*True)|pickle\.load\s*\([^)]*(?:req\.|url\.|download)/i },
|
|
78
|
+
|
|
79
|
+
// ── Node.js advanced (njsscan + Bearer + ESLint security) ────────────────────
|
|
80
|
+
{ name: 'Host Header Injection', severity: 'HIGH', pattern: /req\.(?:headers\[['"]host['"]\]|hostname|get\s*\(\s*['"]host['"]\))[^;\n]{0,120}(?:redirect|resetLink|confirmUrl|href|url)/i },
|
|
81
|
+
{ name: 'Headless Browser SSRF', severity: 'CRITICAL', pattern: /(?:page\.goto|page\.navigate|browser\.goto|wkhtmltopdf|wkhtmltoimage|phantom\.create)\s*\([^)]*req\.(?:query|body|params)/i },
|
|
82
|
+
{ name: 'Body Parser DoS', severity: 'HIGH', pattern: /express\.(?:json|urlencoded|text|raw)\s*\(\s*\)(?!\s*\/\/)|bodyParser\.(?:json|urlencoded)\s*\(\s*\)(?!\s*\/\/)/ },
|
|
83
|
+
{ name: 'vm2 Deprecated', severity: 'CRITICAL', pattern: /require\s*\(\s*['"]vm2['"]\)|from\s*['"]vm2['"]/i },
|
|
84
|
+
{ name: 'Pug Raw Output', severity: 'HIGH', pattern: /!\{(?!\s*#\{)[^}]+\}/ },
|
|
85
|
+
{ name: 'EJS Unescaped Output', severity: 'HIGH', pattern: /<%[-=](?!=)/ },
|
|
86
|
+
{ name: 'Handlebars Triple-Stache', severity: 'HIGH', pattern: /\{\{\{(?!\s*>)[^}]+\}\}\}/ },
|
|
87
|
+
{ name: 'postMessage No Origin', severity: 'HIGH', pattern: /addEventListener\s*\(\s*['"]message['"]\s*,[^)]+\)(?![^{]*event\.origin|[^{]*e\.origin)/i },
|
|
88
|
+
{ name: 'Dynamic Import User Input', severity: 'HIGH', pattern: /import\s*\(\s*req\.|import\s*\(\s*[a-z_]*[Pp]ath\s*\+|import\s*\(\s*`[^`]*\$\{req\./i },
|
|
89
|
+
{ name: 'JWT No Revocation', severity: 'HIGH', pattern: /jwt\.sign\s*\([^)]*expiresIn\s*:\s*['"][0-9]+[dDhH](?:[0-9]+)?['"]/i },
|
|
90
|
+
{ name: 'X-Powered-By Exposed', severity: 'MEDIUM', pattern: /app\s*=\s*express\s*\(\s*\)(?![^;]{0,500}disable\s*\(\s*['"]x-powered-by['"])/i },
|
|
91
|
+
{ name: 'GraphQL Introspection On', severity: 'HIGH', pattern: /introspection\s*:\s*true\b/i },
|
|
92
|
+
{ name: 'GraphQL No Depth Limit', severity: 'MEDIUM', pattern: /new ApolloServer\s*\(\s*\{(?![^}]*depthLimit|[^}]*createDepthLimitPlugin)/i },
|
|
93
|
+
{ name: 'Sequelize TLS Disabled', severity: 'HIGH', pattern: /dialectOptions[^}]*ssl\s*:\s*(?:false|require\s*:\s*false)/i },
|
|
94
|
+
{ name: 'Silent Exception Swallow', severity: 'MEDIUM', skipInTests: true, pattern: /catch\s*\([^)]*\)\s*\{\s*(?:\/\/[^\n]*)?\s*\}/i },
|
|
95
|
+
{ name: 'Insecure WebSocket URL', severity: 'MEDIUM', skipInTests: true, pattern: /new WebSocket\s*\(\s*['"]ws:\/\/(?!localhost|127\.0\.0\.1)/i },
|
|
67
96
|
];
|
|
68
97
|
|
|
69
98
|
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart', '.yml', '.yaml']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhi/tdd-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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": {
|