@lhi/tdd-audit 1.1.2 → 1.3.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/SKILL.md +7 -1
- package/index.js +138 -17
- package/package.json +9 -2
- package/prompts/auto-audit.md +143 -0
- package/prompts/green-phase.md +435 -0
- package/prompts/hardening-phase.md +243 -0
- package/prompts/red-phase.md +154 -0
- package/prompts/refactor-phase.md +26 -0
- package/templates/sample.exploit.test.dart +52 -0
- package/templates/sample.exploit.test.react.tsx +59 -0
- package/templates/workflows/security-tests.flutter.yml +26 -0
- package/workflows/tdd-audit.md +8 -1
package/prompts/red-phase.md
CHANGED
|
@@ -120,3 +120,157 @@ def test_vuln_type_exploit(client, attacker_token):
|
|
|
120
120
|
)
|
|
121
121
|
assert response.status_code == 403 # currently 200 — RED
|
|
122
122
|
```
|
|
123
|
+
|
|
124
|
+
### React / Next.js (Vitest + Testing Library)
|
|
125
|
+
```typescript
|
|
126
|
+
// Sensitive storage: token must NOT land in localStorage
|
|
127
|
+
import { render, fireEvent, waitFor } from '@testing-library/react';
|
|
128
|
+
import LoginForm from '../../components/LoginForm';
|
|
129
|
+
|
|
130
|
+
test('SHOULD NOT store auth token in localStorage', async () => {
|
|
131
|
+
render(<LoginForm />);
|
|
132
|
+
fireEvent.submit(screen.getByRole('form'));
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(localStorage.getItem('token')).toBeNull(); // currently set — RED
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// XSS: dangerouslySetInnerHTML must not accept unsanitized input
|
|
139
|
+
test('SHOULD sanitize user content before rendering', () => {
|
|
140
|
+
const xssPayload = '<script>alert(1)</script>';
|
|
141
|
+
const { container } = render(<CommentBody content={xssPayload} />);
|
|
142
|
+
expect(container.innerHTML).not.toContain('<script>'); // currently reflected — RED
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### React Native / Expo (Jest)
|
|
147
|
+
```javascript
|
|
148
|
+
// Route param injection: params must be validated before API use
|
|
149
|
+
import { renderRouter, screen } from 'expo-router/testing-library';
|
|
150
|
+
|
|
151
|
+
test('SHOULD NOT pass raw route params to API query', async () => {
|
|
152
|
+
const maliciousParam = "1 UNION SELECT * FROM users";
|
|
153
|
+
// Render the screen with a crafted route param
|
|
154
|
+
renderRouter({ initialUrl: `/item/${encodeURIComponent(maliciousParam)}` });
|
|
155
|
+
// Assert the API was NOT called with the raw param
|
|
156
|
+
expect(mockApiClient.getItem).not.toHaveBeenCalledWith(maliciousParam); // currently called — RED
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Sensitive storage: tokens must use SecureStore, not AsyncStorage
|
|
160
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
161
|
+
|
|
162
|
+
test('SHOULD NOT store token in plain AsyncStorage', async () => {
|
|
163
|
+
await simulateLogin({ username: 'user', password: 'pass' });
|
|
164
|
+
const stored = await AsyncStorage.getItem('token');
|
|
165
|
+
expect(stored).toBeNull(); // currently stored in plain AsyncStorage — RED
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### SSRF (Server-Side Request Forgery)
|
|
170
|
+
Supply a user-controlled URL pointing to an internal resource (e.g., `http://169.254.169.254/` AWS metadata).
|
|
171
|
+
Assert a 400 or 403 — not a 200 proxying internal content.
|
|
172
|
+
```javascript
|
|
173
|
+
const res = await request(app)
|
|
174
|
+
.post('/api/fetch-preview')
|
|
175
|
+
.send({ url: 'http://169.254.169.254/latest/meta-data/' });
|
|
176
|
+
expect(res.status).toBe(400); // currently fetches and returns internal data — RED
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Open Redirect
|
|
180
|
+
Supply a fully external URL as the redirect destination.
|
|
181
|
+
Assert a 400 or that the redirect stays within the same origin.
|
|
182
|
+
```javascript
|
|
183
|
+
const res = await request(app)
|
|
184
|
+
.get('/auth/callback')
|
|
185
|
+
.query({ redirect: 'https://evil.com/steal-token' });
|
|
186
|
+
expect(res.status).toBe(400); // currently 302 to attacker site — RED
|
|
187
|
+
// OR assert Location header is relative:
|
|
188
|
+
expect(res.headers.location).not.toMatch(/^https?:\/\//);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### NoSQL Injection
|
|
192
|
+
Supply a MongoDB operator object instead of a plain string value.
|
|
193
|
+
Assert the query is rejected or returns no data.
|
|
194
|
+
```javascript
|
|
195
|
+
const res = await request(app)
|
|
196
|
+
.post('/api/login')
|
|
197
|
+
.send({ username: { $gt: '' }, password: { $gt: '' } });
|
|
198
|
+
expect(res.status).toBe(400); // currently returns first user record — RED
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Mass Assignment
|
|
202
|
+
Submit extra fields that should not be user-settable (e.g., `isAdmin`, `role`).
|
|
203
|
+
Assert the privileged field was ignored.
|
|
204
|
+
```javascript
|
|
205
|
+
const res = await request(app)
|
|
206
|
+
.post('/api/users/register')
|
|
207
|
+
.send({ username: 'attacker', password: 'pass', isAdmin: true });
|
|
208
|
+
expect(res.status).toBe(201);
|
|
209
|
+
const user = await User.findOne({ username: 'attacker' });
|
|
210
|
+
expect(user.isAdmin).toBe(false); // currently set to true — RED
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Prototype Pollution
|
|
214
|
+
Submit a payload that sets `__proto__` to inject properties into Object.prototype.
|
|
215
|
+
Assert the injected property is not visible on a fresh `{}`.
|
|
216
|
+
```javascript
|
|
217
|
+
const res = await request(app)
|
|
218
|
+
.post('/api/settings/merge')
|
|
219
|
+
.send({ '__proto__': { polluted: true } });
|
|
220
|
+
expect(res.status).toBe(200);
|
|
221
|
+
expect({}.polluted).toBeUndefined(); // currently true — RED
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Weak Crypto (Password Hashing)
|
|
225
|
+
Hash a known password and assert the resulting hash is not a raw MD5/SHA1 hex string.
|
|
226
|
+
```javascript
|
|
227
|
+
const bcrypt = require('bcrypt');
|
|
228
|
+
const user = await User.create({ email: 'x@x.com', password: 'mypassword' });
|
|
229
|
+
// An MD5 hash of 'mypassword' is 34819d7beeabb9260a5c854bc85b3e44
|
|
230
|
+
expect(user.passwordHash).not.toBe('34819d7beeabb9260a5c854bc85b3e44');
|
|
231
|
+
// A proper bcrypt hash starts with $2b$
|
|
232
|
+
expect(user.passwordHash).toMatch(/^\$2[aby]\$/); // currently fails — RED
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Missing Rate Limiting
|
|
236
|
+
Send 10 rapid login attempts; assert the 11th is throttled (429).
|
|
237
|
+
```javascript
|
|
238
|
+
for (let i = 0; i < 10; i++) {
|
|
239
|
+
await request(app).post('/api/auth/login').send({ email: 'x@x.com', password: 'wrong' });
|
|
240
|
+
}
|
|
241
|
+
const res = await request(app).post('/api/auth/login').send({ email: 'x@x.com', password: 'wrong' });
|
|
242
|
+
expect(res.status).toBe(429); // currently 401 — rate limit not enforced — RED
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Missing Security Headers
|
|
246
|
+
Assert a response includes the `X-Content-Type-Options` and `X-Frame-Options` headers set by Helmet.
|
|
247
|
+
```javascript
|
|
248
|
+
const res = await request(app).get('/');
|
|
249
|
+
expect(res.headers['x-content-type-options']).toBe('nosniff'); // currently absent — RED
|
|
250
|
+
expect(res.headers['x-frame-options']).toBeDefined(); // currently absent — RED
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Flutter / Dart (flutter_test)
|
|
254
|
+
```dart
|
|
255
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
256
|
+
import 'package:shared_preferences/shared_preferences.dart';
|
|
257
|
+
|
|
258
|
+
void main() {
|
|
259
|
+
// Sensitive storage: token must NOT be in unencrypted SharedPreferences
|
|
260
|
+
test('SHOULD NOT store auth token in SharedPreferences', () async {
|
|
261
|
+
SharedPreferences.setMockInitialValues({});
|
|
262
|
+
await simulateLogin(username: 'user', password: 'password');
|
|
263
|
+
final prefs = await SharedPreferences.getInstance();
|
|
264
|
+
expect(prefs.getString('token'), isNull,
|
|
265
|
+
reason: 'Tokens must not be in unencrypted SharedPreferences — use flutter_secure_storage'); // currently stored — RED
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// TLS bypass: HTTP client must not disable certificate validation
|
|
269
|
+
test('SHOULD enforce TLS certificate verification', () {
|
|
270
|
+
final client = buildHttpClient(); // the app's HTTP client factory
|
|
271
|
+
// Inspect that no badCertificateCallback bypasses verification
|
|
272
|
+
expect(client.badCertificateCallback, isNull,
|
|
273
|
+
reason: 'badCertificateCallback must not be set to bypass TLS'); // currently bypassed — RED
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
```
|
|
@@ -22,6 +22,32 @@ Go through this checklist before closing the vulnerability:
|
|
|
22
22
|
- [ ] **Performance acceptable** — the patch doesn't add unbounded DB queries or blocking I/O
|
|
23
23
|
- [ ] **No secrets in code** — patch doesn't hardcode keys, tokens, or credentials
|
|
24
24
|
|
|
25
|
+
**React / Next.js additions:**
|
|
26
|
+
- [ ] **`dangerouslySetInnerHTML` removed or wrapped** — confirm DOMPurify is imported and called before all remaining usages
|
|
27
|
+
- [ ] **Next.js middleware matcher is correct** — `/api/:path*` or tighter; public routes (health checks, webhooks) still reachable
|
|
28
|
+
- [ ] **`app.json` / `.env.local` clean** — no API keys or secrets committed; `*.env` is in `.gitignore`
|
|
29
|
+
|
|
30
|
+
**React Native / Expo additions:**
|
|
31
|
+
- [ ] **`AsyncStorage` fully migrated** — no remaining `setItem('token', ...)` calls; `expo-secure-store` in `package.json`
|
|
32
|
+
- [ ] **Offline token refresh still works** — `SecureStore.getItemAsync` is called in the right lifecycle (not before `SecureStore.isAvailableAsync()` on web)
|
|
33
|
+
- [ ] **Deep link params validated** — any `route.params` passed to API calls are sanitized or type-checked
|
|
34
|
+
|
|
35
|
+
**New vulnerability class additions:**
|
|
36
|
+
- [ ] **SSRF allowlist verified** — `validateExternalUrl` throws on internal IPs and non-allowlisted hosts; confirm `169.254.x.x` and `10.x.x.x` are blocked
|
|
37
|
+
- [ ] **Open redirect uses relative-only check** — `/^https?:\/\//` and `//` prefix both rejected; confirm legitimate in-app redirects still work
|
|
38
|
+
- [ ] **NoSQL injection sanitized** — `express-mongo-sanitize` or equivalent applied globally; confirm `{ $gt: '' }` payloads return 400
|
|
39
|
+
- [ ] **Mass assignment uses field allowlist** — no `req.body` passed directly to ORM; confirm privileged fields (`isAdmin`, `role`) cannot be set by user
|
|
40
|
+
- [ ] **Prototype pollution sanitizes keys** — `__proto__`, `constructor`, `prototype` keys stripped before any merge; confirm `{}.polluted` is still `undefined` after merge
|
|
41
|
+
- [ ] **Passwords use bcrypt/argon2** — no `createHash('md5')` or `createHash('sha1')` for passwords; `bcrypt.compare` used on login
|
|
42
|
+
- [ ] **Rate limiting active on auth routes** — `/login` and `/register` return 429 after threshold; general API routes have a broader limit
|
|
43
|
+
- [ ] **Helmet applied before all routes** — `X-Content-Type-Options: nosniff` and `X-Frame-Options` present in response; CSP header present
|
|
44
|
+
|
|
45
|
+
**Flutter additions:**
|
|
46
|
+
- [ ] **`flutter_secure_storage` in `pubspec.yaml`** — dependency present and `flutter pub get` ran
|
|
47
|
+
- [ ] **No remaining `SharedPreferences` calls for sensitive keys** — grep for `prefs.getString('token')`, `prefs.setString('password', ...)`
|
|
48
|
+
- [ ] **TLS `badCertificateCallback` fully removed** — grep the entire `lib/` directory for `badCertificateCallback`
|
|
49
|
+
- [ ] **iOS entitlements updated if needed** — `flutter_secure_storage` requires Keychain Sharing capability on iOS
|
|
50
|
+
|
|
25
51
|
### Step 3: Clean the patch
|
|
26
52
|
- Remove any debugging `console.log` or `print` statements added during patching
|
|
27
53
|
- Extract reusable security logic into middleware or utility functions if it appears in more than one place
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/// TDD Remediation: Red Phase Sample Test (Flutter / Dart)
|
|
2
|
+
///
|
|
3
|
+
/// Replace the boilerplate below with the specific exploit you are verifying.
|
|
4
|
+
/// This test MUST fail initially (Red Phase). Once you apply the fix, it MUST pass (Green Phase).
|
|
5
|
+
///
|
|
6
|
+
/// Run with: flutter test test/security/
|
|
7
|
+
|
|
8
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
9
|
+
// import 'package:shared_preferences/shared_preferences.dart';
|
|
10
|
+
// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
11
|
+
// import '../../lib/services/auth_service.dart'; // update with your auth service path
|
|
12
|
+
|
|
13
|
+
void main() {
|
|
14
|
+
group('Security Vulnerability Remediation - Red Phase', () {
|
|
15
|
+
|
|
16
|
+
// ── Example 1: Sensitive Storage ─────────────────────────────────────────
|
|
17
|
+
// test('SHOULD NOT store auth token in plain SharedPreferences', () async {
|
|
18
|
+
// SharedPreferences.setMockInitialValues({});
|
|
19
|
+
//
|
|
20
|
+
// // Act: simulate what the app does after login
|
|
21
|
+
// // await AuthService().login(username: 'user', password: 'pass');
|
|
22
|
+
//
|
|
23
|
+
// // Assert: token must NOT be in unencrypted SharedPreferences
|
|
24
|
+
// final prefs = await SharedPreferences.getInstance();
|
|
25
|
+
// expect(prefs.getString('token'), isNull,
|
|
26
|
+
// reason: 'Use flutter_secure_storage instead'); // currently stored — RED
|
|
27
|
+
// });
|
|
28
|
+
|
|
29
|
+
// ── Example 2: TLS Bypass ─────────────────────────────────────────────────
|
|
30
|
+
// test('SHOULD enforce TLS certificate verification', () {
|
|
31
|
+
// final client = buildHttpClient(); // your app's HTTP client factory
|
|
32
|
+
// expect(client.badCertificateCallback, isNull,
|
|
33
|
+
// reason: 'badCertificateCallback must not bypass TLS'); // currently bypassed — RED
|
|
34
|
+
// });
|
|
35
|
+
|
|
36
|
+
// ── Example 3: Navigation Param Injection ─────────────────────────────────
|
|
37
|
+
// test('SHOULD NOT use raw route params in API calls', () async {
|
|
38
|
+
// const maliciousId = "1; DROP TABLE users";
|
|
39
|
+
// // Simulate screen loading with a crafted route argument
|
|
40
|
+
// // final result = await ItemService().fetchItem(id: maliciousId);
|
|
41
|
+
// //
|
|
42
|
+
// // Assert the input was validated / rejected
|
|
43
|
+
// // expect(result, isNull); // currently fetches — RED
|
|
44
|
+
// });
|
|
45
|
+
|
|
46
|
+
test('PLACEHOLDER — replace with your exploit assertion', () {
|
|
47
|
+
// Remove this placeholder and uncomment one of the examples above.
|
|
48
|
+
expect(true, isTrue);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD Remediation: Red Phase Sample Test (React / Next.js — Testing Library + Vitest)
|
|
3
|
+
*
|
|
4
|
+
* For UI-layer security tests: XSS via dangerouslySetInnerHTML, sensitive data
|
|
5
|
+
* rendering, unauthenticated route access, client-side auth bypass, etc.
|
|
6
|
+
*
|
|
7
|
+
* Replace the boilerplate below with the specific exploit you are verifying.
|
|
8
|
+
* This test MUST fail initially (Red Phase). Once you apply the fix, it MUST pass (Green Phase).
|
|
9
|
+
*
|
|
10
|
+
* Run with: vitest run __tests__/security/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
14
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
15
|
+
// import ComponentUnderTest from '../../components/ComponentUnderTest'; // update path
|
|
16
|
+
|
|
17
|
+
describe('Security Vulnerability Remediation - Red Phase (UI)', () => {
|
|
18
|
+
|
|
19
|
+
// ── Example 1: XSS via dangerouslySetInnerHTML ───────────────────────────
|
|
20
|
+
// it('SHOULD sanitize user content before rendering as HTML', () => {
|
|
21
|
+
// const xssPayload = '<script>window.__xss = true</script>';
|
|
22
|
+
// render(<CommentBody content={xssPayload} />);
|
|
23
|
+
//
|
|
24
|
+
// // Script tag must not be injected into the DOM
|
|
25
|
+
// expect(document.querySelector('script')).toBeNull(); // currently rendered — RED
|
|
26
|
+
// expect((window as any).__xss).toBeUndefined();
|
|
27
|
+
// });
|
|
28
|
+
|
|
29
|
+
// ── Example 2: Sensitive data must not appear in rendered output ─────────
|
|
30
|
+
// it('SHOULD NOT expose auth token in the DOM', () => {
|
|
31
|
+
// render(<UserProfile token="super-secret-jwt" />);
|
|
32
|
+
// expect(screen.queryByText('super-secret-jwt')).toBeNull(); // currently visible — RED
|
|
33
|
+
// });
|
|
34
|
+
|
|
35
|
+
// ── Example 3: Protected route must reject unauthenticated users ─────────
|
|
36
|
+
// it('SHOULD NOT render protected content without a valid session', () => {
|
|
37
|
+
// render(<ProtectedPage />, { wrapper: UnauthenticatedProvider });
|
|
38
|
+
// expect(screen.queryByRole('main')).toBeNull(); // currently renders — RED
|
|
39
|
+
// expect(screen.getByText(/sign in/i)).toBeInTheDocument();
|
|
40
|
+
// });
|
|
41
|
+
|
|
42
|
+
// ── Example 4: Form input must be sanitized before submission ────────────
|
|
43
|
+
// it('SHOULD strip script tags from form input before submit', async () => {
|
|
44
|
+
// const user = userEvent.setup();
|
|
45
|
+
// render(<CommentForm onSubmit={mockSubmit} />);
|
|
46
|
+
// await user.type(screen.getByRole('textbox'), '<script>alert(1)</script>');
|
|
47
|
+
// await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
48
|
+
//
|
|
49
|
+
// expect(mockSubmit).toHaveBeenCalledWith(
|
|
50
|
+
// expect.not.objectContaining({ body: expect.stringContaining('<script>') })
|
|
51
|
+
// ); // currently passes raw payload — RED
|
|
52
|
+
// });
|
|
53
|
+
|
|
54
|
+
it('PLACEHOLDER — replace with your exploit assertion', () => {
|
|
55
|
+
// Remove this placeholder and uncomment one of the examples above.
|
|
56
|
+
expect(true).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Security Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main, master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
security-tests:
|
|
11
|
+
name: Exploit Test Suite
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: subosito/flutter-action@v2
|
|
18
|
+
with:
|
|
19
|
+
flutter-version: 'stable'
|
|
20
|
+
cache: true
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: flutter pub get
|
|
24
|
+
|
|
25
|
+
- name: Run security exploit tests
|
|
26
|
+
run: flutter test test/security/
|
package/workflows/tdd-audit.md
CHANGED
|
@@ -11,6 +11,13 @@ Follow the full Auto-Audit protocol from `auto-audit.md`:
|
|
|
11
11
|
- Write the exploit test (Red — must fail)
|
|
12
12
|
- Apply the patch (Green — test must pass)
|
|
13
13
|
- Run the full suite (Refactor — no regressions)
|
|
14
|
-
4. **
|
|
14
|
+
4. **Harden** the codebase proactively after all vulnerabilities are patched:
|
|
15
|
+
- Security headers (Helmet / CSP)
|
|
16
|
+
- Rate limiting on auth routes
|
|
17
|
+
- Dependency vulnerability audit (npm audit / pip-audit / govulncheck)
|
|
18
|
+
- Secret history scan (gitleaks / trufflehog)
|
|
19
|
+
- Production error handling (no stack traces)
|
|
20
|
+
- CSRF protection and secure cookie flags
|
|
21
|
+
5. **Report** a final Remediation Summary table when all issues are addressed.
|
|
15
22
|
|
|
16
23
|
Do not skip steps. Do not advance to the next vulnerability until the current one is fully proven closed by a passing test.
|