@lhi/tdd-audit 1.4.0 → 1.4.1

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.
Files changed (2) hide show
  1. package/lib/scanner.js +309 -0
  2. package/package.json +2 -1
package/lib/scanner.js ADDED
@@ -0,0 +1,309 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ─── Vulnerability Patterns ───────────────────────────────────────────────────
7
+
8
+ const VULN_PATTERNS = [
9
+ { name: 'SQL Injection', severity: 'CRITICAL', pattern: /(`SELECT[^`]*\$\{|"SELECT[^"]*"\s*\+|execute\(f"|cursor\.execute\(.*%s|\.query\(`[^`]*\$\{)/i },
10
+ { name: 'Command Injection', severity: 'CRITICAL', pattern: /\bexec(Sync)?\s*\(.*req\.(params|body|query)|subprocess\.(run|Popen|call)\([^)]*shell\s*=\s*True/i },
11
+ { name: 'IDOR', severity: 'HIGH', pattern: /findById\s*\(\s*req\.(params|body|query)\.|findOne\s*\(\s*\{[^}]*id\s*:\s*req\.(params|body|query)/i },
12
+ { name: 'XSS', severity: 'HIGH', pattern: /[^/]innerHTML\s*=(?!=)|dangerouslySetInnerHTML\s*=\s*\{\{|document\.write\s*\(|res\.send\s*\(`[^`]*\$\{req\./i },
13
+ { name: 'Path Traversal', severity: 'HIGH', pattern: /(readFile|sendFile|createReadStream|open)\s*\(.*req\.(params|body|query)|path\.join\s*\([^)]*req\.(params|body|query)/i },
14
+ { name: 'Broken Auth', severity: 'HIGH', pattern: /jwt\.decode\s*\((?![^;]*\.verify)|verify\s*:\s*false|secret\s*=\s*['"][a-z0-9]{1,20}['"]/i },
15
+ // Vibecoding / mobile stacks
16
+ { name: 'Sensitive Storage', severity: 'HIGH', pattern: /(localStorage|AsyncStorage)\.setItem\s*\(\s*['"](token|password|secret|auth|jwt|api.?key)['"]/i },
17
+ { name: 'TLS Bypass', severity: 'CRITICAL', pattern: /badCertificateCallback[^;]*=\s*true|rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/i },
18
+ { name: 'Hardcoded Secret', severity: 'CRITICAL', skipInTests: true, pattern: /(?:const|final|var|let|static)\s+(?:API_KEY|PRIVATE_KEY|SECRET_KEY|ACCESS_TOKEN|CLIENT_SECRET)\s*=\s*['"][A-Za-z0-9+/=_\-]{20,}['"]/i },
19
+ { name: 'eval() Injection', severity: 'HIGH', pattern: /\beval\s*\([^)]*(?:route\.params|searchParams\.get|req\.(query|body)|params\[)/i },
20
+ // Common vibecoding anti-patterns
21
+ { name: 'Insecure Random', severity: 'HIGH', pattern: /(?:token|sessionId|nonce|secret|csrf)\w*\s*=.*Math\.random\(\)|Math\.random\(\).*(?:token|session|nonce|secret)/i },
22
+ { name: 'Sensitive Log', severity: 'MEDIUM', skipInTests: true, pattern: /console\.(log|info|debug)\([^)]*(?:token|password|secret|jwt|authorization|apiKey|api_key)/i },
23
+ { name: 'Secret Fallback', severity: 'HIGH', pattern: /process\.env\.\w+\s*\|\|\s*['"][A-Za-z0-9+/=_\-]{10,}['"]/i },
24
+ // SSRF, redirects, injection
25
+ { name: 'SSRF', severity: 'CRITICAL', pattern: /\b(?:fetch|axios\.(?:get|post|put|patch|delete|request)|got|https?\.get)\s*\(\s*req\.(?:query|body|params)\./i },
26
+ { name: 'Open Redirect', severity: 'HIGH', pattern: /res\.redirect\s*\(\s*req\.(?:query|body|params)\.|window\.location(?:\.href)?\s*=\s*(?:params\.|route\.params\.|searchParams\.get)/i },
27
+ { name: 'NoSQL Injection', severity: 'HIGH', pattern: /\.(?:find|findOne|findById|updateOne|deleteOne)\s*\(\s*req\.(?:body|query|params)\b|\$where\s*:\s*['"`]/i },
28
+ { name: 'Template Injection', severity: 'HIGH', pattern: /res\.render\s*\(\s*req\.(?:params|body|query)\.|(?:ejs|pug|nunjucks|handlebars)\.render(?:File)?\s*\([^)]*req\.(?:body|params|query)/i },
29
+ { name: 'Insecure Deserialization',severity: 'CRITICAL', pattern: /\.unserialize\s*\(.*req\.|__proto__\s*[=:][^=]|Object\.setPrototypeOf\s*\([^,]+,\s*req\./i },
30
+ // Assignment / pollution
31
+ { name: 'Mass Assignment', severity: 'HIGH', pattern: /new\s+\w+\s*\(\s*req\.body\b|\.create\s*\(\s*req\.body\b|\.update(?:One)?\s*\(\s*\{[^}]*\},\s*req\.body\b/i },
32
+ { name: 'Prototype Pollution', severity: 'HIGH', pattern: /(?:_\.merge|lodash\.merge|deepmerge|hoek\.merge)\s*\([^)]*req\.(?:body|query|params)/i },
33
+ // Crypto / config
34
+ { name: 'Weak Crypto', severity: 'HIGH', pattern: /createHash\s*\(\s*['"](?:md5|sha1)['"]\)|(?:md5|sha1)\s*\(\s*(?:password|passwd|pwd|secret)/i },
35
+ { name: 'CORS Wildcard', severity: 'MEDIUM', pattern: /cors\s*\(\s*\{\s*origin\s*:\s*['"]?\*['"]?|['"]Access-Control-Allow-Origin['"]\s*,\s*['"]?\*/i },
36
+ { name: 'Cleartext Traffic', severity: 'MEDIUM', skipInTests: true, pattern: /(?:baseURL|apiUrl|API_URL|endpoint|baseUrl)\s*[:=]\s*['"]http:\/\/(?!localhost|127\.0\.0\.1)/i },
37
+ { name: 'XXE', severity: 'HIGH', pattern: /noent\s*:\s*true|expand_entities\s*=\s*True|resolve_entities\s*=\s*True/i },
38
+ // Mobile / WebView
39
+ { name: 'WebView JS Bridge', severity: 'HIGH', pattern: /addJavascriptInterface\s*\(|javaScriptEnabled\s*:\s*true|allowFileAccess\s*:\s*true|allowUniversalAccessFromFileURLs\s*:\s*true/i },
40
+ { name: 'Deep Link Injection', severity: 'MEDIUM', pattern: /Linking\.getInitialURL\s*\(\)|Linking\.addEventListener\s*\(\s*['"]url['"]/i },
41
+ ];
42
+
43
+ const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart']);
44
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
45
+
46
+ // ─── Framework Detection ──────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Detect the test framework used in the given project directory.
50
+ * @param {string} dir - absolute path to the project root
51
+ * @returns {'flutter'|'vitest'|'jest'|'mocha'|'pytest'|'go'}
52
+ */
53
+ function detectFramework(dir) {
54
+ // Flutter / Dart — check before package.json since a Flutter project may have both
55
+ if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return 'flutter';
56
+
57
+ const pkgPath = path.join(dir, 'package.json');
58
+ if (fs.existsSync(pkgPath)) {
59
+ try {
60
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
61
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
62
+ if (deps.vitest) return 'vitest';
63
+ if (deps.jest || deps.supertest) return 'jest';
64
+ if (deps.mocha) return 'mocha';
65
+ } catch {}
66
+ }
67
+ if (
68
+ fs.existsSync(path.join(dir, 'pytest.ini')) ||
69
+ fs.existsSync(path.join(dir, 'pyproject.toml')) ||
70
+ fs.existsSync(path.join(dir, 'setup.py')) ||
71
+ fs.existsSync(path.join(dir, 'requirements.txt'))
72
+ ) return 'pytest';
73
+ if (fs.existsSync(path.join(dir, 'go.mod'))) return 'go';
74
+ return 'jest';
75
+ }
76
+
77
+ /**
78
+ * Detect the UI/app framework used in the given project directory.
79
+ * @param {string} dir - absolute path to the project root
80
+ * @returns {'flutter'|'expo'|'react-native'|'nextjs'|'react'|null}
81
+ */
82
+ function detectAppFramework(dir) {
83
+ if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return 'flutter';
84
+ const pkgPath = path.join(dir, 'package.json');
85
+ if (fs.existsSync(pkgPath)) {
86
+ try {
87
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
88
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
89
+ if (deps.expo) return 'expo';
90
+ if (deps['react-native']) return 'react-native';
91
+ if (deps.next) return 'nextjs';
92
+ if (deps.react) return 'react';
93
+ } catch {}
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ─── Test Directory Detection ─────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Detect the test base directory convention used in the given project.
102
+ * @param {string} dir - absolute path to the project root
103
+ * @param {string} framework - test framework (from detectFramework)
104
+ * @returns {string} - relative directory name, e.g. '__tests__'
105
+ */
106
+ function detectTestBaseDir(dir, framework) {
107
+ const candidates = ['__tests__', 'tests', 'test', 'spec'];
108
+ for (const candidate of candidates) {
109
+ if (fs.existsSync(path.join(dir, candidate))) return candidate;
110
+ }
111
+ if (framework === 'pytest') return 'tests';
112
+ if (framework === 'go') return 'test';
113
+ return '__tests__';
114
+ }
115
+
116
+ // ─── File Walking ─────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Generator that yields all scannable file paths under dir, skipping
120
+ * known noise dirs and symlinks (to avoid escaping the project root).
121
+ * @param {string} dir - directory to walk
122
+ */
123
+ function* walkFiles(dir) {
124
+ let entries;
125
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
126
+ for (const entry of entries) {
127
+ if (SKIP_DIRS.has(entry.name)) continue;
128
+ // Skip symlinks — they can escape the project root (M2 fix)
129
+ if (entry.isSymbolicLink()) continue;
130
+ const fullPath = path.join(dir, entry.name);
131
+ if (entry.isDirectory()) yield* walkFiles(fullPath);
132
+ else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) yield fullPath;
133
+ }
134
+ }
135
+
136
+ // ─── Test-file detection ──────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Returns true if the file is a test/spec file.
140
+ * @param {string} filePath - absolute path
141
+ * @param {string} projectDir - absolute project root (used for relative path calc)
142
+ */
143
+ function isTestFile(filePath, projectDir) {
144
+ const rel = path.relative(projectDir, filePath).replace(/\\/g, '/');
145
+ return (
146
+ /[._-]test\.[a-z]+$/.test(rel) || // *.test.js / *.test.ts
147
+ /[._-]spec\.[a-z]+$/.test(rel) || // *.spec.js / *.spec.ts
148
+ /_test\.dart$/.test(rel) || // *_test.dart (Flutter)
149
+ /(^|\/)(__tests__|tests?)\//.test(rel) || // __tests__/ or tests/ at any depth
150
+ /(^|\/)spec\//.test(rel) || // spec/ at any depth
151
+ /(^|\/)test_/.test(rel) // test_helpers.js style
152
+ );
153
+ }
154
+
155
+ // ─── Config / Manifest Scanners ───────────────────────────────────────────────
156
+
157
+ /**
158
+ * Scan app.json / app.config.* for embedded secrets.
159
+ * @param {string} projectDir - project root
160
+ * @returns {Array}
161
+ */
162
+ function scanAppConfig(projectDir) {
163
+ const findings = [];
164
+ const configCandidates = ['app.json', 'app.config.js', 'app.config.ts'];
165
+ // Match quoted string values AND template-literal fallback secrets (L2 fix)
166
+ const secretPattern = /['"]?(?:apiKey|api_key|secret|privateKey|accessToken|clientSecret)['"]?\s*[:=]\s*(?:['"][A-Za-z0-9+/=_\-]{20,}['"]|`[^`]*['"][A-Za-z0-9+/=_\-]{10,}['"][^`]*`)/i;
167
+
168
+ for (const name of configCandidates) {
169
+ const filePath = path.join(projectDir, name);
170
+ if (!fs.existsSync(filePath)) continue;
171
+ let lines;
172
+ try { lines = fs.readFileSync(filePath, 'utf8').split('\n'); } catch { continue; }
173
+ for (let i = 0; i < lines.length; i++) {
174
+ if (secretPattern.test(lines[i])) {
175
+ findings.push({
176
+ severity: 'CRITICAL',
177
+ name: 'Config Secret',
178
+ file: name,
179
+ line: i + 1,
180
+ snippet: lines[i].trim().slice(0, 80),
181
+ inTestFile: false,
182
+ });
183
+ }
184
+ }
185
+ }
186
+ return findings;
187
+ }
188
+
189
+ /**
190
+ * Scan AndroidManifest.xml for android:debuggable="true".
191
+ * @param {string} projectDir - project root
192
+ * @returns {Array}
193
+ */
194
+ function scanAndroidManifest(projectDir) {
195
+ const findings = [];
196
+ const manifestPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
197
+ if (!fs.existsSync(manifestPath)) return findings;
198
+ let lines;
199
+ try { lines = fs.readFileSync(manifestPath, 'utf8').split('\n'); } catch { return findings; }
200
+ for (let i = 0; i < lines.length; i++) {
201
+ if (/android:debuggable\s*=\s*["']true["']/i.test(lines[i])) {
202
+ findings.push({
203
+ severity: 'HIGH',
204
+ name: 'Android Debuggable',
205
+ file: 'android/app/src/main/AndroidManifest.xml',
206
+ line: i + 1,
207
+ snippet: lines[i].trim().slice(0, 80),
208
+ inTestFile: false,
209
+ likelyFalsePositive: false,
210
+ });
211
+ }
212
+ }
213
+ return findings;
214
+ }
215
+
216
+ // ─── Quick Scan ───────────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Scan all source files in projectDir for known vulnerability patterns.
220
+ * @param {string} projectDir - project root to scan
221
+ * @returns {Array} findings
222
+ */
223
+ function quickScan(projectDir) {
224
+ const findings = [];
225
+ for (const filePath of walkFiles(projectDir)) {
226
+ const inTest = isTestFile(filePath, projectDir);
227
+ let content;
228
+ // L1 fix: guard against binary / non-UTF-8 files
229
+ try {
230
+ content = fs.readFileSync(filePath, 'utf8');
231
+ } catch {
232
+ continue;
233
+ }
234
+ // Skip files that contain null bytes — likely binary
235
+ if (content.includes('\0')) continue;
236
+
237
+ const lines = content.split('\n');
238
+ for (let i = 0; i < lines.length; i++) {
239
+ // M3 fix: collect ALL matching patterns per line (no break)
240
+ for (const vuln of VULN_PATTERNS) {
241
+ if (vuln.pattern.test(lines[i])) {
242
+ findings.push({
243
+ severity: vuln.severity,
244
+ name: vuln.name,
245
+ file: path.relative(projectDir, filePath),
246
+ line: i + 1,
247
+ snippet: lines[i].trim().slice(0, 80),
248
+ inTestFile: inTest,
249
+ likelyFalsePositive: inTest && !!vuln.skipInTests,
250
+ });
251
+ }
252
+ }
253
+ }
254
+ }
255
+ return [...findings, ...scanAppConfig(projectDir), ...scanAndroidManifest(projectDir)];
256
+ }
257
+
258
+ // ─── Print Findings ───────────────────────────────────────────────────────────
259
+
260
+ /**
261
+ * Print a human-readable findings report to stdout.
262
+ * @param {Array} findings
263
+ */
264
+ function printFindings(findings) {
265
+ if (findings.length === 0) {
266
+ console.log(' ✅ No obvious vulnerability patterns detected.\n');
267
+ return;
268
+ }
269
+ const real = findings.filter(f => !f.likelyFalsePositive);
270
+ const noisy = findings.filter(f => f.likelyFalsePositive);
271
+
272
+ const bySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
273
+ for (const f of real) (bySeverity[f.severity] || bySeverity.LOW).push(f);
274
+ const icons = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🔵' };
275
+
276
+ console.log(`\n Found ${real.length} potential issue(s)${noisy.length ? ` (+${noisy.length} in test files — see below)` : ''}:\n`);
277
+ for (const [sev, list] of Object.entries(bySeverity)) {
278
+ if (!list.length) continue;
279
+ for (const f of list) {
280
+ const testBadge = f.inTestFile ? ' [test file]' : '';
281
+ console.log(` ${icons[sev]} [${sev}] ${f.name} — ${f.file}:${f.line}${testBadge}`);
282
+ console.log(` ${f.snippet}`);
283
+ }
284
+ }
285
+
286
+ if (noisy.length) {
287
+ console.log('\n ⚪ Likely intentional (in test files — verify manually):');
288
+ for (const f of noisy) {
289
+ console.log(` ${f.name} — ${f.file}:${f.line}`);
290
+ }
291
+ }
292
+
293
+ console.log('\n Run /tdd-audit in your agent to remediate.\n');
294
+ }
295
+
296
+ module.exports = {
297
+ VULN_PATTERNS,
298
+ SCAN_EXTENSIONS,
299
+ SKIP_DIRS,
300
+ detectFramework,
301
+ detectAppFramework,
302
+ detectTestBaseDir,
303
+ walkFiles,
304
+ isTestFile,
305
+ scanAppConfig,
306
+ scanAndroidManifest,
307
+ quickScan,
308
+ printFindings,
309
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lhi/tdd-audit",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Anti-Gravity Skill for TDD Remediation. Patches security vulnerabilities using a Red-Green-Refactor protocol with automated exploit tests.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
+ "lib/",
11
12
  "SKILL.md",
12
13
  "prompts/",
13
14
  "templates/",