@lhi/tdd-audit 1.9.0 → 1.11.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 +19 -0
- package/lib/config.js +95 -23
- package/lib/remediator.js +79 -14
- package/lib/server.js +81 -14
- package/package.json +2 -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,24 @@ 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
|
+
const providerIdx = args.indexOf('--provider');
|
|
54
|
+
const provider = providerIdx !== -1 ? args[providerIdx + 1] : 'openai';
|
|
55
|
+
try {
|
|
56
|
+
const written = writeInitConfig(destArg, force, provider);
|
|
57
|
+
console.log(`✅ Created ${path.relative(process.cwd(), written)}`);
|
|
58
|
+
console.log(' Edit it, then run: node index.js serve or node index.js --scan');
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`❌ ${e.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
// ─── Serve mode early exit ────────────────────────────────────────────────────
|
|
48
67
|
|
|
49
68
|
if (isServe) {
|
package/lib/config.js
CHANGED
|
@@ -6,42 +6,87 @@ 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
|
+
// Provider-specific defaults for `tdd-audit init --provider <name>`
|
|
23
|
+
const PROVIDER_TEMPLATES = {
|
|
24
|
+
openai: {
|
|
25
|
+
provider: 'openai',
|
|
26
|
+
model: 'gpt-4o',
|
|
27
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
28
|
+
baseUrl: null,
|
|
29
|
+
},
|
|
30
|
+
anthropic: {
|
|
31
|
+
provider: 'anthropic',
|
|
32
|
+
model: 'claude-opus-4-6',
|
|
33
|
+
apiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
34
|
+
baseUrl: null,
|
|
35
|
+
},
|
|
36
|
+
gemini: {
|
|
37
|
+
provider: 'gemini',
|
|
38
|
+
model: 'gemini-2.0-flash',
|
|
39
|
+
apiKeyEnv: 'GEMINI_API_KEY',
|
|
40
|
+
baseUrl: null,
|
|
41
|
+
},
|
|
42
|
+
ollama: {
|
|
43
|
+
provider: 'ollama',
|
|
44
|
+
model: 'llama3',
|
|
45
|
+
apiKeyEnv: null,
|
|
46
|
+
baseUrl: 'http://localhost:11434',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Template written by `tdd-audit init`
|
|
51
|
+
const INIT_TEMPLATE = {
|
|
52
|
+
output: 'text',
|
|
53
|
+
severityThreshold: 'LOW',
|
|
54
|
+
port: 3000,
|
|
55
|
+
serverApiKey: null,
|
|
56
|
+
ignore: ['node_modules', 'dist', 'build', 'coverage'],
|
|
18
57
|
};
|
|
19
58
|
|
|
20
59
|
/**
|
|
21
|
-
* Load
|
|
22
|
-
* CLI flags
|
|
60
|
+
* Load config from an explicit file path or from .tdd-audit.json in cwd.
|
|
61
|
+
* CLI flags win over file config; file config wins over DEFAULTS.
|
|
23
62
|
*
|
|
24
63
|
* @param {string} [cwd=process.cwd()]
|
|
25
|
-
* @param {object} [cliOverrides={}]
|
|
64
|
+
* @param {object} [cliOverrides={}] - may include { configPath: '/abs/path/to/file.json' }
|
|
26
65
|
* @returns {object}
|
|
27
66
|
*/
|
|
28
67
|
function loadConfig(cwd = process.cwd(), cliOverrides = {}) {
|
|
29
68
|
let fileConfig = {};
|
|
30
|
-
|
|
69
|
+
|
|
70
|
+
// Explicit --config path wins over the cwd convention
|
|
71
|
+
const filePath = cliOverrides.configPath
|
|
72
|
+
? path.resolve(cliOverrides.configPath)
|
|
73
|
+
: path.join(cwd, CONFIG_FILE);
|
|
74
|
+
|
|
31
75
|
if (fs.existsSync(filePath)) {
|
|
32
76
|
try {
|
|
33
77
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
34
78
|
fileConfig = JSON.parse(raw);
|
|
35
79
|
} catch (err) {
|
|
36
|
-
process.stderr.write(`⚠️ Could not parse ${
|
|
80
|
+
process.stderr.write(`⚠️ Could not parse ${filePath}: ${err.message}\n`);
|
|
37
81
|
}
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
const merged = { ...DEFAULTS, ...fileConfig };
|
|
41
85
|
|
|
42
|
-
// CLI overrides
|
|
86
|
+
// Apply CLI overrides (skip internal keys like configPath)
|
|
87
|
+
const INTERNAL = new Set(['configPath']);
|
|
43
88
|
for (const [key, val] of Object.entries(cliOverrides)) {
|
|
44
|
-
if (val !== undefined && val !== null) merged[key] = val;
|
|
89
|
+
if (!INTERNAL.has(key) && val !== undefined && val !== null) merged[key] = val;
|
|
45
90
|
}
|
|
46
91
|
|
|
47
92
|
// Resolve apiKey from env var if apiKeyEnv is set and apiKey isn't already
|
|
@@ -63,14 +108,41 @@ function parseCliOverrides(args) {
|
|
|
63
108
|
return i !== -1 ? args[i + 1] : undefined;
|
|
64
109
|
};
|
|
65
110
|
const overrides = {};
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
111
|
+
const configPath = get('--config'); if (configPath) overrides.configPath = configPath;
|
|
112
|
+
const port = get('--port'); if (port) overrides.port = Number(port);
|
|
113
|
+
const provider = get('--provider'); if (provider) overrides.provider = provider;
|
|
114
|
+
const model = get('--model'); if (model) overrides.model = model;
|
|
115
|
+
const apiKey = get('--api-key'); if (apiKey) overrides.apiKey = apiKey;
|
|
116
|
+
const baseUrl = get('--base-url'); if (baseUrl) overrides.baseUrl = baseUrl;
|
|
117
|
+
const format = get('--format'); if (format) overrides.output = format;
|
|
118
|
+
const srvKey = get('--api-key'); if (srvKey) overrides.serverApiKey = srvKey;
|
|
72
119
|
if (args.includes('--json')) overrides.output = 'json';
|
|
73
120
|
return overrides;
|
|
74
121
|
}
|
|
75
122
|
|
|
76
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Write a starter .tdd-audit.json to destPath (default: cwd/.tdd-audit.json).
|
|
125
|
+
* Returns the path written, or throws if the file already exists and force is false.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} [destPath]
|
|
128
|
+
* @param {boolean} [force=false]
|
|
129
|
+
* @param {string} [provider='openai']
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function writeInitConfig(destPath, force = false, provider = 'openai') {
|
|
133
|
+
const providerDefaults = PROVIDER_TEMPLATES[provider];
|
|
134
|
+
if (!providerDefaults) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Unknown provider "${provider}". Valid options: ${Object.keys(PROVIDER_TEMPLATES).join(', ')}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const target = destPath || path.join(process.cwd(), CONFIG_FILE);
|
|
140
|
+
if (fs.existsSync(target) && !force) {
|
|
141
|
+
throw new Error(`${target} already exists. Pass --force to overwrite.`);
|
|
142
|
+
}
|
|
143
|
+
const template = { ...providerDefaults, ...INIT_TEMPLATE };
|
|
144
|
+
fs.writeFileSync(target, JSON.stringify(template, null, 2) + '\n', 'utf8');
|
|
145
|
+
return target;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { loadConfig, parseCliOverrides, writeInitConfig, DEFAULTS, INIT_TEMPLATE, PROVIDER_TEMPLATES, 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,15 +52,49 @@ 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
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize a scalar finding field (name, file, line, severity) before
|
|
73
|
+
* embedding it in an AI prompt. Strips null bytes and collapses newlines
|
|
74
|
+
* so that attacker-controlled metadata cannot inject top-level instructions.
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeField(raw) {
|
|
77
|
+
const s = typeof raw === 'string' ? raw : String(raw ?? '');
|
|
78
|
+
return s
|
|
79
|
+
.replace(/\x00/g, '')
|
|
80
|
+
.replace(/[\r\n]+/g, ' ')
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
54
84
|
function buildRemediationPrompt(finding) {
|
|
85
|
+
const snippet = sanitizeSnippet(finding.snippet);
|
|
86
|
+
const name = sanitizeField(finding.name);
|
|
87
|
+
const severity = sanitizeField(finding.severity);
|
|
88
|
+
const file = sanitizeField(finding.file);
|
|
89
|
+
const line = sanitizeField(finding.line);
|
|
55
90
|
return `You are a security engineer applying the Red-Green-Refactor TDD remediation protocol.
|
|
56
91
|
|
|
57
92
|
VULNERABILITY FINDING:
|
|
58
|
-
- Type: ${
|
|
59
|
-
- Severity: ${
|
|
60
|
-
- File: ${
|
|
61
|
-
- Line: ${
|
|
62
|
-
- Code snippet:
|
|
93
|
+
- Type: ${name}
|
|
94
|
+
- Severity: ${severity}
|
|
95
|
+
- File: ${file}
|
|
96
|
+
- Line: ${line}
|
|
97
|
+
- Code snippet: <snippet>${snippet}</snippet>
|
|
63
98
|
|
|
64
99
|
TASK:
|
|
65
100
|
1. Write a Jest/supertest exploit test (Red phase) that proves this vulnerability exists.
|
|
@@ -84,18 +119,47 @@ Respond with valid JSON in exactly this shape:
|
|
|
84
119
|
|
|
85
120
|
// ─── HTTP call ────────────────────────────────────────────────────────────────
|
|
86
121
|
|
|
87
|
-
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} provider - 'anthropic' | 'openai' | 'gemini' | 'ollama'
|
|
124
|
+
* @param {string} apiKey
|
|
125
|
+
* @param {string} model
|
|
126
|
+
* @param {string} prompt
|
|
127
|
+
* @param {string} [baseUrl] - override base URL for OpenAI-compatible providers
|
|
128
|
+
* e.g. 'https://api.groq.com/openai/v1'
|
|
129
|
+
* 'https://openrouter.ai/api/v1'
|
|
130
|
+
* 'https://api.together.xyz/v1'
|
|
131
|
+
*/
|
|
132
|
+
async function callProvider(provider, apiKey, model, prompt, baseUrl) {
|
|
88
133
|
const p = PROVIDERS[provider];
|
|
89
134
|
if (!p) throw new Error(`Unknown provider "${provider}". Supported: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
90
135
|
|
|
91
|
-
|
|
136
|
+
let url = typeof p.url === 'function' ? p.url(apiKey) : p.url;
|
|
137
|
+
if (baseUrl && p.openaiCompat) {
|
|
138
|
+
// Validate baseUrl to prevent SSRF: must be HTTPS or a localhost origin.
|
|
139
|
+
let parsed;
|
|
140
|
+
try { parsed = new URL(baseUrl); } catch {
|
|
141
|
+
throw new Error(`Invalid baseUrl "${baseUrl}" — must be a valid URL`);
|
|
142
|
+
}
|
|
143
|
+
const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
|
144
|
+
if (parsed.protocol !== 'https:' && !isLocalhost) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`baseUrl must use HTTPS for non-localhost hosts (got "${parsed.protocol}//${parsed.hostname}"). ` +
|
|
147
|
+
'Plain HTTP is only allowed for localhost.'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
// Any OpenAI-compatible service: strip trailing slash and append path
|
|
151
|
+
url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
152
|
+
}
|
|
92
153
|
const headers = p.headers(apiKey);
|
|
93
154
|
const body = JSON.stringify(p.body(model, prompt));
|
|
94
155
|
|
|
95
156
|
const res = await fetch(url, { method: 'POST', headers, body });
|
|
96
157
|
if (!res.ok) {
|
|
97
|
-
const
|
|
98
|
-
|
|
158
|
+
const raw = await res.text().catch(() => '');
|
|
159
|
+
// Redact the apiKey from provider error bodies before surfacing the message —
|
|
160
|
+
// some providers echo the submitted key in 401/403 responses.
|
|
161
|
+
const safe = apiKey ? raw.split(apiKey).join('[REDACTED]') : raw;
|
|
162
|
+
throw new Error(`Provider ${provider} returned ${res.status}: ${safe.slice(0, 200)}`);
|
|
99
163
|
}
|
|
100
164
|
const data = await res.json();
|
|
101
165
|
return p.extract(data);
|
|
@@ -120,10 +184,11 @@ function parseResponse(text) {
|
|
|
120
184
|
* @param {string} opts.provider - 'anthropic' | 'openai' | 'gemini' | 'ollama'
|
|
121
185
|
* @param {string} opts.apiKey
|
|
122
186
|
* @param {string} [opts.model]
|
|
187
|
+
* @param {string} [opts.baseUrl] - override base URL for OpenAI-compatible providers
|
|
123
188
|
* @param {string} [opts.severity] - minimum severity to fix ('CRITICAL','HIGH','MEDIUM','LOW')
|
|
124
189
|
* @returns {Promise<Array>} - results per finding
|
|
125
190
|
*/
|
|
126
|
-
async function remediate({ findings, provider, apiKey, model, severity = 'LOW' }) {
|
|
191
|
+
async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW' }) {
|
|
127
192
|
const ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
128
193
|
const threshold = ORDER[severity.toUpperCase()] ?? 3;
|
|
129
194
|
|
|
@@ -135,7 +200,7 @@ async function remediate({ findings, provider, apiKey, model, severity = 'LOW' }
|
|
|
135
200
|
for (const finding of targets) {
|
|
136
201
|
try {
|
|
137
202
|
const prompt = buildRemediationPrompt(finding);
|
|
138
|
-
const raw = await callProvider(provider, apiKey, model, prompt);
|
|
203
|
+
const raw = await callProvider(provider, apiKey, model, prompt, baseUrl);
|
|
139
204
|
const parsed = parseResponse(raw);
|
|
140
205
|
results.push({ finding, status: 'remediated', ...parsed });
|
|
141
206
|
} 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,15 +39,37 @@ 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) {
|
|
29
66
|
const payload = JSON.stringify(body);
|
|
30
67
|
res.writeHead(status, {
|
|
31
|
-
'Content-Type':
|
|
32
|
-
'Content-Length':
|
|
33
|
-
'X-Content-Type-Options':
|
|
34
|
-
'X-Frame-Options':
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
70
|
+
'X-Content-Type-Options': 'nosniff',
|
|
71
|
+
'X-Frame-Options': 'DENY',
|
|
72
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
35
73
|
});
|
|
36
74
|
res.end(payload);
|
|
37
75
|
}
|
|
@@ -51,15 +89,21 @@ function readBody(req) {
|
|
|
51
89
|
});
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
// Fixed HMAC key for normalising token lengths before constant-time comparison.
|
|
93
|
+
// Does not need to be secret — purpose is timing-safety, not confidentiality.
|
|
94
|
+
const _authHmacKey = crypto.randomBytes(32);
|
|
95
|
+
|
|
54
96
|
/**
|
|
55
97
|
* Authenticate incoming requests.
|
|
56
|
-
*
|
|
98
|
+
* Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
|
|
57
99
|
*/
|
|
58
100
|
function authenticate(req, cfg) {
|
|
59
101
|
if (!cfg.serverApiKey) return true; // no key configured — open
|
|
60
102
|
const header = req.headers['authorization'] || '';
|
|
61
103
|
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
62
|
-
|
|
104
|
+
const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
|
|
105
|
+
const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
|
|
106
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
63
107
|
}
|
|
64
108
|
|
|
65
109
|
/**
|
|
@@ -69,7 +113,11 @@ function authenticate(req, cfg) {
|
|
|
69
113
|
function safeScanPath(rawPath) {
|
|
70
114
|
const cwd = process.cwd();
|
|
71
115
|
const resolved = path.resolve(cwd, rawPath || cwd);
|
|
72
|
-
|
|
116
|
+
// Append sep so "/app" cannot match "/app-evil" via startsWith
|
|
117
|
+
const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
118
|
+
if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
|
|
119
|
+
throw new Error('Path outside working directory');
|
|
120
|
+
}
|
|
73
121
|
return resolved;
|
|
74
122
|
}
|
|
75
123
|
|
|
@@ -78,6 +126,16 @@ function safeScanPath(rawPath) {
|
|
|
78
126
|
async function handleRequest(req, res, cfg) {
|
|
79
127
|
const { method, url } = req;
|
|
80
128
|
|
|
129
|
+
// ── Rate limiting ──────────────────────────────────────────────────────────
|
|
130
|
+
// Only trust X-Forwarded-For when cfg.trustProxy is explicitly enabled.
|
|
131
|
+
// Default is false to prevent header-spoofing rate-limit bypasses.
|
|
132
|
+
const ip = cfg.trustProxy
|
|
133
|
+
? (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').split(',')[0].trim()
|
|
134
|
+
: (req.socket?.remoteAddress || 'unknown');
|
|
135
|
+
if (!rateLimiter.check(ip)) {
|
|
136
|
+
return json(res, 429, { error: 'Too Many Requests' });
|
|
137
|
+
}
|
|
138
|
+
|
|
81
139
|
// ── GET /health ────────────────────────────────────────────────────────────
|
|
82
140
|
if (method === 'GET' && url === '/health') {
|
|
83
141
|
return json(res, 200, { status: 'ok', version });
|
|
@@ -116,7 +174,7 @@ async function handleRequest(req, res, cfg) {
|
|
|
116
174
|
try { body = await readBody(req); }
|
|
117
175
|
catch (e) { return json(res, 400, { error: e.message }); }
|
|
118
176
|
|
|
119
|
-
const { findings, provider, apiKey, model } = body;
|
|
177
|
+
const { findings, provider, apiKey, model, baseUrl } = body;
|
|
120
178
|
if (!findings || !provider || !apiKey) {
|
|
121
179
|
return json(res, 400, { error: 'findings, provider, and apiKey are required' });
|
|
122
180
|
}
|
|
@@ -128,7 +186,11 @@ async function handleRequest(req, res, cfg) {
|
|
|
128
186
|
try {
|
|
129
187
|
updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
|
|
130
188
|
const { remediate } = require('./remediator');
|
|
131
|
-
const results = await remediate({
|
|
189
|
+
const results = await remediate({
|
|
190
|
+
findings, provider, apiKey,
|
|
191
|
+
model: model || cfg.model,
|
|
192
|
+
baseUrl: baseUrl || cfg.baseUrl,
|
|
193
|
+
});
|
|
132
194
|
updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), results });
|
|
133
195
|
} catch (err) {
|
|
134
196
|
updateJob(jobId, { status: 'error', error: err.message });
|
|
@@ -178,4 +240,9 @@ function start(args = []) {
|
|
|
178
240
|
return server; // returned for testing
|
|
179
241
|
}
|
|
180
242
|
|
|
181
|
-
module.exports = {
|
|
243
|
+
module.exports = {
|
|
244
|
+
start, handleRequest, authenticate,
|
|
245
|
+
jobs, createJob, updateJob,
|
|
246
|
+
safeScanPath, MAX_JOBS, JOB_TTL_MS,
|
|
247
|
+
rateLimiter, RATE_LIMIT_MAX,
|
|
248
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhi/tdd-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"test": "jest --forceExit",
|
|
22
22
|
"test:unit": "jest --testPathPatterns=__tests__/unit --forceExit --coverage",
|
|
23
23
|
"test:security": "jest --testPathPatterns=__tests__/security --forceExit",
|
|
24
|
+
"test:e2e": "jest --testPathPatterns=__tests__/e2e --forceExit",
|
|
24
25
|
"test:smoke": "node index.js --local --skip-scan && echo 'Smoke test passed'"
|
|
25
26
|
},
|
|
26
27
|
"keywords": [
|