@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 +54 -17
- package/docs/ai-remediation.md +111 -36
- package/docs/rest-api.md +77 -35
- package/index.js +17 -0
- package/lib/config.js +63 -23
- package/lib/remediator.js +41 -8
- package/lib/server.js +76 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @lhi/tdd-audit
|
|
2
2
|
|
|
3
|
-
> **v1.
|
|
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
|
-
#
|
|
56
|
-
npx @lhi/tdd-audit
|
|
57
|
-
--provider
|
|
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
|
-
##
|
|
109
|
+
## Testing
|
|
71
110
|
|
|
72
|
-
|
|
111
|
+
323 tests across unit, integration, and security suites:
|
|
73
112
|
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
89
|
-
| [AI Remediation](docs/ai-remediation.md) | Provider setup,
|
|
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 |
|
package/docs/ai-remediation.md
CHANGED
|
@@ -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
|
-
##
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
#
|
|
16
|
-
npx @lhi/tdd-audit
|
|
47
|
+
# OpenAI
|
|
48
|
+
npx @lhi/tdd-audit serve \
|
|
17
49
|
--provider openai \
|
|
18
|
-
--model gpt-4o \
|
|
19
50
|
--api-key $OPENAI_API_KEY \
|
|
20
|
-
--
|
|
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 "{
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
21
|
+
**`.tdd-audit.json` server options:**
|
|
14
22
|
|
|
15
23
|
```json
|
|
16
24
|
{
|
|
17
|
-
"port":
|
|
25
|
+
"port": 3000,
|
|
18
26
|
"serverApiKey": "YOUR_SECRET",
|
|
19
|
-
"output":
|
|
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
|
-
##
|
|
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
|
|
104
|
+
**Response — JSON**
|
|
69
105
|
```json
|
|
70
106
|
{
|
|
71
|
-
"version":
|
|
72
|
-
"summary":
|
|
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":
|
|
77
|
-
"duration":
|
|
111
|
+
"exempted": [],
|
|
112
|
+
"scannedAt": "2026-03-25T12:00:00.000Z",
|
|
113
|
+
"duration": 42
|
|
78
114
|
}
|
|
79
115
|
```
|
|
80
116
|
|
|
81
|
-
**Response — SARIF
|
|
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 |
|
|
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": "
|
|
102
|
-
"apiKey":
|
|
103
|
-
"model":
|
|
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":
|
|
136
|
-
"status":
|
|
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":
|
|
144
|
-
"exploitTest":
|
|
145
|
-
"patch":
|
|
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
|
-
##
|
|
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
|
-
#
|
|
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:
|
|
221
|
+
method: 'POST',
|
|
180
222
|
headers: {
|
|
181
223
|
'Authorization': 'Bearer mysecret',
|
|
182
|
-
'Content-Type':
|
|
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:
|
|
10
|
-
output:
|
|
11
|
-
severityThreshold:'LOW',
|
|
12
|
-
ignore:
|
|
13
|
-
provider:
|
|
14
|
-
model:
|
|
15
|
-
apiKey:
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
-
* CLI flags
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
const
|
|
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
|
|
13
|
-
let
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 = {
|
|
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.
|
|
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": {
|