@lhi/tdd-audit 1.12.0 → 1.14.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 +30 -5
- package/docs/rest-api.md +185 -35
- package/docs/scanner.md +13 -9
- package/index.js +5 -0
- package/lib/badge.js +94 -0
- package/lib/jobs.js +53 -0
- package/lib/plugin.js +308 -0
- package/lib/remediator.js +8 -3
- package/lib/scanner.js +1 -1
- package/lib/server.js +57 -100
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @lhi/tdd-audit
|
|
2
|
+
[](https://www.npmjs.com/package/@lhi/tdd-audit) <!-- tdd-audit-badge -->
|
|
2
3
|
|
|
3
|
-
> **v1.
|
|
4
|
+
> **v1.14.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
5
|
|
|
5
6
|
## Install
|
|
6
7
|
|
|
@@ -80,7 +81,7 @@ npx @lhi/tdd-audit serve --config ~/configs/prod-audit.json
|
|
|
80
81
|
## REST API + AI remediation
|
|
81
82
|
|
|
82
83
|
```bash
|
|
83
|
-
# Start the API server
|
|
84
|
+
# Start the API server (now powered by Fastify)
|
|
84
85
|
npx @lhi/tdd-audit serve --port 3000 --api-key YOUR_SECRET
|
|
85
86
|
|
|
86
87
|
# Scan any path → JSON
|
|
@@ -88,6 +89,19 @@ curl -X POST http://localhost:3000/scan \
|
|
|
88
89
|
-H "Authorization: Bearer YOUR_SECRET" \
|
|
89
90
|
-d '{"path": "."}' | jq '.summary'
|
|
90
91
|
|
|
92
|
+
# Full automated pipeline: scan + remediate in one shot
|
|
93
|
+
curl -X POST http://localhost:3000/audit \
|
|
94
|
+
-H "Authorization: Bearer YOUR_SECRET" \
|
|
95
|
+
-H "Content-Type: application/json" \
|
|
96
|
+
-d '{"path": ".", "provider": "anthropic", "apiKey": "sk-ant-..."}' \
|
|
97
|
+
| jq '.jobId'
|
|
98
|
+
|
|
99
|
+
# Poll job status
|
|
100
|
+
curl http://localhost:3000/jobs/<jobId>
|
|
101
|
+
|
|
102
|
+
# Or stream real-time updates via SSE
|
|
103
|
+
curl -N http://localhost:3000/jobs/<jobId>/stream
|
|
104
|
+
|
|
91
105
|
# Use any OpenAI-compatible service (Groq, OpenRouter, Together AI, etc.)
|
|
92
106
|
npx @lhi/tdd-audit serve \
|
|
93
107
|
--provider openai \
|
|
@@ -98,6 +112,17 @@ npx @lhi/tdd-audit serve \
|
|
|
98
112
|
|
|
99
113
|
Supported providers: `anthropic` · `openai` · `gemini` · `ollama` (local) · **any OpenAI-compatible endpoint via `--base-url`**
|
|
100
114
|
|
|
115
|
+
### Endpoints
|
|
116
|
+
|
|
117
|
+
| Method | Path | Auth | Description |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `GET` | `/health` | No | Version + liveness check |
|
|
120
|
+
| `POST` | `/scan` | Yes | Scan a path, return findings |
|
|
121
|
+
| `POST` | `/remediate` | Yes | AI-fix a findings list; returns `jobId` |
|
|
122
|
+
| `POST` | `/audit` | Yes | Full scan+remediate pipeline; returns `jobId` |
|
|
123
|
+
| `GET` | `/jobs/:id` | Yes | Poll job status |
|
|
124
|
+
| `GET` | `/jobs/:id/stream` | Yes | SSE stream — real-time job progress |
|
|
125
|
+
|
|
101
126
|
## Output formats
|
|
102
127
|
|
|
103
128
|
```bash
|
|
@@ -108,11 +133,11 @@ npx @lhi/tdd-audit --scan # human-readable text (default)
|
|
|
108
133
|
|
|
109
134
|
## Testing
|
|
110
135
|
|
|
111
|
-
|
|
136
|
+
586 tests across unit, E2E, and security suites:
|
|
112
137
|
|
|
113
138
|
```bash
|
|
114
139
|
npm test # full suite
|
|
115
|
-
npm run test:unit # unit tests with coverage (
|
|
140
|
+
npm run test:unit # unit tests with coverage (96.6% branch coverage)
|
|
116
141
|
npm run test:security # security regression tests only
|
|
117
142
|
npm run test:e2e # end-to-end REST API tests
|
|
118
143
|
```
|
|
@@ -126,7 +151,7 @@ Security tests cover prompt injection, path traversal, rate limiting, timing-saf
|
|
|
126
151
|
| [REST API](docs/rest-api.md) | Endpoints, auth, rate limiting, trust-proxy, request/response schema |
|
|
127
152
|
| [AI Remediation](docs/ai-remediation.md) | Provider setup, `--base-url` for compatible APIs, config file |
|
|
128
153
|
| [Scanner](docs/scanner.md) | Architecture, detection logic, false-positive handling |
|
|
129
|
-
| [Vulnerability Patterns](docs/vulnerability-patterns.md) | All
|
|
154
|
+
| [Vulnerability Patterns](docs/vulnerability-patterns.md) | All 57 patterns — descriptions, grep signatures, fix pointers |
|
|
130
155
|
| [TDD Protocol](docs/tdd-protocol.md) | Red-Green-Refactor in full, with framework templates for all 6 stacks |
|
|
131
156
|
| [Agentic AI Security](docs/agentic-ai-security.md) | ASI01–ASI10 — prompt injection, MCP supply chain, Actions injection |
|
|
132
157
|
| [Hardening](docs/hardening.md) | Phase 4 controls — Helmet, CSP, CSRF, rate limiting, gitleaks, SRI |
|
package/docs/rest-api.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# REST API
|
|
2
2
|
|
|
3
|
-
`tdd-audit serve` turns the scanner into an authenticated HTTP API
|
|
3
|
+
`tdd-audit serve` turns the scanner into an authenticated HTTP API built on **Fastify**. Use it to integrate vulnerability scanning and AI remediation into dashboards, CI pipelines, bots, or any tooling that speaks JSON.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ npx @lhi/tdd-audit serve --config ~/configs/prod.json
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
If `--api-key` / `serverApiKey` is omitted the server starts unauthenticated with a warning. Always set one in production.
|
|
32
|
+
If `--api-key` / `serverApiKey` is omitted the server starts unauthenticated with a stderr warning. Always set one in production.
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
|
|
@@ -45,13 +45,13 @@ Authorization: Bearer YOUR_SECRET
|
|
|
45
45
|
|
|
46
46
|
Missing or wrong key → `401 Unauthorized`.
|
|
47
47
|
|
|
48
|
-
Tokens are compared using **HMAC + `crypto.timingSafeEqual`** to prevent timing-oracle attacks.
|
|
48
|
+
Tokens are compared using **HMAC + `crypto.timingSafeEqual`** to prevent timing-oracle attacks (both values are HMAC-normalised before comparison so lengths are always equal).
|
|
49
49
|
|
|
50
50
|
### Rate limiting
|
|
51
51
|
|
|
52
|
-
All endpoints are rate-limited to **60 requests / IP / minute
|
|
52
|
+
All endpoints are rate-limited to **60 requests / IP / minute**. Exceeding the limit returns `429 Too Many Requests`.
|
|
53
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
|
|
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 when you are behind a trusted reverse proxy:
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{ "trustProxy": true }
|
|
@@ -59,15 +59,16 @@ By default the rate limiter keys on the **socket IP**, not `X-Forwarded-For`, to
|
|
|
59
59
|
|
|
60
60
|
### Path validation
|
|
61
61
|
|
|
62
|
-
`POST /scan`
|
|
62
|
+
`POST /scan` and `POST /audit` validate that the requested path is inside the server's working directory. The check is normalised with a trailing path separator to prevent sibling-directory prefix bypasses (e.g. `/app-evil` cannot escape via `/app`). Paths outside cwd return `400`.
|
|
63
63
|
|
|
64
64
|
### Security headers
|
|
65
65
|
|
|
66
66
|
Every response includes:
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
|
|
70
|
-
X-
|
|
69
|
+
Content-Security-Policy: default-src 'none'
|
|
70
|
+
X-Content-Type-Options: nosniff
|
|
71
|
+
X-Frame-Options: DENY
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
---
|
|
@@ -79,14 +80,14 @@ X-Frame-Options: DENY
|
|
|
79
80
|
No auth required. Returns server status and version.
|
|
80
81
|
|
|
81
82
|
```json
|
|
82
|
-
{ "status": "ok", "version": "1.
|
|
83
|
+
{ "status": "ok", "version": "1.13.0" }
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
---
|
|
86
87
|
|
|
87
88
|
### `POST /scan`
|
|
88
89
|
|
|
89
|
-
Scan a local path and return structured findings.
|
|
90
|
+
Scan a local path and return structured findings synchronously.
|
|
90
91
|
|
|
91
92
|
**Request**
|
|
92
93
|
```json
|
|
@@ -104,13 +105,13 @@ Scan a local path and return structured findings.
|
|
|
104
105
|
**Response — JSON**
|
|
105
106
|
```json
|
|
106
107
|
{
|
|
107
|
-
"version":
|
|
108
|
-
"summary":
|
|
109
|
-
"findings":
|
|
108
|
+
"version": "1.13.0",
|
|
109
|
+
"summary": { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 1, "LOW": 0 },
|
|
110
|
+
"findings": [ ... ],
|
|
110
111
|
"likelyFalsePositives": [ ... ],
|
|
111
|
-
"exempted":
|
|
112
|
-
"scannedAt":
|
|
113
|
-
"duration":
|
|
112
|
+
"exempted": [],
|
|
113
|
+
"scannedAt": "2026-03-25T12:00:00.000Z",
|
|
114
|
+
"duration": 42
|
|
114
115
|
}
|
|
115
116
|
```
|
|
116
117
|
|
|
@@ -122,7 +123,7 @@ Returns a SARIF 2.1.0 object ready to upload to GitHub code scanning.
|
|
|
122
123
|
|
|
123
124
|
| Status | Reason |
|
|
124
125
|
|---|---|
|
|
125
|
-
| 400 | Path traversal attempt,
|
|
126
|
+
| 400 | Path traversal attempt, oversized body (> 512 KB), or invalid JSON |
|
|
126
127
|
| 401 | Missing or invalid API key |
|
|
127
128
|
| 429 | Rate limit exceeded |
|
|
128
129
|
|
|
@@ -130,18 +131,18 @@ Returns a SARIF 2.1.0 object ready to upload to GitHub code scanning.
|
|
|
130
131
|
|
|
131
132
|
### `POST /remediate`
|
|
132
133
|
|
|
133
|
-
Queue an AI-powered remediation job
|
|
134
|
+
Queue an AI-powered remediation job for a **provided findings list**. Returns immediately with a `jobId`; poll `GET /jobs/:id` (or stream `GET /jobs/:id/stream`) for results.
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
Use `POST /audit` instead if you want the server to run the scan itself.
|
|
136
137
|
|
|
137
138
|
**Request**
|
|
138
139
|
```json
|
|
139
140
|
{
|
|
140
141
|
"findings": [ ... ],
|
|
141
|
-
"provider": "
|
|
142
|
-
"apiKey": "sk-...",
|
|
143
|
-
"model": "
|
|
144
|
-
"baseUrl":
|
|
142
|
+
"provider": "anthropic",
|
|
143
|
+
"apiKey": "sk-ant-...",
|
|
144
|
+
"model": "claude-opus-4-6",
|
|
145
|
+
"baseUrl": null,
|
|
145
146
|
"severity": "HIGH"
|
|
146
147
|
}
|
|
147
148
|
```
|
|
@@ -155,23 +156,69 @@ The server stores up to **1 000 jobs** in memory (TTL: 1 hour). Oldest jobs are
|
|
|
155
156
|
| `baseUrl` | no | Override base URL for any OpenAI-compatible service |
|
|
156
157
|
| `severity` | no | Minimum severity to fix. Default: `LOW` (fix all) |
|
|
157
158
|
|
|
158
|
-
**Response**
|
|
159
|
+
**Response — 202 Accepted**
|
|
159
160
|
```json
|
|
160
161
|
{ "jobId": "job_1_1711363200000" }
|
|
161
162
|
```
|
|
162
163
|
|
|
164
|
+
Job lifecycle: `pending → running → done | error`
|
|
165
|
+
|
|
163
166
|
---
|
|
164
167
|
|
|
165
|
-
### `
|
|
168
|
+
### `POST /audit`
|
|
169
|
+
|
|
170
|
+
Full automated pipeline: **scan + AI remediation in one shot**. No interaction needed. Returns immediately with a `jobId`.
|
|
166
171
|
|
|
167
|
-
|
|
172
|
+
If no `provider`/`apiKey` are supplied, the server runs the scan only (no remediation) and the job transitions to `done` with just the `findings` array.
|
|
168
173
|
|
|
169
|
-
**
|
|
174
|
+
**Request**
|
|
170
175
|
```json
|
|
171
|
-
{
|
|
176
|
+
{
|
|
177
|
+
"path": ".",
|
|
178
|
+
"provider": "anthropic",
|
|
179
|
+
"apiKey": "sk-ant-...",
|
|
180
|
+
"model": "claude-opus-4-6",
|
|
181
|
+
"baseUrl": null,
|
|
182
|
+
"webhook": "https://your-server.example.com/webhook"
|
|
183
|
+
}
|
|
172
184
|
```
|
|
173
185
|
|
|
174
|
-
|
|
186
|
+
| Field | Required | Description |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `path` | no | Path to scan. Defaults to cwd. Must be inside server cwd. |
|
|
189
|
+
| `provider` | no | If supplied with `apiKey`, AI remediation runs after the scan |
|
|
190
|
+
| `apiKey` | no | Provider API key |
|
|
191
|
+
| `model` | no | Defaults per provider |
|
|
192
|
+
| `baseUrl` | no | Override base URL for OpenAI-compatible providers |
|
|
193
|
+
| `webhook` | no | URL to POST the final job payload to when complete (fire-and-forget) |
|
|
194
|
+
|
|
195
|
+
**Response — 202 Accepted**
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
HTTP/1.1 202 Accepted
|
|
199
|
+
Location: /jobs/job_1_1711363200000
|
|
200
|
+
Retry-After: 2
|
|
201
|
+
```
|
|
202
|
+
```json
|
|
203
|
+
{ "jobId": "job_1_1711363200000" }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Job lifecycle: `pending → scanning → scanned → remediating → done | error`
|
|
207
|
+
|
|
208
|
+
Poll `GET /jobs/:id` or stream `GET /jobs/:id/stream` for progress.
|
|
209
|
+
|
|
210
|
+
**Job object during remediation**
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"id": "job_1_...",
|
|
214
|
+
"status": "remediating",
|
|
215
|
+
"total": 8,
|
|
216
|
+
"completed": 3,
|
|
217
|
+
"current": "SQL Injection"
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Job object when done**
|
|
175
222
|
```json
|
|
176
223
|
{
|
|
177
224
|
"id": "job_1_...",
|
|
@@ -179,6 +226,7 @@ Poll for remediation job status.
|
|
|
179
226
|
"createdAt": "...",
|
|
180
227
|
"startedAt": "...",
|
|
181
228
|
"completedAt": "...",
|
|
229
|
+
"findings": [ ... ],
|
|
182
230
|
"results": [
|
|
183
231
|
{
|
|
184
232
|
"finding": { ... },
|
|
@@ -193,28 +241,130 @@ Poll for remediation job status.
|
|
|
193
241
|
|
|
194
242
|
---
|
|
195
243
|
|
|
196
|
-
|
|
244
|
+
### `GET /jobs/:id`
|
|
245
|
+
|
|
246
|
+
Poll for job status. Works for jobs created by both `POST /remediate` and `POST /audit`.
|
|
247
|
+
|
|
248
|
+
**Response — pending / scanning**
|
|
249
|
+
```json
|
|
250
|
+
{ "id": "job_1_...", "status": "scanning", "createdAt": "..." }
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Response — remediating (with progress)**
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"id": "job_1_...",
|
|
257
|
+
"status": "remediating",
|
|
258
|
+
"total": 8,
|
|
259
|
+
"completed": 3,
|
|
260
|
+
"current": "SQL Injection"
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Response — done**
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"id": "job_1_...",
|
|
268
|
+
"status": "done",
|
|
269
|
+
"createdAt": "...",
|
|
270
|
+
"startedAt": "...",
|
|
271
|
+
"completedAt": "...",
|
|
272
|
+
"results": [ ... ]
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Response — error**
|
|
277
|
+
```json
|
|
278
|
+
{ "id": "job_1_...", "status": "error", "error": "Provider returned 401: ..." }
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The job store keeps up to **1 000 jobs** in memory (TTL: 1 hour). Oldest jobs are evicted when the cap is reached.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### `GET /jobs/:id/stream`
|
|
197
286
|
|
|
198
|
-
|
|
287
|
+
Real-time job progress via **Server-Sent Events (SSE)**. The server pushes an event each time the job state changes, and closes the connection when the job reaches `done` or `error`.
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
curl -N http://localhost:3000/jobs/job_1_.../stream \
|
|
291
|
+
-H "Authorization: Bearer YOUR_SECRET"
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Event format**
|
|
295
|
+
```
|
|
296
|
+
data: {"id":"job_1_...","status":"scanning","createdAt":"..."}
|
|
297
|
+
|
|
298
|
+
data: {"id":"job_1_...","status":"scanned","findings":[...]}
|
|
299
|
+
|
|
300
|
+
data: {"id":"job_1_...","status":"remediating","total":8,"completed":1,"current":"SQL Injection"}
|
|
301
|
+
|
|
302
|
+
data: {"id":"job_1_...","status":"done","completedAt":"...","results":[...]}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The connection is closed automatically after the terminal state (`done` / `error`). If you connect to an already-completed job, the server pushes the current state and closes immediately.
|
|
306
|
+
|
|
307
|
+
**Node.js example using EventSource**
|
|
308
|
+
```javascript
|
|
309
|
+
const es = new EventSource(
|
|
310
|
+
'http://localhost:3000/jobs/job_1_.../stream',
|
|
311
|
+
{ headers: { Authorization: 'Bearer YOUR_SECRET' } }
|
|
312
|
+
);
|
|
313
|
+
es.onmessage = (e) => {
|
|
314
|
+
const job = JSON.parse(e.data);
|
|
315
|
+
if (job.status === 'done') { console.log(job.results); es.close(); }
|
|
316
|
+
if (job.status === 'error') { console.error(job.error); es.close(); }
|
|
317
|
+
};
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Full workflow examples
|
|
323
|
+
|
|
324
|
+
### curl — scan only
|
|
199
325
|
|
|
200
326
|
```bash
|
|
201
|
-
# Start server
|
|
202
327
|
npx @lhi/tdd-audit serve --port 3000 --api-key mysecret &
|
|
203
328
|
|
|
204
|
-
# Scan current directory
|
|
205
329
|
curl -s -X POST http://localhost:3000/scan \
|
|
206
330
|
-H "Authorization: Bearer mysecret" \
|
|
207
331
|
-H "Content-Type: application/json" \
|
|
208
332
|
-d '{"path": "."}' | jq '.summary'
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### curl — full pipeline with polling
|
|
209
336
|
|
|
210
|
-
|
|
337
|
+
```bash
|
|
338
|
+
# Kick off audit
|
|
339
|
+
JOB=$(curl -s -X POST http://localhost:3000/audit \
|
|
340
|
+
-H "Authorization: Bearer mysecret" \
|
|
341
|
+
-H "Content-Type: application/json" \
|
|
342
|
+
-d '{
|
|
343
|
+
"path": ".",
|
|
344
|
+
"provider": "anthropic",
|
|
345
|
+
"apiKey": "sk-ant-..."
|
|
346
|
+
}' | jq -r '.jobId')
|
|
347
|
+
|
|
348
|
+
# Poll until done
|
|
349
|
+
while true; do
|
|
350
|
+
STATUS=$(curl -s http://localhost:3000/jobs/$JOB \
|
|
351
|
+
-H "Authorization: Bearer mysecret" | jq -r '.status')
|
|
352
|
+
echo "Status: $STATUS"
|
|
353
|
+
[ "$STATUS" = "done" ] || [ "$STATUS" = "error" ] && break
|
|
354
|
+
sleep 2
|
|
355
|
+
done
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### curl — SARIF output for GitHub code scanning
|
|
359
|
+
|
|
360
|
+
```bash
|
|
211
361
|
curl -s -X POST http://localhost:3000/scan \
|
|
212
362
|
-H "Authorization: Bearer mysecret" \
|
|
213
363
|
-H "Content-Type: application/json" \
|
|
214
364
|
-d '{"path": ".", "format": "sarif"}' > results.sarif
|
|
215
365
|
```
|
|
216
366
|
|
|
217
|
-
### Node.js
|
|
367
|
+
### Node.js — scan
|
|
218
368
|
|
|
219
369
|
```javascript
|
|
220
370
|
const res = await fetch('http://localhost:3000/scan', {
|
package/docs/scanner.md
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
| Export | Purpose |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `quickScan(projectDir)` | Walk all source files and return a findings array |
|
|
11
|
+
| `quickScan(projectDir)` | Walk all source files and return a merged findings array |
|
|
12
12
|
| `scanPromptFiles(projectDir)` | Walk all `.md` prompt/skill files and check for prompt-specific patterns |
|
|
13
13
|
| `scanAppConfig(projectDir)` | Check `app.json` / `app.config.*` for embedded secrets |
|
|
14
14
|
| `scanAndroidManifest(projectDir)` | Check `AndroidManifest.xml` for `android:debuggable="true"` |
|
|
15
|
+
| `scanPackageJson(projectDir)` | Check `package.json` lifecycle scripts for supply-chain exfiltration (postinstall curl/wget) |
|
|
16
|
+
| `scanEnvFiles(projectDir)` | Check `.env*` files for `NEXT_PUBLIC_*SECRET/KEY/TOKEN` leaking secrets to the browser |
|
|
15
17
|
| `printFindings(findings, exempted)` | Format and print a findings report to stdout |
|
|
16
18
|
| `detectFramework(dir)` | Detect the test framework (`jest`, `vitest`, `mocha`, `pytest`, `go`, `flutter`) |
|
|
17
19
|
| `detectAppFramework(dir)` | Detect the UI framework (`nextjs`, `expo`, `react-native`, `react`, `flutter`) |
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
|
|
24
26
|
```
|
|
25
27
|
projectDir
|
|
26
|
-
└─ walkFiles()
|
|
28
|
+
└─ walkFiles() — yields source files (see Scanned extensions below)
|
|
27
29
|
└─ for each file:
|
|
28
30
|
1. Read file content (read-first, check length after — no TOCTOU)
|
|
29
31
|
2. Skip if content.length > 512 KB
|
|
@@ -32,12 +34,14 @@ projectDir
|
|
|
32
34
|
– If pattern matches, push finding with severity / name / file / line / snippet
|
|
33
35
|
– inTestFile: true if path is under a test directory
|
|
34
36
|
– likelyFalsePositive: true if inTestFile && pattern.skipInTests
|
|
35
|
-
└─ scanAppConfig()
|
|
36
|
-
└─ scanAndroidManifest() — checks android:debuggable
|
|
37
|
-
└─ scanPromptFiles()
|
|
37
|
+
└─ scanAppConfig() — checks app.json / app.config.* for embedded secret patterns
|
|
38
|
+
└─ scanAndroidManifest() — checks android:debuggable="true"
|
|
39
|
+
└─ scanPromptFiles() — walks .md files in agent config directories for prompt-specific patterns
|
|
40
|
+
└─ scanPackageJson() — checks postinstall/preinstall lifecycle scripts for curl/wget exfiltration
|
|
41
|
+
└─ scanEnvFiles() — checks .env* files for NEXT_PUBLIC_* keys with secret-sounding names
|
|
38
42
|
```
|
|
39
43
|
|
|
40
|
-
All
|
|
44
|
+
All six result sets are merged into one array and returned to the caller.
|
|
41
45
|
|
|
42
46
|
---
|
|
43
47
|
|
|
@@ -47,7 +51,7 @@ All four result sets are merged into one array and returned to the caller.
|
|
|
47
51
|
|
|
48
52
|
Yields scannable source files (`SCAN_EXTENSIONS`). Skips:
|
|
49
53
|
|
|
50
|
-
- **`SKIP_DIRS`**: `node_modules`, `.git`, `dist`, `build`, `.next`, `out`, `__pycache__`, `venv`, `.venv`, `vendor`, `.expo`, `.dart_tool`, `.pub-cache`
|
|
54
|
+
- **`SKIP_DIRS`**: `node_modules`, `.git`, `dist`, `build`, `coverage`, `.next`, `out`, `__pycache__`, `venv`, `.venv`, `vendor`, `.expo`, `.dart_tool`, `.pub-cache`
|
|
51
55
|
- **Symlinks** — never followed, preventing escape from the project root on shared/M-series filesystems
|
|
52
56
|
|
|
53
57
|
### `walkMdFiles(dir)`
|
|
@@ -58,9 +62,9 @@ Same skip rules, yields `.md` files only. Used by `scanPromptFiles`.
|
|
|
58
62
|
|
|
59
63
|
## Scanned extensions
|
|
60
64
|
|
|
61
|
-
`.js` `.ts` `.jsx` `.tsx` `.mjs` `.py` `.go` `.dart`
|
|
65
|
+
`.js` `.ts` `.jsx` `.tsx` `.mjs` `.py` `.go` `.dart` `.yml` `.yaml`
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
JSON and XML files are not walked by the code scanner. `package.json` is handled by `scanPackageJson()` and `.env*` files by `scanEnvFiles()` — both run as separate targeted checks. CI workflow files (`.yml`/`.yaml`) **are** now scanned by `walkFiles()` for GitHub Actions expression injection and similar patterns.
|
|
64
68
|
|
|
65
69
|
---
|
|
66
70
|
|
package/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
} = require('./lib/scanner');
|
|
14
14
|
const { toJson, toSarif, toText } = require('./lib/reporter');
|
|
15
15
|
const { writeInitConfig } = require('./lib/config');
|
|
16
|
+
const { badgeLine, injectBadge } = require('./lib/badge');
|
|
16
17
|
|
|
17
18
|
const args = process.argv.slice(2);
|
|
18
19
|
const isLocal = args.includes('--local');
|
|
@@ -87,6 +88,7 @@ if (scanOnly) {
|
|
|
87
88
|
process.stdout.write('\n');
|
|
88
89
|
printFindings(findings, exempted);
|
|
89
90
|
}
|
|
91
|
+
injectBadge(projectDir, badgeLine(findings));
|
|
90
92
|
process.exit(0);
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -243,6 +245,9 @@ if (!skipScan) {
|
|
|
243
245
|
const findings = quickScan(projectDir);
|
|
244
246
|
process.stdout.write('\n');
|
|
245
247
|
printFindings(findings);
|
|
248
|
+
const badge = badgeLine(findings);
|
|
249
|
+
injectBadge(projectDir, badge);
|
|
250
|
+
console.log('✅ README badge updated');
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
console.log(`\nSkill installed to ${path.relative(os.homedir(), targetSkillDir)}`);
|
package/lib/badge.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Marker embedded in the badge line — used to find and replace it on re-scan.
|
|
7
|
+
const BADGE_MARKER = 'tdd-audit-badge';
|
|
8
|
+
|
|
9
|
+
const NPM_URL = 'https://www.npmjs.com/package/@lhi/tdd-audit';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a shields.io badge markdown line reflecting actual scan results.
|
|
13
|
+
*
|
|
14
|
+
* - 0 critical/high (real) findings → "passing" · brightgreen
|
|
15
|
+
* - ≥1 high (no critical) → "{n} high" · orange
|
|
16
|
+
* - ≥1 critical → "{n} critical" · red
|
|
17
|
+
*
|
|
18
|
+
* likelyFalsePositive findings (test fixtures) are excluded from the count.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array} findings - findings array returned by quickScan()
|
|
21
|
+
* @returns {string} - single-line markdown badge ending with \n
|
|
22
|
+
*/
|
|
23
|
+
function badgeLine(findings) {
|
|
24
|
+
// Exclude test-file findings and likely false positives — badge reflects production code only
|
|
25
|
+
const real = (findings || []).filter(f => !f.likelyFalsePositive && !f.inTestFile);
|
|
26
|
+
const criticals = real.filter(f => f.severity === 'CRITICAL').length;
|
|
27
|
+
const highs = real.filter(f => f.severity === 'HIGH').length;
|
|
28
|
+
|
|
29
|
+
let message, color;
|
|
30
|
+
if (criticals > 0) {
|
|
31
|
+
message = `${criticals}%20critical`;
|
|
32
|
+
color = 'red';
|
|
33
|
+
} else if (highs > 0) {
|
|
34
|
+
message = `${highs}%20high`;
|
|
35
|
+
color = 'orange';
|
|
36
|
+
} else {
|
|
37
|
+
message = 'passing';
|
|
38
|
+
color = 'brightgreen';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const badgeUrl = `https://img.shields.io/badge/tdd--audit-${message}-${color}`;
|
|
42
|
+
// Embed the marker as a hidden HTML comment after the badge so injectBadge()
|
|
43
|
+
// can locate and replace the line on subsequent runs.
|
|
44
|
+
return `[](${NPM_URL}) <!-- ${BADGE_MARKER} -->\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inject or update the tdd-audit badge in the project's README.md.
|
|
49
|
+
*
|
|
50
|
+
* Behaviour:
|
|
51
|
+
* - Searches for README.md / readme.md / README in the project root.
|
|
52
|
+
* - If a badge line (identified by BADGE_MARKER) already exists, replaces it.
|
|
53
|
+
* - Otherwise inserts the badge immediately after the first `# Heading` line.
|
|
54
|
+
* If no heading exists, prepends to the file.
|
|
55
|
+
* - No-ops silently when no README is found.
|
|
56
|
+
* - Idempotent: running twice with the same inputs produces the same output.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} projectDir - absolute path to the project root
|
|
59
|
+
* @param {string} badge - badge markdown line from badgeLine()
|
|
60
|
+
*/
|
|
61
|
+
function injectBadge(projectDir, badge) {
|
|
62
|
+
const candidates = ['README.md', 'readme.md', 'Readme.md', 'README'];
|
|
63
|
+
let readmePath = null;
|
|
64
|
+
for (const name of candidates) {
|
|
65
|
+
const p = path.join(projectDir, name);
|
|
66
|
+
if (fs.existsSync(p)) { readmePath = p; break; }
|
|
67
|
+
}
|
|
68
|
+
if (!readmePath) return;
|
|
69
|
+
|
|
70
|
+
const original = fs.readFileSync(readmePath, 'utf8');
|
|
71
|
+
|
|
72
|
+
// Replace existing badge (idempotent + allows re-scan update)
|
|
73
|
+
if (original.includes(BADGE_MARKER)) {
|
|
74
|
+
const updated = original.replace(/^.*tdd-audit-badge.*$/m, badge.trimEnd());
|
|
75
|
+
fs.writeFileSync(readmePath, updated);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Insert after the first h1 line, or prepend if no h1 exists
|
|
80
|
+
const lines = original.split('\n');
|
|
81
|
+
const h1Idx = lines.findIndex(l => /^#\s/.test(l));
|
|
82
|
+
|
|
83
|
+
let updated;
|
|
84
|
+
if (h1Idx !== -1) {
|
|
85
|
+
lines.splice(h1Idx + 1, 0, badge.trimEnd());
|
|
86
|
+
updated = lines.join('\n');
|
|
87
|
+
} else {
|
|
88
|
+
updated = badge.trimEnd() + '\n' + original;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fs.writeFileSync(readmePath, updated);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { badgeLine, injectBadge, BADGE_MARKER };
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
// ─── Job store (singleton, in-memory) ────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const MAX_JOBS = 1_000;
|
|
8
|
+
const JOB_TTL_MS = 60 * 60 * 1_000; // 1 hour
|
|
9
|
+
|
|
10
|
+
const jobs = new Map();
|
|
11
|
+
let jobSeq = 0;
|
|
12
|
+
|
|
13
|
+
// EventEmitter used to push job updates to SSE subscribers
|
|
14
|
+
const _emitter = new EventEmitter();
|
|
15
|
+
_emitter.setMaxListeners(500);
|
|
16
|
+
|
|
17
|
+
function evictJobs() {
|
|
18
|
+
const cutoff = Date.now() - JOB_TTL_MS;
|
|
19
|
+
for (const [id, job] of jobs) {
|
|
20
|
+
if (new Date(job.createdAt).getTime() < cutoff) jobs.delete(id);
|
|
21
|
+
}
|
|
22
|
+
while (jobs.size >= MAX_JOBS) {
|
|
23
|
+
jobs.delete(jobs.keys().next().value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createJob() {
|
|
28
|
+
evictJobs();
|
|
29
|
+
const id = `job_${++jobSeq}_${Date.now()}`;
|
|
30
|
+
jobs.set(id, { id, status: 'pending', createdAt: new Date().toISOString() });
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateJob(id, patch) {
|
|
35
|
+
const job = jobs.get(id);
|
|
36
|
+
if (!job) return;
|
|
37
|
+
const updated = { ...job, ...patch };
|
|
38
|
+
jobs.set(id, updated);
|
|
39
|
+
_emitter.emit(id, updated);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to live updates for a job.
|
|
44
|
+
* @param {string} id - job id
|
|
45
|
+
* @param {Function} fn - called with the updated job object on every change
|
|
46
|
+
* @returns {Function} - call to unsubscribe
|
|
47
|
+
*/
|
|
48
|
+
function subscribe(id, fn) {
|
|
49
|
+
_emitter.on(id, fn);
|
|
50
|
+
return () => _emitter.off(id, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { jobs, createJob, updateJob, subscribe, evictJobs, MAX_JOBS, JOB_TTL_MS };
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const Fastify = require('fastify');
|
|
6
|
+
|
|
7
|
+
const { quickScan } = require('./scanner');
|
|
8
|
+
const { toJson, toSarif } = require('./reporter');
|
|
9
|
+
const { remediate } = require('./remediator');
|
|
10
|
+
const { version } = require('../package.json');
|
|
11
|
+
const {
|
|
12
|
+
jobs, createJob, updateJob, subscribe, MAX_JOBS,
|
|
13
|
+
} = require('./jobs');
|
|
14
|
+
|
|
15
|
+
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
// Fixed HMAC key — normalises token lengths for constant-time comparison.
|
|
18
|
+
const _authHmacKey = crypto.randomBytes(32);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Authenticate a Fastify request.
|
|
22
|
+
* Accepts either a raw Node req or a Fastify request object.
|
|
23
|
+
* Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
|
|
24
|
+
*/
|
|
25
|
+
function authenticate(req, cfg) {
|
|
26
|
+
if (!cfg.serverApiKey) return true;
|
|
27
|
+
const headers = req.headers || {};
|
|
28
|
+
const header = headers['authorization'] || '';
|
|
29
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
30
|
+
const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
|
|
31
|
+
const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
|
|
32
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Rate limiter ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const RATE_LIMIT_MAX = 60;
|
|
38
|
+
const RATE_LIMIT_WINDOW = 60 * 1_000;
|
|
39
|
+
|
|
40
|
+
function createRateLimit() {
|
|
41
|
+
const _counts = new Map();
|
|
42
|
+
return {
|
|
43
|
+
_counts,
|
|
44
|
+
check(ip) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const entry = _counts.get(ip) || { count: 0, windowStart: now };
|
|
47
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW) {
|
|
48
|
+
entry.count = 0;
|
|
49
|
+
entry.windowStart = now;
|
|
50
|
+
}
|
|
51
|
+
entry.count += 1;
|
|
52
|
+
_counts.set(ip, entry);
|
|
53
|
+
return entry.count <= RATE_LIMIT_MAX;
|
|
54
|
+
},
|
|
55
|
+
reset() { _counts.clear(); },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Path validation ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function safeScanPath(rawPath) {
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const resolved = path.resolve(cwd, rawPath || cwd);
|
|
64
|
+
const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
65
|
+
if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
|
|
66
|
+
throw new Error('Path outside working directory');
|
|
67
|
+
}
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Security headers ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const SECURITY_HEADERS = {
|
|
74
|
+
'X-Content-Type-Options': 'nosniff',
|
|
75
|
+
'X-Frame-Options': 'DENY',
|
|
76
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ─── Fastify plugin ───────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fastify plugin that registers all tdd-audit REST routes.
|
|
83
|
+
*
|
|
84
|
+
* Options:
|
|
85
|
+
* cfg - loaded config object
|
|
86
|
+
* rateLimiter - rate limiter instance (from createRateLimit())
|
|
87
|
+
*/
|
|
88
|
+
async function tddAuditPlugin(fastify, opts) {
|
|
89
|
+
const { cfg, rateLimiter } = opts;
|
|
90
|
+
|
|
91
|
+
// ── Security headers on every reply ────────────────────────────────────────
|
|
92
|
+
fastify.addHook('onSend', async (request, reply) => {
|
|
93
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
|
|
94
|
+
reply.header(k, v);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── Rate limiting ────────────────────────────────────────────────────────
|
|
99
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
100
|
+
const ip = cfg.trustProxy
|
|
101
|
+
? (request.headers['x-forwarded-for'] || request.ip || '').split(',')[0].trim()
|
|
102
|
+
: (request.ip || 'unknown');
|
|
103
|
+
if (!rateLimiter.check(ip)) {
|
|
104
|
+
reply.code(429).send({ error: 'Too Many Requests' });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── GET /health ──────────────────────────────────────────────────────────
|
|
109
|
+
fastify.get('/health', async () => ({ status: 'ok', version }));
|
|
110
|
+
|
|
111
|
+
// ── Authentication for all non-health routes ────────────────────────────
|
|
112
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
113
|
+
if (request.routeOptions?.url === '/health') return;
|
|
114
|
+
if (!authenticate(request, cfg)) {
|
|
115
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── POST /scan ──────────────────────────────────────────────────────────
|
|
120
|
+
fastify.post('/scan', {
|
|
121
|
+
config: { rawBody: false },
|
|
122
|
+
}, async (request, reply) => {
|
|
123
|
+
const body = request.body || {};
|
|
124
|
+
|
|
125
|
+
let scanPath;
|
|
126
|
+
try { scanPath = safeScanPath(body.path); }
|
|
127
|
+
catch (e) { return reply.code(400).send({ error: e.message }); }
|
|
128
|
+
|
|
129
|
+
const format = body.format || cfg.output || 'json';
|
|
130
|
+
const t0 = Date.now();
|
|
131
|
+
const findings = quickScan(scanPath);
|
|
132
|
+
const exempted = findings.exempted || [];
|
|
133
|
+
const duration = Date.now() - t0;
|
|
134
|
+
|
|
135
|
+
if (format === 'sarif') return toSarif(findings, scanPath);
|
|
136
|
+
return { ...toJson(findings, exempted), duration };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── POST /remediate ──────────────────────────────────────────────────────
|
|
140
|
+
fastify.post('/remediate', async (request, reply) => {
|
|
141
|
+
const body = request.body || {};
|
|
142
|
+
const { findings, provider, apiKey, model, baseUrl } = body;
|
|
143
|
+
|
|
144
|
+
if (!findings || !provider || !apiKey) {
|
|
145
|
+
return reply.code(400).send({ error: 'findings, provider, and apiKey are required' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const jobId = createJob();
|
|
149
|
+
|
|
150
|
+
setImmediate(async () => {
|
|
151
|
+
try {
|
|
152
|
+
updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
|
|
153
|
+
const results = await remediate({
|
|
154
|
+
findings, provider, apiKey,
|
|
155
|
+
model: model || cfg.model,
|
|
156
|
+
baseUrl: baseUrl || cfg.baseUrl,
|
|
157
|
+
});
|
|
158
|
+
updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), results });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
updateJob(jobId, { status: 'error', error: err.message });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return reply.code(202).send({ jobId });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── POST /audit — full scan+remediate pipeline ────────────────────────────
|
|
168
|
+
fastify.post('/audit', async (request, reply) => {
|
|
169
|
+
const body = request.body || {};
|
|
170
|
+
const { path: rawPath, provider, apiKey, model, baseUrl, webhook } = body;
|
|
171
|
+
|
|
172
|
+
let scanPath;
|
|
173
|
+
try { scanPath = safeScanPath(rawPath); }
|
|
174
|
+
catch (e) { return reply.code(400).send({ error: e.message }); }
|
|
175
|
+
|
|
176
|
+
const jobId = createJob();
|
|
177
|
+
|
|
178
|
+
setImmediate(async () => {
|
|
179
|
+
try {
|
|
180
|
+
// Phase 1: scan
|
|
181
|
+
updateJob(jobId, { status: 'scanning', startedAt: new Date().toISOString() });
|
|
182
|
+
const findings = quickScan(scanPath);
|
|
183
|
+
updateJob(jobId, { status: 'scanned', findings });
|
|
184
|
+
|
|
185
|
+
// Phase 2: remediate (if provider supplied)
|
|
186
|
+
if (provider && apiKey) {
|
|
187
|
+
const total = findings.filter(f => !f.likelyFalsePositive).length;
|
|
188
|
+
updateJob(jobId, { status: 'remediating', total, completed: 0 });
|
|
189
|
+
|
|
190
|
+
// remediate is eagerly required at module top
|
|
191
|
+
const results = await remediate({
|
|
192
|
+
findings, provider, apiKey,
|
|
193
|
+
model: model || cfg.model,
|
|
194
|
+
baseUrl: baseUrl || cfg.baseUrl,
|
|
195
|
+
onProgress: (completed, current) => {
|
|
196
|
+
updateJob(jobId, { status: 'remediating', total, completed, current });
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
updateJob(jobId, {
|
|
200
|
+
status: 'done',
|
|
201
|
+
completedAt: new Date().toISOString(),
|
|
202
|
+
findings,
|
|
203
|
+
results,
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
updateJob(jobId, { status: 'done', completedAt: new Date().toISOString(), findings });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Optional webhook fire-and-forget
|
|
210
|
+
if (webhook) {
|
|
211
|
+
const job = jobs.get(jobId);
|
|
212
|
+
fetch(webhook, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json' },
|
|
215
|
+
body: JSON.stringify(job),
|
|
216
|
+
}).catch(() => {}); // never throw
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
updateJob(jobId, { status: 'error', error: err.message });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
reply.header('Location', `/jobs/${jobId}`);
|
|
224
|
+
reply.header('Retry-After', '2');
|
|
225
|
+
return reply.code(202).send({ jobId });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── GET /jobs/:id ────────────────────────────────────────────────────────
|
|
229
|
+
fastify.get('/jobs/:id', async (request, reply) => {
|
|
230
|
+
const job = jobs.get(request.params.id);
|
|
231
|
+
if (!job) return reply.code(404).send({ error: 'Job not found' });
|
|
232
|
+
return job;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── GET /jobs/:id/stream — SSE real-time job updates ─────────────────────
|
|
236
|
+
fastify.get('/jobs/:id/stream', async (request, reply) => {
|
|
237
|
+
const id = request.params.id;
|
|
238
|
+
const job = jobs.get(id);
|
|
239
|
+
if (!job) return reply.code(404).send({ error: 'Job not found' });
|
|
240
|
+
|
|
241
|
+
reply.hijack();
|
|
242
|
+
const raw = reply.raw;
|
|
243
|
+
raw.writeHead(200, {
|
|
244
|
+
'Content-Type': 'text/event-stream',
|
|
245
|
+
'Cache-Control': 'no-cache',
|
|
246
|
+
'Connection': 'keep-alive',
|
|
247
|
+
...SECURITY_HEADERS,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const send = (data) => {
|
|
251
|
+
raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Push current state immediately
|
|
255
|
+
send(jobs.get(id));
|
|
256
|
+
|
|
257
|
+
if (job.status === 'done' || job.status === 'error') {
|
|
258
|
+
raw.end();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const unsubscribe = subscribe(id, (updated) => {
|
|
263
|
+
send(updated);
|
|
264
|
+
if (updated.status === 'done' || updated.status === 'error') {
|
|
265
|
+
unsubscribe();
|
|
266
|
+
raw.end();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
raw.on('close', unsubscribe);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── App factory ──────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build and return a configured Fastify instance.
|
|
278
|
+
* @param {object} cfg - loaded config object
|
|
279
|
+
* @param {object} [overrides] - optional overrides (e.g. { logger: true })
|
|
280
|
+
*/
|
|
281
|
+
function buildApp(cfg, overrides = {}) {
|
|
282
|
+
const fastify = Fastify({
|
|
283
|
+
logger: false,
|
|
284
|
+
trustProxy: cfg.trustProxy || false,
|
|
285
|
+
bodyLimit: 512 * 1024, // 512 KB
|
|
286
|
+
...overrides,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const rateLimiter = createRateLimit();
|
|
290
|
+
|
|
291
|
+
fastify.register(tddAuditPlugin, { cfg, rateLimiter });
|
|
292
|
+
|
|
293
|
+
// Expose internals for testing
|
|
294
|
+
fastify.decorate('rateLimiter', rateLimiter);
|
|
295
|
+
fastify.decorate('jobs', jobs);
|
|
296
|
+
fastify.decorate('cfg', cfg);
|
|
297
|
+
|
|
298
|
+
return fastify;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
tddAuditPlugin,
|
|
303
|
+
buildApp,
|
|
304
|
+
authenticate,
|
|
305
|
+
safeScanPath,
|
|
306
|
+
createRateLimit,
|
|
307
|
+
RATE_LIMIT_MAX,
|
|
308
|
+
};
|
package/lib/remediator.js
CHANGED
|
@@ -185,10 +185,11 @@ function parseResponse(text) {
|
|
|
185
185
|
* @param {string} opts.apiKey
|
|
186
186
|
* @param {string} [opts.model]
|
|
187
187
|
* @param {string} [opts.baseUrl] - override base URL for OpenAI-compatible providers
|
|
188
|
-
* @param {string}
|
|
188
|
+
* @param {string} [opts.severity] - minimum severity to fix ('CRITICAL','HIGH','MEDIUM','LOW')
|
|
189
|
+
* @param {Function} [opts.onProgress] - called with (completedCount, currentFindingName) after each finding
|
|
189
190
|
* @returns {Promise<Array>} - results per finding
|
|
190
191
|
*/
|
|
191
|
-
async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW' }) {
|
|
192
|
+
async function remediate({ findings, provider, apiKey, model, baseUrl, severity = 'LOW', onProgress }) {
|
|
192
193
|
const ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
193
194
|
const threshold = ORDER[severity.toUpperCase()] ?? 3;
|
|
194
195
|
|
|
@@ -197,7 +198,8 @@ async function remediate({ findings, provider, apiKey, model, baseUrl, severity
|
|
|
197
198
|
.sort((a, b) => (ORDER[a.severity] ?? 99) - (ORDER[b.severity] ?? 99));
|
|
198
199
|
|
|
199
200
|
const results = [];
|
|
200
|
-
for (
|
|
201
|
+
for (let i = 0; i < targets.length; i++) {
|
|
202
|
+
const finding = targets[i];
|
|
201
203
|
try {
|
|
202
204
|
const prompt = buildRemediationPrompt(finding);
|
|
203
205
|
const raw = await callProvider(provider, apiKey, model, prompt, baseUrl);
|
|
@@ -206,6 +208,9 @@ async function remediate({ findings, provider, apiKey, model, baseUrl, severity
|
|
|
206
208
|
} catch (err) {
|
|
207
209
|
results.push({ finding, status: 'error', error: err.message });
|
|
208
210
|
}
|
|
211
|
+
if (typeof onProgress === 'function') {
|
|
212
|
+
onProgress(i + 1, finding.name);
|
|
213
|
+
}
|
|
209
214
|
}
|
|
210
215
|
return results;
|
|
211
216
|
}
|
package/lib/scanner.js
CHANGED
|
@@ -70,7 +70,7 @@ const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.
|
|
|
70
70
|
|
|
71
71
|
/** Maximum file size to read before skipping (512 KB). Prevents OOM on large generated files. */
|
|
72
72
|
const MAX_SCAN_FILE_BYTES = 512 * 1024;
|
|
73
|
-
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
|
|
73
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
|
|
74
74
|
|
|
75
75
|
// ─── Prompt / Skill Patterns ──────────────────────────────────────────────────
|
|
76
76
|
|
package/lib/server.js
CHANGED
|
@@ -1,48 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
-
const http = require('http');
|
|
5
4
|
const path = require('path');
|
|
6
|
-
const { quickScan
|
|
7
|
-
const { toJson, toSarif
|
|
5
|
+
const { quickScan } = require('./scanner');
|
|
6
|
+
const { toJson, toSarif } = require('./reporter');
|
|
8
7
|
const { loadConfig, parseCliOverrides } = require('./config');
|
|
9
|
-
const { version }
|
|
8
|
+
const { version } = require('../package.json');
|
|
9
|
+
const { buildApp, RATE_LIMIT_MAX } = require('./plugin');
|
|
10
|
+
const {
|
|
11
|
+
jobs, createJob, updateJob, MAX_JOBS, JOB_TTL_MS,
|
|
12
|
+
} = require('./jobs');
|
|
10
13
|
|
|
11
|
-
// ───
|
|
14
|
+
// ─── Auth (kept here for backward compat — SEC-17 reads this file) ────────────
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
}
|
|
16
|
+
// Fixed HMAC key for normalising token lengths before constant-time comparison.
|
|
17
|
+
const _authHmacKey = crypto.randomBytes(32);
|
|
29
18
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Authenticate incoming requests.
|
|
21
|
+
* Accepts Node.js http.IncomingMessage OR Fastify Request objects.
|
|
22
|
+
* Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
|
|
23
|
+
*/
|
|
24
|
+
function authenticate(req, cfg) {
|
|
25
|
+
if (!cfg.serverApiKey) return true;
|
|
26
|
+
const headers = req.headers || {};
|
|
27
|
+
const header = headers['authorization'] || '';
|
|
28
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
29
|
+
const expected = crypto.createHmac('sha256', _authHmacKey).update(cfg.serverApiKey).digest();
|
|
30
|
+
const actual = crypto.createHmac('sha256', _authHmacKey).update(token).digest();
|
|
31
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Validate and sanitise the `path` field from POST /scan.
|
|
36
|
+
* Only allow paths inside cwd to prevent path traversal.
|
|
37
|
+
*/
|
|
38
|
+
function safeScanPath(rawPath) {
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const resolved = path.resolve(cwd, rawPath || cwd);
|
|
41
|
+
const cwdNorm = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
42
|
+
if (resolved !== cwd && !resolved.startsWith(cwdNorm)) {
|
|
43
|
+
throw new Error('Path outside working directory');
|
|
44
|
+
}
|
|
45
|
+
return resolved;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
// ─── Rate limiter (
|
|
48
|
+
// ─── Rate limiter (kept here for backward compat — SEC-14/16/20 read this) ───
|
|
43
49
|
|
|
44
|
-
const
|
|
45
|
-
const RATE_LIMIT_WINDOW = 60 * 1_000; // 1 minute in ms
|
|
50
|
+
const RATE_LIMIT_WINDOW = 60 * 1_000;
|
|
46
51
|
|
|
47
52
|
const rateLimiter = {
|
|
48
53
|
_counts: new Map(),
|
|
@@ -89,46 +94,11 @@ function readBody(req) {
|
|
|
89
94
|
});
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
//
|
|
93
|
-
// Does not need to be secret — purpose is timing-safety, not confidentiality.
|
|
94
|
-
const _authHmacKey = crypto.randomBytes(32);
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Authenticate incoming requests.
|
|
98
|
-
* Uses HMAC + timingSafeEqual to prevent timing-oracle attacks.
|
|
99
|
-
*/
|
|
100
|
-
function authenticate(req, cfg) {
|
|
101
|
-
if (!cfg.serverApiKey) return true; // no key configured — open
|
|
102
|
-
const header = req.headers['authorization'] || '';
|
|
103
|
-
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
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);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Validate and sanitise the `path` field from POST /scan.
|
|
111
|
-
* Only allow paths inside cwd to prevent path traversal.
|
|
112
|
-
*/
|
|
113
|
-
function safeScanPath(rawPath) {
|
|
114
|
-
const cwd = process.cwd();
|
|
115
|
-
const resolved = path.resolve(cwd, rawPath || cwd);
|
|
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
|
-
}
|
|
121
|
-
return resolved;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ─── Router ───────────────────────────────────────────────────────────────────
|
|
97
|
+
// ─── Router (kept for backward compat — SEC-16/20 and E2E tests use this) ────
|
|
125
98
|
|
|
126
99
|
async function handleRequest(req, res, cfg) {
|
|
127
100
|
const { method, url } = req;
|
|
128
101
|
|
|
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
102
|
const ip = cfg.trustProxy
|
|
133
103
|
? (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').split(',')[0].trim()
|
|
134
104
|
: (req.socket?.remoteAddress || 'unknown');
|
|
@@ -136,17 +106,14 @@ async function handleRequest(req, res, cfg) {
|
|
|
136
106
|
return json(res, 429, { error: 'Too Many Requests' });
|
|
137
107
|
}
|
|
138
108
|
|
|
139
|
-
// ── GET /health ────────────────────────────────────────────────────────────
|
|
140
109
|
if (method === 'GET' && url === '/health') {
|
|
141
110
|
return json(res, 200, { status: 'ok', version });
|
|
142
111
|
}
|
|
143
112
|
|
|
144
|
-
// All other routes require authentication
|
|
145
113
|
if (!authenticate(req, cfg)) {
|
|
146
114
|
return json(res, 401, { error: 'Unauthorized' });
|
|
147
115
|
}
|
|
148
116
|
|
|
149
|
-
// ── POST /scan ─────────────────────────────────────────────────────────────
|
|
150
117
|
if (method === 'POST' && url === '/scan') {
|
|
151
118
|
let body;
|
|
152
119
|
try { body = await readBody(req); }
|
|
@@ -156,19 +123,16 @@ async function handleRequest(req, res, cfg) {
|
|
|
156
123
|
try { scanPath = safeScanPath(body.path); }
|
|
157
124
|
catch (e) { return json(res, 400, { error: e.message }); }
|
|
158
125
|
|
|
159
|
-
const format
|
|
160
|
-
const t0
|
|
126
|
+
const format = body.format || cfg.output || 'json';
|
|
127
|
+
const t0 = Date.now();
|
|
161
128
|
const findings = quickScan(scanPath);
|
|
162
129
|
const exempted = findings.exempted || [];
|
|
163
130
|
const duration = Date.now() - t0;
|
|
164
131
|
|
|
165
|
-
if (format === 'sarif')
|
|
166
|
-
return json(res, 200, toSarif(findings, scanPath));
|
|
167
|
-
}
|
|
132
|
+
if (format === 'sarif') return json(res, 200, toSarif(findings, scanPath));
|
|
168
133
|
return json(res, 200, { ...toJson(findings, exempted), duration });
|
|
169
134
|
}
|
|
170
135
|
|
|
171
|
-
// ── POST /remediate ────────────────────────────────────────────────────────
|
|
172
136
|
if (method === 'POST' && url === '/remediate') {
|
|
173
137
|
let body;
|
|
174
138
|
try { body = await readBody(req); }
|
|
@@ -181,7 +145,6 @@ async function handleRequest(req, res, cfg) {
|
|
|
181
145
|
|
|
182
146
|
const jobId = createJob();
|
|
183
147
|
|
|
184
|
-
// Kick off async remediation (non-blocking)
|
|
185
148
|
setImmediate(async () => {
|
|
186
149
|
try {
|
|
187
150
|
updateJob(jobId, { status: 'running', startedAt: new Date().toISOString() });
|
|
@@ -200,7 +163,6 @@ async function handleRequest(req, res, cfg) {
|
|
|
200
163
|
return json(res, 202, { jobId });
|
|
201
164
|
}
|
|
202
165
|
|
|
203
|
-
// ── GET /jobs/:id ──────────────────────────────────────────────────────────
|
|
204
166
|
const jobMatch = url.match(/^\/jobs\/([^/?]+)$/);
|
|
205
167
|
if (method === 'GET' && jobMatch) {
|
|
206
168
|
const job = jobs.get(jobMatch[1]);
|
|
@@ -211,33 +173,28 @@ async function handleRequest(req, res, cfg) {
|
|
|
211
173
|
return json(res, 404, { error: 'Not found' });
|
|
212
174
|
}
|
|
213
175
|
|
|
214
|
-
// ─── Start
|
|
176
|
+
// ─── Start (uses Fastify) ─────────────────────────────────────────────────────
|
|
215
177
|
|
|
216
|
-
function start(args = []) {
|
|
178
|
+
async function start(args = []) {
|
|
217
179
|
const cfg = loadConfig(process.cwd(), parseCliOverrides(args));
|
|
218
180
|
const port = cfg.port;
|
|
219
181
|
|
|
220
|
-
const
|
|
221
|
-
try {
|
|
222
|
-
await handleRequest(req, res, cfg);
|
|
223
|
-
} catch (err) {
|
|
224
|
-
// Production error handler — no stack traces
|
|
225
|
-
json(res, 500, { error: 'Internal server error' });
|
|
226
|
-
}
|
|
227
|
-
});
|
|
182
|
+
const fastify = buildApp(cfg);
|
|
228
183
|
|
|
229
|
-
|
|
230
|
-
process.stdout.write(`\n🔒 tdd-audit REST API listening on http://localhost:${port}\n`);
|
|
231
|
-
if (!cfg.serverApiKey) {
|
|
232
|
-
process.stderr.write('⚠️ No --api-key set — server is unauthenticated. Set one for production.\n');
|
|
233
|
-
}
|
|
234
|
-
process.stdout.write(' GET /health\n');
|
|
235
|
-
process.stdout.write(' POST /scan { path, format? }\n');
|
|
236
|
-
process.stdout.write(' POST /remediate { findings, provider, apiKey, model? }\n');
|
|
237
|
-
process.stdout.write(' GET /jobs/:id\n\n');
|
|
238
|
-
});
|
|
184
|
+
await fastify.listen({ port, host: '0.0.0.0' });
|
|
239
185
|
|
|
240
|
-
|
|
186
|
+
process.stdout.write(`\n🔒 tdd-audit REST API listening on http://localhost:${port}\n`);
|
|
187
|
+
if (!cfg.serverApiKey) {
|
|
188
|
+
process.stderr.write('⚠️ No --api-key set — server is unauthenticated. Set one for production.\n');
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(' GET /health\n');
|
|
191
|
+
process.stdout.write(' POST /scan { path, format? }\n');
|
|
192
|
+
process.stdout.write(' POST /remediate { findings, provider, apiKey, model? }\n');
|
|
193
|
+
process.stdout.write(' POST /audit { path, provider?, apiKey?, model? }\n');
|
|
194
|
+
process.stdout.write(' GET /jobs/:id\n');
|
|
195
|
+
process.stdout.write(' GET /jobs/:id/stream (SSE)\n\n');
|
|
196
|
+
|
|
197
|
+
return fastify.server; // returned for testing
|
|
241
198
|
}
|
|
242
199
|
|
|
243
200
|
module.exports = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhi/tdd-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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": {
|
|
@@ -60,5 +60,8 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"jest": "^30.3.0"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"fastify": "^5.8.4"
|
|
63
66
|
}
|
|
64
67
|
}
|