@lhi/tdd-audit 1.3.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.
package/index.js CHANGED
@@ -4,6 +4,14 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
+ const {
8
+ detectFramework,
9
+ detectAppFramework,
10
+ detectTestBaseDir,
11
+ quickScan,
12
+ printFindings,
13
+ } = require('./lib/scanner');
14
+
7
15
  const args = process.argv.slice(2);
8
16
  const isLocal = args.includes('--local');
9
17
  const isClaude = args.includes('--claude');
@@ -20,245 +28,22 @@ const targetWorkflowDir = isClaude
20
28
  ? path.join(agentBaseDir, agentDirName, 'commands')
21
29
  : path.join(agentBaseDir, agentDirName, 'workflows');
22
30
 
23
- // ─── 1. Framework Detection ──────────────────────────────────────────────────
24
-
25
- function detectFramework() {
26
- // Flutter / Dart — check before package.json since a Flutter project may have both
27
- if (fs.existsSync(path.join(projectDir, 'pubspec.yaml'))) return 'flutter';
28
-
29
- const pkgPath = path.join(projectDir, 'package.json');
30
- if (fs.existsSync(pkgPath)) {
31
- try {
32
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
33
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
34
- if (deps.vitest) return 'vitest';
35
- if (deps.jest || deps.supertest) return 'jest';
36
- if (deps.mocha) return 'mocha';
37
- } catch {}
38
- }
39
- if (
40
- fs.existsSync(path.join(projectDir, 'pytest.ini')) ||
41
- fs.existsSync(path.join(projectDir, 'pyproject.toml')) ||
42
- fs.existsSync(path.join(projectDir, 'setup.py')) ||
43
- fs.existsSync(path.join(projectDir, 'requirements.txt'))
44
- ) return 'pytest';
45
- if (fs.existsSync(path.join(projectDir, 'go.mod'))) return 'go';
46
- return 'jest';
47
- }
48
-
49
- // Detect the UI framework for richer scan context (React, Next.js, RN, Expo, Flutter)
50
- function detectAppFramework() {
51
- if (fs.existsSync(path.join(projectDir, 'pubspec.yaml'))) return 'flutter';
52
- const pkgPath = path.join(projectDir, 'package.json');
53
- if (fs.existsSync(pkgPath)) {
54
- try {
55
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
56
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
57
- if (deps.expo) return 'expo';
58
- if (deps['react-native']) return 'react-native';
59
- if (deps.next) return 'nextjs';
60
- if (deps.react) return 'react';
61
- } catch {}
62
- }
63
- return null;
64
- }
65
-
66
- const appFramework = detectAppFramework();
67
-
68
- const framework = detectFramework();
69
-
70
- // ─── 2. Test Directory Detection ─────────────────────────────────────────────
71
-
72
- function detectTestBaseDir() {
73
- // Respect an existing convention before inventing one
74
- const candidates = ['__tests__', 'tests', 'test', 'spec'];
75
- for (const dir of candidates) {
76
- if (fs.existsSync(path.join(projectDir, dir))) return dir;
77
- }
78
- // Framework-informed defaults when no directory exists yet
79
- if (framework === 'pytest') return 'tests';
80
- if (framework === 'go') return 'test';
81
- return '__tests__';
82
- }
83
-
84
- const testBaseDir = detectTestBaseDir();
31
+ const appFramework = detectAppFramework(projectDir);
32
+ const framework = detectFramework(projectDir);
33
+ const testBaseDir = detectTestBaseDir(projectDir, framework);
85
34
  const targetTestDir = path.join(projectDir, testBaseDir, 'security');
86
35
 
87
- // ─── 3. Quick Scan ───────────────────────────────────────────────────────────
88
-
89
- const VULN_PATTERNS = [
90
- { name: 'SQL Injection', severity: 'CRITICAL', pattern: /(`SELECT[^`]*\$\{|"SELECT[^"]*"\s*\+|execute\(f"|cursor\.execute\(.*%s|\.query\(`[^`]*\$\{)/i },
91
- { name: 'Command Injection', severity: 'CRITICAL', pattern: /\bexec(Sync)?\s*\(.*req\.(params|body|query)|subprocess\.(run|Popen|call)\([^)]*shell\s*=\s*True/i },
92
- { name: 'IDOR', severity: 'HIGH', pattern: /findById\s*\(\s*req\.(params|body|query)\.|findOne\s*\(\s*\{[^}]*id\s*:\s*req\.(params|body|query)/i },
93
- { name: 'XSS', severity: 'HIGH', pattern: /[^/]innerHTML\s*=(?!=)|dangerouslySetInnerHTML\s*=\s*\{\{|document\.write\s*\(|res\.send\s*\(`[^`]*\$\{req\./i },
94
- { name: 'Path Traversal', severity: 'HIGH', pattern: /(readFile|sendFile|createReadStream|open)\s*\(.*req\.(params|body|query)|path\.join\s*\([^)]*req\.(params|body|query)/i },
95
- { name: 'Broken Auth', severity: 'HIGH', pattern: /jwt\.decode\s*\((?![^;]*\.verify)|verify\s*:\s*false|secret\s*=\s*['"][a-z0-9]{1,20}['"]/i },
96
- // Vibecoding / mobile stacks
97
- { name: 'Sensitive Storage', severity: 'HIGH', pattern: /(localStorage|AsyncStorage)\.setItem\s*\(\s*['"](token|password|secret|auth|jwt|api.?key)['"]/i },
98
- { name: 'TLS Bypass', severity: 'CRITICAL', pattern: /badCertificateCallback[^;]*=\s*true|rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/i },
99
- { 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 },
100
- { name: 'eval() Injection', severity: 'HIGH', pattern: /\beval\s*\([^)]*(?:route\.params|searchParams\.get|req\.(query|body)|params\[)/i },
101
- // Common vibecoding anti-patterns
102
- { name: 'Insecure Random', severity: 'HIGH', pattern: /(?:token|sessionId|nonce|secret|csrf)\w*\s*=.*Math\.random\(\)|Math\.random\(\).*(?:token|session|nonce|secret)/i },
103
- { name: 'Sensitive Log', severity: 'MEDIUM', skipInTests: true, pattern: /console\.(log|info|debug)\([^)]*(?:token|password|secret|jwt|authorization|apiKey|api_key)/i },
104
- { name: 'Secret Fallback', severity: 'HIGH', pattern: /process\.env\.\w+\s*\|\|\s*['"][A-Za-z0-9+/=_\-]{10,}['"]/i },
105
- // SSRF, redirects, injection
106
- { name: 'SSRF', severity: 'CRITICAL', pattern: /\b(?:fetch|axios\.(?:get|post|put|patch|delete|request)|got|https?\.get)\s*\(\s*req\.(?:query|body|params)\./i },
107
- { 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 },
108
- { name: 'NoSQL Injection', severity: 'HIGH', pattern: /\.(?:find|findOne|findById|updateOne|deleteOne)\s*\(\s*req\.(?:body|query|params)\b|\$where\s*:\s*['"`]/i },
109
- { 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 },
110
- { name: 'Insecure Deserialization',severity: 'CRITICAL', pattern: /\.unserialize\s*\(.*req\.|__proto__\s*[=:][^=]|Object\.setPrototypeOf\s*\([^,]+,\s*req\./i },
111
- // Assignment / pollution
112
- { 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 },
113
- { name: 'Prototype Pollution', severity: 'HIGH', pattern: /(?:_\.merge|lodash\.merge|deepmerge|hoek\.merge)\s*\([^)]*req\.(?:body|query|params)/i },
114
- // Crypto / config
115
- { name: 'Weak Crypto', severity: 'HIGH', pattern: /createHash\s*\(\s*['"](?:md5|sha1)['"]\)|(?:md5|sha1)\s*\(\s*(?:password|passwd|pwd|secret)/i },
116
- { name: 'CORS Wildcard', severity: 'MEDIUM', pattern: /cors\s*\(\s*\{\s*origin\s*:\s*['"]?\*['"]?|'Access-Control-Allow-Origin',\s*['"]?\*/i },
117
- { name: 'Cleartext Traffic', severity: 'MEDIUM', skipInTests: true, pattern: /(?:baseURL|apiUrl|API_URL|endpoint|baseUrl)\s*[:=]\s*['"]http:\/\/(?!localhost|127\.0\.0\.1)/i },
118
- { name: 'XXE', severity: 'HIGH', pattern: /noent\s*:\s*true|expand_entities\s*=\s*True|resolve_entities\s*=\s*True/i },
119
- // Mobile / WebView
120
- { name: 'WebView JS Bridge', severity: 'HIGH', pattern: /addJavascriptInterface\s*\(|javaScriptEnabled\s*:\s*true|allowFileAccess\s*:\s*true|allowUniversalAccessFromFileURLs\s*:\s*true/i },
121
- { name: 'Deep Link Injection', severity: 'MEDIUM', pattern: /Linking\.getInitialURL\s*\(\)|Linking\.addEventListener\s*\(\s*['"]url['"]/i },
122
- ];
123
-
124
- const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart']);
125
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
126
-
127
- function* walkFiles(dir) {
128
- let entries;
129
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
130
- for (const entry of entries) {
131
- if (SKIP_DIRS.has(entry.name)) continue;
132
- const fullPath = path.join(dir, entry.name);
133
- if (entry.isDirectory()) yield* walkFiles(fullPath);
134
- else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) yield fullPath;
135
- }
136
- }
137
-
138
- // Returns true for test/spec files — used to down-weight false-positive-prone patterns
139
- function isTestFile(filePath) {
140
- const rel = path.relative(projectDir, filePath).replace(/\\/g, '/');
141
- return /[._-]test\.[a-z]+$|[._-]spec\.[a-z]+$|_test\.dart$|\/tests?\/|\/spec\/|\/test_/.test(rel);
142
- }
143
-
144
- // Scan app.json / app.config.* for embedded secrets (common Expo vibecoding issue)
145
- function scanAppConfig() {
146
- const findings = [];
147
- const configCandidates = ['app.json', 'app.config.js', 'app.config.ts'];
148
- const secretPattern = /['"]?(?:apiKey|api_key|secret|privateKey|accessToken|clientSecret)['"]?\s*[:=]\s*['"][A-Za-z0-9+/=_\-]{20,}['"]/i;
149
-
150
- for (const name of configCandidates) {
151
- const filePath = path.join(projectDir, name);
152
- if (!fs.existsSync(filePath)) continue;
153
- let lines;
154
- try { lines = fs.readFileSync(filePath, 'utf8').split('\n'); } catch { continue; }
155
- for (let i = 0; i < lines.length; i++) {
156
- if (secretPattern.test(lines[i])) {
157
- findings.push({
158
- severity: 'CRITICAL',
159
- name: 'Config Secret',
160
- file: name,
161
- line: i + 1,
162
- snippet: lines[i].trim().slice(0, 80),
163
- inTestFile: false,
164
- });
165
- }
166
- }
167
- }
168
- return findings;
169
- }
170
-
171
- function scanAndroidManifest() {
172
- const findings = [];
173
- const manifestPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
174
- if (!fs.existsSync(manifestPath)) return findings;
175
- let lines;
176
- try { lines = fs.readFileSync(manifestPath, 'utf8').split('\n'); } catch { return findings; }
177
- for (let i = 0; i < lines.length; i++) {
178
- if (/android:debuggable\s*=\s*["']true["']/i.test(lines[i])) {
179
- findings.push({
180
- severity: 'HIGH',
181
- name: 'Android Debuggable',
182
- file: 'android/app/src/main/AndroidManifest.xml',
183
- line: i + 1,
184
- snippet: lines[i].trim().slice(0, 80),
185
- inTestFile: false,
186
- likelyFalsePositive: false,
187
- });
188
- }
189
- }
190
- return findings;
191
- }
192
-
193
- function quickScan() {
194
- const findings = [];
195
- for (const filePath of walkFiles(projectDir)) {
196
- const inTest = isTestFile(filePath);
197
- let lines;
198
- try { lines = fs.readFileSync(filePath, 'utf8').split('\n'); } catch { continue; }
199
- for (let i = 0; i < lines.length; i++) {
200
- for (const vuln of VULN_PATTERNS) {
201
- if (vuln.pattern.test(lines[i])) {
202
- findings.push({
203
- severity: vuln.severity,
204
- name: vuln.name,
205
- file: path.relative(projectDir, filePath),
206
- line: i + 1,
207
- snippet: lines[i].trim().slice(0, 80),
208
- inTestFile: inTest,
209
- likelyFalsePositive: inTest && !!vuln.skipInTests,
210
- });
211
- break; // one finding per line
212
- }
213
- }
214
- }
215
- }
216
- return [...findings, ...scanAppConfig(), ...scanAndroidManifest()];
217
- }
218
-
219
- function printFindings(findings) {
220
- if (findings.length === 0) {
221
- console.log(' ✅ No obvious vulnerability patterns detected.\n');
222
- return;
223
- }
224
- const real = findings.filter(f => !f.likelyFalsePositive);
225
- const noisy = findings.filter(f => f.likelyFalsePositive);
226
-
227
- const bySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
228
- for (const f of real) (bySeverity[f.severity] || bySeverity.LOW).push(f);
229
- const icons = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🔵' };
230
-
231
- console.log(`\n Found ${real.length} potential issue(s)${noisy.length ? ` (+${noisy.length} in test files — see below)` : ''}:\n`);
232
- for (const [sev, list] of Object.entries(bySeverity)) {
233
- if (!list.length) continue;
234
- for (const f of list) {
235
- const testBadge = f.inTestFile ? ' [test file]' : '';
236
- console.log(` ${icons[sev]} [${sev}] ${f.name} — ${f.file}:${f.line}${testBadge}`);
237
- console.log(` ${f.snippet}`);
238
- }
239
- }
240
-
241
- if (noisy.length) {
242
- console.log('\n ⚪ Likely intentional (in test files — verify manually):');
243
- for (const f of noisy) {
244
- console.log(` ${f.name} — ${f.file}:${f.line}`);
245
- }
246
- }
247
-
248
- console.log('\n Run /tdd-audit in your agent to remediate.\n');
249
- }
250
-
251
- // ─── 4. Scan-only early exit ──────────────────────────────────────────────────
36
+ // ─── Scan-only early exit ─────────────────────────────────────────────────────
252
37
 
253
38
  if (scanOnly) {
254
39
  process.stdout.write('\n🔍 Scanning for vulnerability patterns...');
255
- const findings = quickScan();
40
+ const findings = quickScan(projectDir);
256
41
  process.stdout.write('\n');
257
42
  printFindings(findings);
258
43
  process.exit(0);
259
44
  }
260
45
 
261
- // ─── 5. Install Skill Files ───────────────────────────────────────────────────
46
+ // ─── Install Skill Files ──────────────────────────────────────────────────────
262
47
 
263
48
  const appLabel = appFramework ? `, app: ${appFramework}` : '';
264
49
  console.log(`\nInstalling TDD Remediation Skill (${isLocal ? 'local' : 'global'}, framework: ${framework}${appLabel}, test dir: ${testBaseDir}/)...\n`);
@@ -271,7 +56,7 @@ for (const item of ['SKILL.md', 'prompts', 'templates']) {
271
56
  if (fs.existsSync(src)) fs.cpSync(src, dest, { recursive: true });
272
57
  }
273
58
 
274
- // ─── 5. Scaffold Security Test Boilerplate ────────────────────────────────────
59
+ // ─── Scaffold Security Test Boilerplate ───────────────────────────────────────
275
60
 
276
61
  if (!fs.existsSync(targetTestDir)) {
277
62
  fs.mkdirSync(targetTestDir, { recursive: true });
@@ -296,7 +81,7 @@ if (!fs.existsSync(destTest) && fs.existsSync(srcTest)) {
296
81
  console.log(`✅ Scaffolded ${path.relative(projectDir, destTest)}`);
297
82
  }
298
83
 
299
- // ─── 6. Install Workflow Shortcode ────────────────────────────────────────────
84
+ // ─── Install Workflow Shortcode ───────────────────────────────────────────────
300
85
 
301
86
  if (!fs.existsSync(targetWorkflowDir)) fs.mkdirSync(targetWorkflowDir, { recursive: true });
302
87
  const srcWorkflow = path.join(__dirname, 'workflows', 'tdd-audit.md');
@@ -306,7 +91,7 @@ if (fs.existsSync(srcWorkflow)) {
306
91
  console.log(`✅ Installed /tdd-audit workflow shortcode`);
307
92
  }
308
93
 
309
- // ─── 7. Inject test:security into package.json ────────────────────────────────
94
+ // ─── Inject test:security into package.json ───────────────────────────────────
310
95
 
311
96
  const pkgPath = path.join(projectDir, 'package.json');
312
97
  if (framework !== 'pytest' && framework !== 'go' && fs.existsSync(pkgPath)) {
@@ -316,10 +101,10 @@ if (framework !== 'pytest' && framework !== 'go' && fs.existsSync(pkgPath)) {
316
101
  pkg.scripts = pkg.scripts || {};
317
102
  const secDir = `${testBaseDir}/security`;
318
103
  pkg.scripts['test:security'] = {
319
- jest: `jest --testPathPattern=${secDir} --forceExit`,
104
+ jest: `jest --testPathPatterns=${secDir} --forceExit`,
320
105
  vitest: `vitest run ${secDir}`,
321
106
  mocha: `mocha '${secDir}/**/*.spec.js'`,
322
- }[framework] || `jest --testPathPattern=${secDir} --forceExit`;
107
+ }[framework] || `jest --testPathPatterns=${secDir} --forceExit`;
323
108
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
324
109
  console.log(`✅ Added "test:security" script to package.json`);
325
110
  } else {
@@ -330,31 +115,43 @@ if (framework !== 'pytest' && framework !== 'go' && fs.existsSync(pkgPath)) {
330
115
  }
331
116
  }
332
117
 
333
- // ─── 8. Scaffold CI Workflow ─────────────────────────────────────────────────
118
+ // ─── Scaffold CI Workflows ────────────────────────────────────────────────────
334
119
 
335
120
  const ciWorkflowDir = path.join(projectDir, '.github', 'workflows');
336
- const ciWorkflowPath = path.join(ciWorkflowDir, 'security-tests.yml');
337
-
338
- if (!fs.existsSync(ciWorkflowPath)) {
339
- const ciTemplateMap = {
340
- jest: 'security-tests.node.yml',
341
- vitest: 'security-tests.node.yml',
342
- mocha: 'security-tests.node.yml',
343
- pytest: 'security-tests.python.yml',
344
- go: 'security-tests.go.yml',
345
- flutter: 'security-tests.flutter.yml',
346
- };
347
- const ciTemplatePath = path.join(__dirname, 'templates', 'workflows', ciTemplateMap[framework]);
348
- if (fs.existsSync(ciTemplatePath)) {
349
- fs.mkdirSync(ciWorkflowDir, { recursive: true });
350
- fs.copyFileSync(ciTemplatePath, ciWorkflowPath);
351
- console.log(`✅ Scaffolded .github/workflows/security-tests.yml`);
121
+ fs.mkdirSync(ciWorkflowDir, { recursive: true });
122
+
123
+ const ciWorkflows = [
124
+ {
125
+ destName: 'security-tests.yml',
126
+ templateMap: {
127
+ jest: 'security-tests.node.yml', vitest: 'security-tests.node.yml',
128
+ mocha: 'security-tests.node.yml', pytest: 'security-tests.python.yml',
129
+ go: 'security-tests.go.yml', flutter: 'security-tests.flutter.yml',
130
+ },
131
+ },
132
+ {
133
+ destName: 'ci.yml',
134
+ templateMap: {
135
+ jest: 'ci.node.yml', vitest: 'ci.node.yml', mocha: 'ci.node.yml',
136
+ pytest: 'ci.python.yml', go: 'ci.go.yml', flutter: 'ci.flutter.yml',
137
+ },
138
+ },
139
+ ];
140
+
141
+ for (const { destName, templateMap } of ciWorkflows) {
142
+ const destPath = path.join(ciWorkflowDir, destName);
143
+ if (!fs.existsSync(destPath)) {
144
+ const srcPath = path.join(__dirname, 'templates', 'workflows', templateMap[framework]);
145
+ if (fs.existsSync(srcPath)) {
146
+ fs.copyFileSync(srcPath, destPath);
147
+ console.log(`✅ Scaffolded .github/workflows/${destName}`);
148
+ }
149
+ } else {
150
+ console.log(` .github/workflows/${destName} already exists — skipped`);
352
151
  }
353
- } else {
354
- console.log(` .github/workflows/security-tests.yml already exists — skipped`);
355
152
  }
356
153
 
357
- // ─── 9. Pre-commit Hook (opt-in) ─────────────────────────────────────────────
154
+ // ─── Pre-commit Hook (opt-in) ─────────────────────────────────────────────────
358
155
 
359
156
  if (withHooks) {
360
157
  const gitDir = path.join(projectDir, '.git');
@@ -392,11 +189,11 @@ if (withHooks) {
392
189
  }
393
190
  }
394
191
 
395
- // ─── 10. Quick Scan ──────────────────────────────────────────────────────────
192
+ // ─── Quick Scan ───────────────────────────────────────────────────────────────
396
193
 
397
194
  if (!skipScan) {
398
195
  process.stdout.write('\n🔍 Scanning for vulnerability patterns...');
399
- const findings = quickScan();
196
+ const findings = quickScan(projectDir);
400
197
  process.stdout.write('\n');
401
198
  printFindings(findings);
402
199
  }
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.3.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/",
@@ -16,8 +17,10 @@
16
17
  "LICENSE"
17
18
  ],
18
19
  "scripts": {
19
- "test": "node index.js --local --skip-scan && echo 'Smoke test passed'",
20
- "test:security": "jest --testPathPattern=__tests__/security --forceExit"
20
+ "test": "jest --forceExit",
21
+ "test:unit": "jest --testPathPatterns=__tests__/unit --forceExit --coverage",
22
+ "test:security": "jest --testPathPatterns=__tests__/security --forceExit",
23
+ "test:smoke": "node index.js --local --skip-scan && echo 'Smoke test passed'"
21
24
  },
22
25
  "keywords": [
23
26
  "security",
@@ -44,5 +47,8 @@
44
47
  "node": ">=16.7.0"
45
48
  },
46
49
  "author": "Kyra Lee",
47
- "license": "MIT"
50
+ "license": "MIT",
51
+ "devDependencies": {
52
+ "jest": "^30.3.0"
53
+ }
48
54
  }
@@ -99,8 +99,12 @@ const request = require('supertest');
99
99
  const app = require('../../app');
100
100
 
101
101
  describe('[VulnType] - Red Phase', () => {
102
+ let server;
103
+ beforeAll(() => { server = app.listen(0); });
104
+ afterAll(() => server.close());
105
+
102
106
  it('SHOULD block [exploit description]', async () => {
103
- const res = await request(app)
107
+ const res = await request(server)
104
108
  .post('/api/vulnerable-endpoint')
105
109
  .send({ input: '<exploit payload>' });
106
110
 
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master, develop]
6
+ pull_request:
7
+ branches: [main, master, develop]
8
+
9
+ jobs:
10
+ test:
11
+ name: Test & Analyze
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Flutter
18
+ uses: subosito/flutter-action@v2
19
+ with:
20
+ flutter-version: stable
21
+ cache: true
22
+
23
+ - name: Install dependencies
24
+ run: flutter pub get
25
+
26
+ - name: Analyze (dart analyze)
27
+ run: dart analyze --fatal-infos
28
+
29
+ - name: Format check
30
+ run: dart format --output=none --set-exit-if-changed .
31
+
32
+ - name: Unit tests with coverage
33
+ run: flutter test --coverage
34
+
35
+ - name: Security tests
36
+ run: flutter test test/security/
37
+
38
+ - name: Upload coverage
39
+ uses: actions/upload-artifact@v4
40
+ with:
41
+ name: coverage
42
+ path: coverage/lcov.info
43
+ retention-days: 7
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master, develop]
6
+ pull_request:
7
+ branches: [main, master, develop]
8
+
9
+ jobs:
10
+ test:
11
+ name: Test & Lint
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ matrix:
16
+ go-version: ["1.21", "1.22", "1.23"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Go ${{ matrix.go-version }}
22
+ uses: actions/setup-go@v5
23
+ with:
24
+ go-version: ${{ matrix.go-version }}
25
+ cache: true
26
+
27
+ - name: Lint (staticcheck)
28
+ uses: dominikh/staticcheck-action@v1
29
+ with:
30
+ version: latest
31
+ install-go: false
32
+
33
+ - name: Run tests with coverage
34
+ run: go test ./... -coverprofile=coverage.out -covermode=atomic -v
35
+
36
+ - name: Security tests
37
+ run: go test ./security/... -v
38
+
39
+ - name: Upload coverage
40
+ if: matrix.go-version == '1.22'
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: coverage
44
+ path: coverage.out
45
+ retention-days: 7
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master, develop]
6
+ pull_request:
7
+ branches: [main, master, develop]
8
+
9
+ jobs:
10
+ test:
11
+ name: Test & Lint
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ matrix:
16
+ node-version: [18.x, 20.x, 22.x]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Use Node.js ${{ matrix.node-version }}
22
+ uses: actions/setup-node@v4
23
+ with:
24
+ node-version: ${{ matrix.node-version }}
25
+ cache: npm
26
+
27
+ - name: Install dependencies
28
+ run: npm ci
29
+
30
+ - name: Lint
31
+ run: npm run lint --if-present
32
+
33
+ - name: Unit tests
34
+ run: npm test
35
+
36
+ - name: Security tests
37
+ run: npm run test:security --if-present
38
+
39
+ - name: Upload coverage
40
+ if: matrix.node-version == '20.x'
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: coverage
44
+ path: coverage/
45
+ retention-days: 7
@@ -0,0 +1,48 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master, develop]
6
+ pull_request:
7
+ branches: [main, master, develop]
8
+
9
+ jobs:
10
+ test:
11
+ name: Test & Lint
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ cache: pip
26
+
27
+ - name: Install dependencies
28
+ run: |
29
+ python -m pip install --upgrade pip
30
+ pip install -r requirements.txt
31
+ pip install pytest pytest-cov ruff
32
+
33
+ - name: Lint (ruff)
34
+ run: ruff check .
35
+
36
+ - name: Unit tests with coverage
37
+ run: pytest --cov=. --cov-report=xml -v
38
+
39
+ - name: Security tests
40
+ run: pytest tests/security/ -v
41
+
42
+ - name: Upload coverage
43
+ if: matrix.python-version == '3.11'
44
+ uses: actions/upload-artifact@v4
45
+ with:
46
+ name: coverage
47
+ path: coverage.xml
48
+ retention-days: 7