@lhi/tdd-audit 1.9.0 → 1.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lhi/tdd-audit
2
2
 
3
- > **v1.9.0** — Security skill installer for **Claude Code, Gemini CLI, Cursor, Codex, and OpenCode**. Patches vulnerabilities using a Red-Green-Refactor exploit-test protocol — prove the hole exists, apply the fix, prove it's closed.
3
+ > **v1.10.0** — Security skill installer for **Claude Code, Gemini CLI, Cursor, Codex, and OpenCode**. Patches vulnerabilities using a Red-Green-Refactor exploit-test protocol — prove the hole exists, apply the fix, prove it's closed.
4
4
 
5
5
  ## Install
6
6
 
@@ -25,6 +25,9 @@ On first run the installer:
25
25
  | `--with-hooks` | Add a pre-commit hook that blocks commits on failing security tests |
26
26
  | `--skip-scan` | Skip the vulnerability scan on install |
27
27
  | `--scan` / `--scan-only` | Scan only — no install, no code changes |
28
+ | `--json` | Output findings as JSON |
29
+ | `--format sarif` | Output findings as SARIF 2.1.0 (GitHub code scanning) |
30
+ | `--config <path>` | Load config from an explicit file path |
28
31
 
29
32
  ### Platform
30
33
 
@@ -41,6 +44,39 @@ On first run the installer:
41
44
 
42
45
  The agent detects your stack, presents a CRITICAL → LOW findings report, waits for confirmation, then works through each vulnerability one at a time using Red-Green-Refactor. Pass `--scan` for a report-only run with no code changes.
43
46
 
47
+ ## Config file
48
+
49
+ Scaffold a starter config with a single command:
50
+
51
+ ```bash
52
+ npx @lhi/tdd-audit init
53
+ # or at a custom path:
54
+ npx @lhi/tdd-audit init ~/configs/my-audit.json
55
+ ```
56
+
57
+ `.tdd-audit.json` — all CLI flags settable here, loaded automatically from your project root:
58
+
59
+ ```json
60
+ {
61
+ "provider": "openai",
62
+ "model": "gpt-4o",
63
+ "apiKeyEnv": "OPENAI_API_KEY",
64
+ "baseUrl": null,
65
+ "output": "text",
66
+ "severityThreshold": "LOW",
67
+ "port": 3000,
68
+ "serverApiKey": null,
69
+ "trustProxy": false,
70
+ "ignore": ["node_modules", "dist", "build", "coverage"]
71
+ }
72
+ ```
73
+
74
+ Point to a config anywhere with `--config`:
75
+
76
+ ```bash
77
+ npx @lhi/tdd-audit serve --config ~/configs/prod-audit.json
78
+ ```
79
+
44
80
  ## REST API + AI remediation
45
81
 
46
82
  ```bash
@@ -52,12 +88,15 @@ curl -X POST http://localhost:3000/scan \
52
88
  -H "Authorization: Bearer YOUR_SECRET" \
53
89
  -d '{"path": "."}' | jq '.summary'
54
90
 
55
- # Auto-fix with any AI provider
56
- npx @lhi/tdd-audit --scan --fix critical \
57
- --provider anthropic --api-key $ANTHROPIC_API_KEY --json
91
+ # Use any OpenAI-compatible service (Groq, OpenRouter, Together AI, etc.)
92
+ npx @lhi/tdd-audit serve \
93
+ --provider openai \
94
+ --base-url https://api.groq.com/openai/v1 \
95
+ --api-key $GROQ_API_KEY \
96
+ --model llama-3.3-70b-versatile
58
97
  ```
59
98
 
60
- Supported providers: `anthropic` · `openai` · `gemini` · `ollama` (local)
99
+ Supported providers: `anthropic` · `openai` · `gemini` · `ollama` (local) · **any OpenAI-compatible endpoint via `--base-url`**
61
100
 
62
101
  ## Output formats
63
102
 
@@ -67,26 +106,24 @@ npx @lhi/tdd-audit --scan --format sarif # GitHub code scanning (inline PR anno
67
106
  npx @lhi/tdd-audit --scan # human-readable text (default)
68
107
  ```
69
108
 
70
- ## Config file
109
+ ## Testing
71
110
 
72
- `.tdd-audit.json` in your project root all CLI flags can be set here:
111
+ 323 tests across unit, integration, and security suites:
73
112
 
74
- ```json
75
- {
76
- "port": 3000,
77
- "output": "json",
78
- "provider": "anthropic",
79
- "apiKeyEnv": "ANTHROPIC_API_KEY",
80
- "severityThreshold": "HIGH"
81
- }
113
+ ```bash
114
+ npm test # full suite
115
+ npm run test:unit # unit tests with coverage
116
+ npm run test:security # security regression tests only
82
117
  ```
83
118
 
119
+ Security tests cover prompt injection, path traversal, rate limiting, timing-safe auth, job store bounds, SARIF schema, and more. See [`__tests__/security/`](__tests__/security/) for all 17 regression tests.
120
+
84
121
  ## Documentation
85
122
 
86
123
  | | |
87
124
  |---|---|
88
- | [REST API](docs/rest-api.md) | Endpoints, auth, request/response schema, curl examples |
89
- | [AI Remediation](docs/ai-remediation.md) | Provider setup, CLI flags, Ollama local mode |
125
+ | [REST API](docs/rest-api.md) | Endpoints, auth, rate limiting, trust-proxy, request/response schema |
126
+ | [AI Remediation](docs/ai-remediation.md) | Provider setup, `--base-url` for compatible APIs, config file |
90
127
  | [Scanner](docs/scanner.md) | Architecture, detection logic, false-positive handling |
91
128
  | [Vulnerability Patterns](docs/vulnerability-patterns.md) | All 34 patterns — descriptions, grep signatures, fix pointers |
92
129
  | [TDD Protocol](docs/tdd-protocol.md) | Red-Green-Refactor in full, with framework templates for all 6 stacks |
@@ -4,22 +4,115 @@ Pass a provider and API key to have tdd-audit autonomously generate exploit test
4
4
 
5
5
  ---
6
6
 
7
- ## CLI usage
7
+ ## Config file (recommended)
8
+
9
+ Scaffold once, run anywhere:
10
+
11
+ ```bash
12
+ npx @lhi/tdd-audit init
13
+ ```
14
+
15
+ Edit `.tdd-audit.json`:
16
+
17
+ ```json
18
+ {
19
+ "provider": "openai",
20
+ "model": "gpt-4o",
21
+ "apiKeyEnv": "OPENAI_API_KEY"
22
+ }
23
+ ```
24
+
25
+ `apiKeyEnv` names the environment variable to read the key from — no key ever touches disk. Then just:
26
+
27
+ ```bash
28
+ npx @lhi/tdd-audit serve
29
+ ```
30
+
31
+ Point to a config at any path:
8
32
 
9
33
  ```bash
10
- # Scan and auto-fix all CRITICAL findings via Anthropic
11
- npx @lhi/tdd-audit --scan --fix critical \
34
+ npx @lhi/tdd-audit serve --config ~/configs/my-audit.json
35
+ ```
36
+
37
+ ---
38
+
39
+ ## CLI flags
40
+
41
+ ```bash
42
+ # Anthropic
43
+ npx @lhi/tdd-audit serve \
12
44
  --provider anthropic \
13
45
  --api-key $ANTHROPIC_API_KEY
14
46
 
15
- # Fix everything, use a specific model
16
- npx @lhi/tdd-audit --scan --fix all \
47
+ # OpenAI
48
+ npx @lhi/tdd-audit serve \
17
49
  --provider openai \
18
- --model gpt-4o \
19
50
  --api-key $OPENAI_API_KEY \
20
- --json
51
+ --model gpt-4o-mini
52
+ ```
53
+
54
+ ---
55
+
56
+ ## OpenAI-compatible services
57
+
58
+ Any service that exposes the OpenAI chat completions API works via `--base-url`.
59
+ The API key is sent in the `Authorization: Bearer` header — never in the URL.
60
+
61
+ ```bash
62
+ # Groq (fast inference)
63
+ npx @lhi/tdd-audit serve \
64
+ --provider openai \
65
+ --base-url https://api.groq.com/openai/v1 \
66
+ --model llama-3.3-70b-versatile \
67
+ --api-key $GROQ_API_KEY
68
+
69
+ # OpenRouter (access 200+ models)
70
+ npx @lhi/tdd-audit serve \
71
+ --provider openai \
72
+ --base-url https://openrouter.ai/api/v1 \
73
+ --model meta-llama/llama-3.3-70b-instruct \
74
+ --api-key $OPENROUTER_API_KEY
75
+
76
+ # Together AI
77
+ npx @lhi/tdd-audit serve \
78
+ --provider openai \
79
+ --base-url https://api.together.xyz/v1 \
80
+ --model mistralai/Mixtral-8x7B-Instruct-v0.1 \
81
+ --api-key $TOGETHER_API_KEY
82
+
83
+ # LM Studio / vLLM / llama.cpp (fully local)
84
+ npx @lhi/tdd-audit serve \
85
+ --provider openai \
86
+ --base-url http://localhost:1234/v1 \
87
+ --model local-model
88
+ # no --api-key needed for local servers
89
+ ```
90
+
91
+ In `.tdd-audit.json`:
92
+
93
+ ```json
94
+ {
95
+ "provider": "openai",
96
+ "baseUrl": "https://api.groq.com/openai/v1",
97
+ "model": "llama-3.3-70b-versatile",
98
+ "apiKeyEnv": "GROQ_API_KEY"
99
+ }
21
100
  ```
22
101
 
102
+ ---
103
+
104
+ ## Supported providers
105
+
106
+ | Provider | `--provider` | Default model | Key env var | Notes |
107
+ |---|---|---|---|---|
108
+ | Anthropic | `anthropic` | `claude-opus-4-6` | `ANTHROPIC_API_KEY` | |
109
+ | OpenAI | `openai` | `gpt-4o` | `OPENAI_API_KEY` | Supports `--base-url` |
110
+ | Google Gemini | `gemini` | `gemini-2.0-flash` | `GEMINI_API_KEY` | Key sent via `x-goog-api-key` header |
111
+ | Ollama (local) | `ollama` | `llama3` | — | No key required |
112
+ | Any OpenAI-compat | `openai` | — | varies | Set `--base-url` |
113
+
114
+ ---
115
+
23
116
  ## REST API usage
24
117
 
25
118
  ```bash
@@ -29,11 +122,18 @@ FINDINGS=$(curl -s -X POST http://localhost:3000/scan \
29
122
  -H "Content-Type: application/json" \
30
123
  -d '{"path": "."}' | jq '.findings')
31
124
 
32
- # 2. Submit remediation job
125
+ # 2. Submit remediation job (using Groq via --base-url)
33
126
  JOB=$(curl -s -X POST http://localhost:3000/remediate \
34
127
  -H "Authorization: Bearer $SERVER_KEY" \
35
128
  -H "Content-Type: application/json" \
36
- -d "{\"findings\": $FINDINGS, \"provider\": \"anthropic\", \"apiKey\": \"$ANTHROPIC_API_KEY\", \"severity\": \"HIGH\"}")
129
+ -d "{
130
+ \"findings\": $FINDINGS,
131
+ \"provider\": \"openai\",
132
+ \"apiKey\": \"$GROQ_API_KEY\",
133
+ \"baseUrl\": \"https://api.groq.com/openai/v1\",
134
+ \"model\": \"llama-3.3-70b-versatile\",
135
+ \"severity\": \"HIGH\"
136
+ }")
37
137
 
38
138
  JOB_ID=$(echo $JOB | jq -r '.jobId')
39
139
 
@@ -44,31 +144,6 @@ curl -s "http://localhost:3000/jobs/$JOB_ID" \
44
144
 
45
145
  ---
46
146
 
47
- ## Supported providers
48
-
49
- | Provider | `--provider` | Default model | Key env var |
50
- |---|---|---|---|
51
- | Anthropic | `anthropic` | `claude-opus-4-6` | `ANTHROPIC_API_KEY` |
52
- | OpenAI | `openai` | `gpt-4o` | `OPENAI_API_KEY` |
53
- | Google Gemini | `gemini` | `gemini-2.0-flash` | `GEMINI_API_KEY` |
54
- | Ollama (local) | `ollama` | `llama3` | — |
55
-
56
- ---
57
-
58
- ## Config file
59
-
60
- ```json
61
- {
62
- "provider": "anthropic",
63
- "model": "claude-opus-4-6",
64
- "apiKeyEnv": "ANTHROPIC_API_KEY"
65
- }
66
- ```
67
-
68
- `apiKeyEnv` lets you name the environment variable to read the key from, so no key is ever written to disk.
69
-
70
- ---
71
-
72
147
  ## What the model returns
73
148
 
74
149
  For each finding the remediator sends a structured prompt and expects back:
@@ -94,12 +169,12 @@ The result is returned as-is from the API — review and apply patches manually
94
169
  ## Ollama (fully local / air-gapped)
95
170
 
96
171
  ```bash
97
- # Start Ollama with a code model
172
+ # Pull a code model
98
173
  ollama pull codellama
99
174
  ollama serve
100
175
 
101
176
  # Run tdd-audit against it
102
- npx @lhi/tdd-audit --scan --fix high \
177
+ npx @lhi/tdd-audit serve \
103
178
  --provider ollama \
104
179
  --model codellama
105
180
  ```
package/docs/rest-api.md CHANGED
@@ -7,16 +7,25 @@
7
7
  ## Start the server
8
8
 
9
9
  ```bash
10
+ # Minimal
10
11
  npx @lhi/tdd-audit serve --port 3000 --api-key YOUR_SECRET
12
+
13
+ # With config file (recommended)
14
+ npx @lhi/tdd-audit init # scaffold .tdd-audit.json
15
+ npx @lhi/tdd-audit serve # reads config automatically
16
+
17
+ # Point to a config anywhere
18
+ npx @lhi/tdd-audit serve --config ~/configs/prod.json
11
19
  ```
12
20
 
13
- Or via config file (`.tdd-audit.json` in your project root):
21
+ **`.tdd-audit.json` server options:**
14
22
 
15
23
  ```json
16
24
  {
17
- "port": 3000,
25
+ "port": 3000,
18
26
  "serverApiKey": "YOUR_SECRET",
19
- "output": "json"
27
+ "output": "json",
28
+ "trustProxy": false
20
29
  }
21
30
  ```
22
31
 
@@ -24,7 +33,9 @@ If `--api-key` / `serverApiKey` is omitted the server starts unauthenticated wit
24
33
 
25
34
  ---
26
35
 
27
- ## Authentication
36
+ ## Security
37
+
38
+ ### Authentication
28
39
 
29
40
  All endpoints except `GET /health` require:
30
41
 
@@ -34,13 +45,38 @@ Authorization: Bearer YOUR_SECRET
34
45
 
35
46
  Missing or wrong key → `401 Unauthorized`.
36
47
 
48
+ Tokens are compared using **HMAC + `crypto.timingSafeEqual`** to prevent timing-oracle attacks.
49
+
50
+ ### Rate limiting
51
+
52
+ All endpoints are rate-limited to **60 requests / IP / minute** (default). Exceeding the limit returns `429 Too Many Requests`.
53
+
54
+ By default the rate limiter keys on the **socket IP**, not `X-Forwarded-For`, to prevent header-spoofing bypasses. Enable proxy-forwarded IPs only if you are behind a trusted reverse proxy:
55
+
56
+ ```json
57
+ { "trustProxy": true }
58
+ ```
59
+
60
+ ### Path validation
61
+
62
+ `POST /scan` validates that the requested path is inside the server's working directory (normalised with a trailing separator to prevent sibling-directory prefix bypasses). Paths outside cwd return `400`.
63
+
64
+ ### Security headers
65
+
66
+ Every response includes:
67
+
68
+ ```
69
+ X-Content-Type-Options: nosniff
70
+ X-Frame-Options: DENY
71
+ ```
72
+
37
73
  ---
38
74
 
39
75
  ## Endpoints
40
76
 
41
77
  ### `GET /health`
42
78
 
43
- No auth required.
79
+ No auth required. Returns server status and version.
44
80
 
45
81
  ```json
46
82
  { "status": "ok", "version": "1.9.0" }
@@ -55,38 +91,40 @@ Scan a local path and return structured findings.
55
91
  **Request**
56
92
  ```json
57
93
  {
58
- "path": ".",
94
+ "path": ".",
59
95
  "format": "json"
60
96
  }
61
97
  ```
62
98
 
63
99
  | Field | Type | Default | Description |
64
100
  |---|---|---|---|
65
- | `path` | string | cwd | Absolute or relative path to scan. Must be inside cwd. |
101
+ | `path` | string | cwd | Absolute or relative path to scan. Must be inside server cwd. |
66
102
  | `format` | `"json"` \| `"sarif"` | `"json"` | Output format |
67
103
 
68
- **Response — JSON format**
104
+ **Response — JSON**
69
105
  ```json
70
106
  {
71
- "version": "1.9.0",
72
- "summary": { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 1, "LOW": 0 },
73
- "findings": [ ... ],
107
+ "version": "1.9.0",
108
+ "summary": { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 1, "LOW": 0 },
109
+ "findings": [ ... ],
74
110
  "likelyFalsePositives": [ ... ],
75
- "exempted": [],
76
- "scannedAt": "2026-03-25T12:00:00.000Z",
77
- "duration": 42
111
+ "exempted": [],
112
+ "scannedAt": "2026-03-25T12:00:00.000Z",
113
+ "duration": 42
78
114
  }
79
115
  ```
80
116
 
81
- **Response — SARIF format**
117
+ **Response — SARIF**
82
118
 
83
119
  Returns a SARIF 2.1.0 object ready to upload to GitHub code scanning.
84
120
 
85
121
  **Errors**
122
+
86
123
  | Status | Reason |
87
124
  |---|---|
88
- | 400 | Missing path, path traversal attempt, or invalid JSON body |
125
+ | 400 | Path traversal attempt, sibling-directory bypass, oversized body (> 512 KB), or invalid JSON |
89
126
  | 401 | Missing or invalid API key |
127
+ | 429 | Rate limit exceeded |
90
128
 
91
129
  ---
92
130
 
@@ -94,13 +132,16 @@ Returns a SARIF 2.1.0 object ready to upload to GitHub code scanning.
94
132
 
95
133
  Queue an AI-powered remediation job. Returns immediately with a `jobId`; poll `/jobs/:id` for results.
96
134
 
135
+ The server stores up to **1 000 jobs** in memory (TTL: 1 hour). Oldest jobs are evicted when the cap is reached.
136
+
97
137
  **Request**
98
138
  ```json
99
139
  {
100
140
  "findings": [ ... ],
101
- "provider": "anthropic",
102
- "apiKey": "sk-ant-...",
103
- "model": "claude-opus-4-6",
141
+ "provider": "openai",
142
+ "apiKey": "sk-...",
143
+ "model": "gpt-4o",
144
+ "baseUrl": "https://api.groq.com/openai/v1",
104
145
  "severity": "HIGH"
105
146
  }
106
147
  ```
@@ -111,6 +152,7 @@ Queue an AI-powered remediation job. Returns immediately with a `jobId`; poll `/
111
152
  | `provider` | yes | `anthropic` \| `openai` \| `gemini` \| `ollama` |
112
153
  | `apiKey` | yes | Provider API key |
113
154
  | `model` | no | Defaults per provider (see [AI Remediation](ai-remediation.md)) |
155
+ | `baseUrl` | no | Override base URL for any OpenAI-compatible service |
114
156
  | `severity` | no | Minimum severity to fix. Default: `LOW` (fix all) |
115
157
 
116
158
  **Response**
@@ -124,7 +166,7 @@ Queue an AI-powered remediation job. Returns immediately with a `jobId`; poll `/
124
166
 
125
167
  Poll for remediation job status.
126
168
 
127
- **Response — pending**
169
+ **Response — pending / running**
128
170
  ```json
129
171
  { "id": "job_1_...", "status": "pending", "createdAt": "..." }
130
172
  ```
@@ -132,17 +174,17 @@ Poll for remediation job status.
132
174
  **Response — done**
133
175
  ```json
134
176
  {
135
- "id": "job_1_...",
136
- "status": "done",
137
- "createdAt": "...",
138
- "startedAt": "...",
177
+ "id": "job_1_...",
178
+ "status": "done",
179
+ "createdAt": "...",
180
+ "startedAt": "...",
139
181
  "completedAt": "...",
140
182
  "results": [
141
183
  {
142
- "finding": { ... },
143
- "status": "remediated",
144
- "exploitTest": { "filename": "__tests__/security/xss.test.js", "content": "..." },
145
- "patch": { "filename": "src/app.js", "diff": "..." },
184
+ "finding": { ... },
185
+ "status": "remediated",
186
+ "exploitTest": { "filename": "__tests__/security/xss.test.js", "content": "..." },
187
+ "patch": { "filename": "src/app.js", "diff": "..." },
146
188
  "refactorChecks": ["npm test", "npm run test:security"]
147
189
  }
148
190
  ]
@@ -151,7 +193,9 @@ Poll for remediation job status.
151
193
 
152
194
  ---
153
195
 
154
- ## Example: scan from curl
196
+ ## Examples
197
+
198
+ ### curl
155
199
 
156
200
  ```bash
157
201
  # Start server
@@ -163,23 +207,21 @@ curl -s -X POST http://localhost:3000/scan \
163
207
  -H "Content-Type: application/json" \
164
208
  -d '{"path": "."}' | jq '.summary'
165
209
 
166
- # Get SARIF for GitHub upload
210
+ # SARIF output for GitHub upload
167
211
  curl -s -X POST http://localhost:3000/scan \
168
212
  -H "Authorization: Bearer mysecret" \
169
213
  -H "Content-Type: application/json" \
170
214
  -d '{"path": ".", "format": "sarif"}' > results.sarif
171
215
  ```
172
216
 
173
- ---
174
-
175
- ## Example: scan from Node.js
217
+ ### Node.js
176
218
 
177
219
  ```javascript
178
220
  const res = await fetch('http://localhost:3000/scan', {
179
- method: 'POST',
221
+ method: 'POST',
180
222
  headers: {
181
223
  'Authorization': 'Bearer mysecret',
182
- 'Content-Type': 'application/json',
224
+ 'Content-Type': 'application/json',
183
225
  },
184
226
  body: JSON.stringify({ path: '/path/to/project' }),
185
227
  });
package/index.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  printFindings,
13
13
  } = require('./lib/scanner');
14
14
  const { toJson, toSarif, toText } = require('./lib/reporter');
15
+ const { writeInitConfig } = require('./lib/config');
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
  const isLocal = args.includes('--local');
@@ -44,6 +45,22 @@ const framework = detectFramework(projectDir);
44
45
  const testBaseDir = detectTestBaseDir(projectDir, framework);
45
46
  const targetTestDir = path.join(projectDir, testBaseDir, 'security');
46
47
 
48
+ // ─── Init mode early exit ────────────────────────────────────────────────────
49
+
50
+ if (args[0] === 'init') {
51
+ const destArg = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
52
+ const force = args.includes('--force');
53
+ try {
54
+ const written = writeInitConfig(destArg, force);
55
+ console.log(`✅ Created ${path.relative(process.cwd(), written)}`);
56
+ console.log(' Edit it, then run: node index.js serve or node index.js --scan');
57
+ } catch (e) {
58
+ console.error(`❌ ${e.message}`);
59
+ process.exit(1);
60
+ }
61
+ process.exit(0);
62
+ }
63
+
47
64
  // ─── Serve mode early exit ────────────────────────────────────────────────────
48
65
 
49
66
  if (isServe) {
package/lib/config.js CHANGED
@@ -6,42 +6,63 @@ const path = require('path');
6
6
  const CONFIG_FILE = '.tdd-audit.json';
7
7
 
8
8
  const DEFAULTS = {
9
- port: 3000,
10
- output: 'text', // 'text' | 'json' | 'sarif'
11
- severityThreshold:'LOW', // minimum severity to include in output
12
- ignore: [], // path prefixes to skip
13
- provider: null, // 'anthropic' | 'openai' | 'gemini' | 'ollama'
14
- model: null,
15
- apiKey: null,
16
- apiKeyEnv: null, // env var name to read the key from
17
- serverApiKey: null, // key required on REST API calls
9
+ port: 3000,
10
+ output: 'text', // 'text' | 'json' | 'sarif'
11
+ severityThreshold: 'LOW', // minimum severity to include in output
12
+ ignore: [], // path prefixes to skip
13
+ provider: null, // 'anthropic' | 'openai' | 'gemini' | 'ollama'
14
+ model: null,
15
+ apiKey: null,
16
+ baseUrl: null, // override base URL for OpenAI-compatible providers
17
+ apiKeyEnv: null, // env var name to read the key from
18
+ serverApiKey: null, // key required on REST API calls
19
+ trustProxy: false, // trust X-Forwarded-For for rate limiting
20
+ };
21
+
22
+ // Template written by `tdd-audit init`
23
+ const INIT_TEMPLATE = {
24
+ provider: 'openai',
25
+ model: 'gpt-4o',
26
+ apiKeyEnv: 'OPENAI_API_KEY',
27
+ baseUrl: null,
28
+ output: 'text',
29
+ severityThreshold: 'LOW',
30
+ port: 3000,
31
+ serverApiKey: null,
32
+ ignore: ['node_modules', 'dist', 'build', 'coverage'],
18
33
  };
19
34
 
20
35
  /**
21
- * Load .tdd-audit.json from cwd (or a given dir), merge with DEFAULTS.
22
- * CLI flags (passed as an object) win over file config.
36
+ * Load config from an explicit file path or from .tdd-audit.json in cwd.
37
+ * CLI flags win over file config; file config wins over DEFAULTS.
23
38
  *
24
39
  * @param {string} [cwd=process.cwd()]
25
- * @param {object} [cliOverrides={}]
40
+ * @param {object} [cliOverrides={}] - may include { configPath: '/abs/path/to/file.json' }
26
41
  * @returns {object}
27
42
  */
28
43
  function loadConfig(cwd = process.cwd(), cliOverrides = {}) {
29
44
  let fileConfig = {};
30
- const filePath = path.join(cwd, CONFIG_FILE);
45
+
46
+ // Explicit --config path wins over the cwd convention
47
+ const filePath = cliOverrides.configPath
48
+ ? path.resolve(cliOverrides.configPath)
49
+ : path.join(cwd, CONFIG_FILE);
50
+
31
51
  if (fs.existsSync(filePath)) {
32
52
  try {
33
53
  const raw = fs.readFileSync(filePath, 'utf8');
34
54
  fileConfig = JSON.parse(raw);
35
55
  } catch (err) {
36
- process.stderr.write(`⚠️ Could not parse ${CONFIG_FILE}: ${err.message}\n`);
56
+ process.stderr.write(`⚠️ Could not parse ${filePath}: ${err.message}\n`);
37
57
  }
38
58
  }
39
59
 
40
60
  const merged = { ...DEFAULTS, ...fileConfig };
41
61
 
42
- // CLI overrides only set keys that were explicitly provided
62
+ // Apply CLI overrides (skip internal keys like configPath)
63
+ const INTERNAL = new Set(['configPath']);
43
64
  for (const [key, val] of Object.entries(cliOverrides)) {
44
- if (val !== undefined && val !== null) merged[key] = val;
65
+ if (!INTERNAL.has(key) && val !== undefined && val !== null) merged[key] = val;
45
66
  }
46
67
 
47
68
  // Resolve apiKey from env var if apiKeyEnv is set and apiKey isn't already
@@ -63,14 +84,33 @@ function parseCliOverrides(args) {
63
84
  return i !== -1 ? args[i + 1] : undefined;
64
85
  };
65
86
  const overrides = {};
66
- const port = get('--port'); if (port) overrides.port = Number(port);
67
- const provider = get('--provider'); if (provider) overrides.provider = provider;
68
- const model = get('--model'); if (model) overrides.model = model;
69
- const apiKey = get('--api-key'); if (apiKey) overrides.apiKey = apiKey;
70
- const format = get('--format'); if (format) overrides.output = format;
71
- const srvKey = get('--api-key'); if (srvKey) overrides.serverApiKey = srvKey;
87
+ const configPath = get('--config'); if (configPath) overrides.configPath = configPath;
88
+ const port = get('--port'); if (port) overrides.port = Number(port);
89
+ const provider = get('--provider'); if (provider) overrides.provider = provider;
90
+ const model = get('--model'); if (model) overrides.model = model;
91
+ const apiKey = get('--api-key'); if (apiKey) overrides.apiKey = apiKey;
92
+ const baseUrl = get('--base-url'); if (baseUrl) overrides.baseUrl = baseUrl;
93
+ const format = get('--format'); if (format) overrides.output = format;
94
+ const srvKey = get('--api-key'); if (srvKey) overrides.serverApiKey = srvKey;
72
95
  if (args.includes('--json')) overrides.output = 'json';
73
96
  return overrides;
74
97
  }
75
98
 
76
- module.exports = { loadConfig, parseCliOverrides, DEFAULTS, CONFIG_FILE };
99
+ /**
100
+ * Write a starter .tdd-audit.json to destPath (default: cwd/.tdd-audit.json).
101
+ * Returns the path written, or throws if the file already exists and force is false.
102
+ *
103
+ * @param {string} [destPath]
104
+ * @param {boolean} [force=false]
105
+ * @returns {string}
106
+ */
107
+ function writeInitConfig(destPath, force = false) {
108
+ const target = destPath || path.join(process.cwd(), CONFIG_FILE);
109
+ if (fs.existsSync(target) && !force) {
110
+ throw new Error(`${target} already exists. Pass --force to overwrite.`);
111
+ }
112
+ fs.writeFileSync(target, JSON.stringify(INIT_TEMPLATE, null, 2) + '\n', 'utf8');
113
+ return target;
114
+ }
115
+
116
+ module.exports = { loadConfig, parseCliOverrides, writeInitConfig, DEFAULTS, INIT_TEMPLATE, CONFIG_FILE };
package/lib/remediator.js CHANGED
@@ -18,7 +18,8 @@ const PROVIDERS = {
18
18
  extract: (data) => data?.content?.[0]?.text || '',
19
19
  },
20
20
  openai: {
21
- url: 'https://api.openai.com/v1/chat/completions',
21
+ url: 'https://api.openai.com/v1/chat/completions',
22
+ openaiCompat: true, // supports --base-url override for compatible APIs
22
23
  headers: (apiKey) => ({
23
24
  'Content-Type': 'application/json',
24
25
  'Authorization': `Bearer ${apiKey}`,
@@ -30,8 +31,8 @@ const PROVIDERS = {
30
31
  extract: (data) => data?.choices?.[0]?.message?.content || '',
31
32
  },
32
33
  gemini: {
33
- url: (apiKey) => `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
34
- headers: () => ({ 'Content-Type': 'application/json' }),
34
+ url: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
35
+ headers: (apiKey) => ({ 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }),
35
36
  body: (model, prompt) => ({
36
37
  contents: [{ parts: [{ text: prompt }] }],
37
38
  }),
@@ -51,7 +52,24 @@ const PROVIDERS = {
51
52
 
52
53
  // ─── Prompt builder ───────────────────────────────────────────────────────────
53
54
 
55
+ const MAX_SNIPPET_CHARS = 500;
56
+
57
+ /**
58
+ * Sanitize a raw code snippet before embedding it in an AI prompt.
59
+ * Strips null bytes, limits length, and trims whitespace so that
60
+ * injected newlines cannot introduce new top-level instruction lines.
61
+ */
62
+ function sanitizeSnippet(raw) {
63
+ if (typeof raw !== 'string') return '';
64
+ return raw
65
+ .replace(/\x00/g, '') // strip null bytes
66
+ .replace(/[\r\n]+/g, ' ') // collapse newlines → prevent line injection
67
+ .slice(0, MAX_SNIPPET_CHARS)
68
+ .trim();
69
+ }
70
+
54
71
  function buildRemediationPrompt(finding) {
72
+ const snippet = sanitizeSnippet(finding.snippet);
55
73
  return `You are a security engineer applying the Red-Green-Refactor TDD remediation protocol.
56
74
 
57
75
  VULNERABILITY FINDING:
@@ -59,7 +77,7 @@ VULNERABILITY FINDING:
59
77
  - Severity: ${finding.severity}
60
78
  - File: ${finding.file}
61
79
  - Line: ${finding.line}
62
- - Code snippet: ${finding.snippet}
80
+ - Code snippet: <snippet>${snippet}</snippet>
63
81
 
64
82
  TASK:
65
83
  1. Write a Jest/supertest exploit test (Red phase) that proves this vulnerability exists.
@@ -84,11 +102,25 @@ Respond with valid JSON in exactly this shape:
84
102
 
85
103
  // ─── HTTP call ────────────────────────────────────────────────────────────────
86
104
 
87
- async function callProvider(provider, apiKey, model, prompt) {
105
+ /**
106
+ * @param {string} provider - 'anthropic' | 'openai' | 'gemini' | 'ollama'
107
+ * @param {string} apiKey
108
+ * @param {string} model
109
+ * @param {string} prompt
110
+ * @param {string} [baseUrl] - override base URL for OpenAI-compatible providers
111
+ * e.g. 'https://api.groq.com/openai/v1'
112
+ * 'https://openrouter.ai/api/v1'
113
+ * 'https://api.together.xyz/v1'
114
+ */
115
+ async function callProvider(provider, apiKey, model, prompt, baseUrl) {
88
116
  const p = PROVIDERS[provider];
89
117
  if (!p) throw new Error(`Unknown provider "${provider}". Supported: ${Object.keys(PROVIDERS).join(', ')}`);
90
118
 
91
- const url = typeof p.url === 'function' ? p.url(apiKey) : p.url;
119
+ let url = typeof p.url === 'function' ? p.url(apiKey) : p.url;
120
+ if (baseUrl && p.openaiCompat) {
121
+ // Any OpenAI-compatible service: strip trailing slash and append path
122
+ url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
123
+ }
92
124
  const headers = p.headers(apiKey);
93
125
  const body = JSON.stringify(p.body(model, prompt));
94
126
 
@@ -120,10 +152,11 @@ function parseResponse(text) {
120
152
  * @param {string} opts.provider - 'anthropic' | 'openai' | 'gemini' | 'ollama'
121
153
  * @param {string} opts.apiKey
122
154
  * @param {string} [opts.model]
155
+ * @param {string} [opts.baseUrl] - override base URL for OpenAI-compatible providers
123
156
  * @param {string} [opts.severity] - minimum severity to fix ('CRITICAL','HIGH','MEDIUM','LOW')
124
157
  * @returns {Promise<Array>} - results per finding
125
158
  */
126
- async function remediate({ findings, provider, apiKey, model, severity = 'LOW' }) {
159
+ async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW' }) {
127
160
  const ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
128
161
  const threshold = ORDER[severity.toUpperCase()] ?? 3;
129
162
 
@@ -135,7 +168,7 @@ async function remediate({ findings, provider, apiKey, model, severity = 'LOW' }
135
168
  for (const finding of targets) {
136
169
  try {
137
170
  const prompt = buildRemediationPrompt(finding);
138
- const raw = await callProvider(provider, apiKey, model, prompt);
171
+ const raw = await callProvider(provider, apiKey, model, prompt, baseUrl);
139
172
  const parsed = parseResponse(raw);
140
173
  results.push({ finding, status: 'remediated', ...parsed });
141
174
  } catch (err) {
package/lib/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const http = require('http');
4
- const path = require('path');
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+ const path = require('path');
5
6
  const { quickScan, scanPromptFiles } = require('./scanner');
6
7
  const { toJson, toSarif, toText } = require('./reporter');
7
8
  const { loadConfig, parseCliOverrides } = require('./config');
@@ -9,10 +10,25 @@ const { version } = require('../package.json');
9
10
 
10
11
  // ─── Job store (in-memory) ────────────────────────────────────────────────────
11
12
 
12
- const jobs = new Map();
13
- let jobSeq = 0;
13
+ const jobs = new Map();
14
+ let jobSeq = 0;
15
+
16
+ const MAX_JOBS = 1_000;
17
+ const JOB_TTL_MS = 60 * 60 * 1_000; // 1 hour
18
+
19
+ function evictJobs() {
20
+ const cutoff = Date.now() - JOB_TTL_MS;
21
+ for (const [id, job] of jobs) {
22
+ if (new Date(job.createdAt).getTime() < cutoff) jobs.delete(id);
23
+ }
24
+ // Hard cap: drop oldest entries until within limit
25
+ while (jobs.size >= MAX_JOBS) {
26
+ jobs.delete(jobs.keys().next().value);
27
+ }
28
+ }
14
29
 
15
30
  function createJob() {
31
+ evictJobs();
16
32
  const id = `job_${++jobSeq}_${Date.now()}`;
17
33
  jobs.set(id, { id, status: 'pending', createdAt: new Date().toISOString() });
18
34
  return id;
@@ -23,6 +39,27 @@ function updateJob(id, patch) {
23
39
  if (job) jobs.set(id, { ...job, ...patch });
24
40
  }
25
41
 
42
+ // ─── Rate limiter (in-memory, per-IP sliding window) ─────────────────────────
43
+
44
+ const RATE_LIMIT_MAX = 60; // requests per window
45
+ const RATE_LIMIT_WINDOW = 60 * 1_000; // 1 minute in ms
46
+
47
+ const rateLimiter = {
48
+ _counts: new Map(),
49
+ check(ip) {
50
+ const now = Date.now();
51
+ const entry = this._counts.get(ip) || { count: 0, windowStart: now };
52
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW) {
53
+ entry.count = 0;
54
+ entry.windowStart = now;
55
+ }
56
+ entry.count += 1;
57
+ this._counts.set(ip, entry);
58
+ return entry.count <= RATE_LIMIT_MAX;
59
+ },
60
+ reset() { this._counts.clear(); },
61
+ };
62
+
26
63
  // ─── Helpers ──────────────────────────────────────────────────────────────────
27
64
 
28
65
  function json(res, status, body) {
@@ -51,15 +88,21 @@ function readBody(req) {
51
88
  });
52
89
  }
53
90
 
91
+ // Fixed HMAC key for normalising token lengths before constant-time comparison.
92
+ // Does not need to be secret — purpose is timing-safety, not confidentiality.
93
+ const _authHmacKey = crypto.randomBytes(32);
94
+
54
95
  /**
55
96
  * Authenticate incoming requests.
56
- * If serverApiKey is set, require `Authorization: Bearer <key>`.
97
+ * Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
57
98
  */
58
99
  function authenticate(req, cfg) {
59
100
  if (!cfg.serverApiKey) return true; // no key configured — open
60
101
  const header = req.headers['authorization'] || '';
61
102
  const token = header.startsWith('Bearer ') ? header.slice(7) : '';
62
- return token === cfg.serverApiKey;
103
+ const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
104
+ const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
105
+ return crypto.timingSafeEqual(expected, actual);
63
106
  }
64
107
 
65
108
  /**
@@ -69,7 +112,11 @@ function authenticate(req, cfg) {
69
112
  function safeScanPath(rawPath) {
70
113
  const cwd = process.cwd();
71
114
  const resolved = path.resolve(cwd, rawPath || cwd);
72
- if (!resolved.startsWith(cwd)) throw new Error('Path outside working directory');
115
+ // Append sep so "/app" cannot match "/app-evil" via startsWith
116
+ const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
117
+ if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
118
+ throw new Error('Path outside working directory');
119
+ }
73
120
  return resolved;
74
121
  }
75
122
 
@@ -78,6 +125,16 @@ function safeScanPath(rawPath) {
78
125
  async function handleRequest(req, res, cfg) {
79
126
  const { method, url } = req;
80
127
 
128
+ // ── Rate limiting ──────────────────────────────────────────────────────────
129
+ // Only trust X-Forwarded-For when cfg.trustProxy is explicitly enabled.
130
+ // Default is false to prevent header-spoofing rate-limit bypasses.
131
+ const ip = cfg.trustProxy
132
+ ? (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').split(',')[0].trim()
133
+ : (req.socket?.remoteAddress || 'unknown');
134
+ if (!rateLimiter.check(ip)) {
135
+ return json(res, 429, { error: 'Too Many Requests' });
136
+ }
137
+
81
138
  // ── GET /health ────────────────────────────────────────────────────────────
82
139
  if (method === 'GET' && url === '/health') {
83
140
  return json(res, 200, { status: 'ok', version });
@@ -116,7 +173,7 @@ async function handleRequest(req, res, cfg) {
116
173
  try { body = await readBody(req); }
117
174
  catch (e) { return json(res, 400, { error: e.message }); }
118
175
 
119
- const { findings, provider, apiKey, model } = body;
176
+ const { findings, provider, apiKey, model, baseUrl } = body;
120
177
  if (!findings || !provider || !apiKey) {
121
178
  return json(res, 400, { error: 'findings, provider, and apiKey are required' });
122
179
  }
@@ -128,7 +185,11 @@ async function handleRequest(req, res, cfg) {
128
185
  try {
129
186
  updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
130
187
  const { remediate } = require('./remediator');
131
- const results = await remediate({ findings, provider, apiKey, model: model || cfg.model });
188
+ const results = await remediate({
189
+ findings, provider, apiKey,
190
+ model: model || cfg.model,
191
+ baseUrl: baseUrl || cfg.baseUrl,
192
+ });
132
193
  updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), results });
133
194
  } catch (err) {
134
195
  updateJob(jobId, { status: 'error', error: err.message });
@@ -178,4 +239,9 @@ function start(args = []) {
178
239
  return server; // returned for testing
179
240
  }
180
241
 
181
- module.exports = { start, jobs, createJob, updateJob, safeScanPath };
242
+ module.exports = {
243
+ start, handleRequest, authenticate,
244
+ jobs, createJob, updateJob,
245
+ safeScanPath, MAX_JOBS, JOB_TTL_MS,
246
+ rateLimiter, RATE_LIMIT_MAX,
247
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lhi/tdd-audit",
3
- "version": "1.9.0",
3
+ "version": "1.10.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": {