phase2-security.test.mjs
382 lines 16.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Phase 2 Security Remediation Tests
3 *
4 * Covers all 6 Phase 2 items from docs/SECURITY-AUDIT-PLAN.md:
5 * 2.1 — npm audit gate in CI (structural: validates audit-level flag is in ci.yml)
6 * 2.2 — Secret scanning in CI (structural: validates trufflehog step is in ci.yml)
7 * 2.3 — Dependency review action on PRs (structural: validates workflow file exists)
8 * 2.4 — Dockerfile: non-root user, pinned base image tag, npm ci
9 * 2.5 — GitHub token encryption: random per-token salt, v1 ciphertext migrated gracefully
10 * 2.6 — multer@2 upgrade; sanitizeUploadFilename validates originalname before disk use
11 */
12
13 import { test, describe } from 'node:test';
14 import assert from 'node:assert/strict';
15 import fs from 'node:fs';
16 import path from 'node:path';
17 import crypto from 'node:crypto';
18 import { fileURLToPath } from 'node:url';
19
20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 const ROOT = path.resolve(__dirname, '..');
22
23 // ---------------------------------------------------------------------------
24 // 2.1 npm audit gate — CI configuration
25 // ---------------------------------------------------------------------------
26 describe('2.1 npm audit gate — CI contains audit step', () => {
27 let ciYml;
28 test('ci.yml exists', () => {
29 const ciPath = path.join(ROOT, '.github/workflows/ci.yml');
30 assert.ok(fs.existsSync(ciPath), 'ci.yml must exist');
31 ciYml = fs.readFileSync(ciPath, 'utf8');
32 });
33
34 test('ci.yml contains npm audit with --audit-level=high', () => {
35 if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8');
36 assert.ok(
37 ciYml.includes('npm audit') && ciYml.includes('--audit-level=high'),
38 'ci.yml must run npm audit --audit-level=high'
39 );
40 });
41
42 test('ci.yml audit step covers root, hub/gateway, and hub/bridge', () => {
43 if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8');
44 const auditBlock = ciYml.slice(ciYml.indexOf('npm audit'));
45 assert.ok(auditBlock.includes('hub/gateway'), 'audit must cover hub/gateway');
46 assert.ok(auditBlock.includes('hub/bridge'), 'audit must cover hub/bridge');
47 });
48
49 test('audit step uses --omit=dev to mirror production install surface', () => {
50 if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8');
51 assert.ok(ciYml.includes('--omit=dev'), 'audit should omit devDependencies (production surface)');
52 });
53 });
54
55 // ---------------------------------------------------------------------------
56 // 2.2 Secret scanning — TruffleHog action in CI
57 // ---------------------------------------------------------------------------
58 describe('2.2 secret scanning — TruffleHog step in CI', () => {
59 let ciYml;
60 const load = () => {
61 if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8');
62 return ciYml;
63 };
64
65 test('ci.yml references trufflehog action', () => {
66 const yml = load();
67 assert.ok(
68 yml.toLowerCase().includes('trufflehog') || yml.toLowerCase().includes('trufflesecurity'),
69 'ci.yml must include a TruffleHog secret-scanning step'
70 );
71 });
72
73 test('trufflehog job uses full checkout (fetch-depth: 0) to scan full history', () => {
74 const yml = load();
75 assert.ok(yml.includes('fetch-depth: 0'), 'secret scan must use full git history (fetch-depth: 0)');
76 });
77
78 test('trufflehog step uses --only-verified flag to reduce noise', () => {
79 const yml = load();
80 assert.ok(yml.includes('only-verified'), 'trufflehog should use --only-verified to suppress false positives');
81 });
82
83 test('trufflehog scan range differs for pull requests and main pushes', () => {
84 const yml = load();
85
86 assert.ok(
87 yml.includes("github.event.pull_request.base.sha"),
88 'pull request secret scan must start from the PR base sha'
89 );
90 assert.ok(
91 yml.includes("github.event.pull_request.head.sha"),
92 'pull request secret scan must end at the PR head sha'
93 );
94 assert.ok(
95 yml.includes('github.event.before'),
96 'main push secret scan must start from the previous main sha'
97 );
98 assert.ok(yml.includes('github.sha'), 'main push secret scan must end at the pushed sha');
99 assert.ok(
100 !yml.includes('base: ${{ github.event.repository.default_branch }}'),
101 'main push secret scan must not compare the checked out main branch to HEAD'
102 );
103 });
104 });
105
106 // ---------------------------------------------------------------------------
107 // 2.3 Dependency review — removed (requires GHAS on private repos);
108 // npm audit in ci.yml covers the same CVEs.
109 // ---------------------------------------------------------------------------
110 describe('2.3 dependency review covered by npm audit in CI', () => {
111 const ciPath = path.join(ROOT, '.github/workflows/ci.yml');
112
113 test('CI workflow includes npm audit gate for high/critical CVEs', () => {
114 const ci = fs.readFileSync(ciPath, 'utf8');
115 assert.ok(ci.includes('npm audit'), 'CI must run npm audit');
116 assert.ok(ci.includes('audit-level'), 'CI must set audit-level threshold');
117 });
118 });
119
120 // ---------------------------------------------------------------------------
121 // 2.4 Dockerfile hardening
122 // ---------------------------------------------------------------------------
123 describe('2.4 Dockerfile: non-root user, pinned tag, npm ci', () => {
124 const dockerfilePath = path.join(ROOT, 'hub/Dockerfile');
125 let dockerfile;
126 const load = () => {
127 if (!dockerfile) dockerfile = fs.readFileSync(dockerfilePath, 'utf8');
128 return dockerfile;
129 };
130
131 test('Dockerfile exists', () => {
132 assert.ok(fs.existsSync(dockerfilePath), 'hub/Dockerfile must exist');
133 });
134
135 test('base image tag is pinned to a specific version (not just node:20-alpine)', () => {
136 const content = load();
137 // Pinned format: node:20.X.Y-alpineX.YY — not the generic floating tag
138 assert.ok(
139 /FROM node:20\.\d+\.\d+-alpine/.test(content),
140 'Dockerfile must pin the base image to a specific patch version (e.g. node:20.19.0-alpine3.21)'
141 );
142 });
143
144 test('Dockerfile does not use generic floating node:20-alpine tag', () => {
145 const content = load();
146 assert.ok(
147 !/FROM node:20-alpine\b/.test(content),
148 'Dockerfile must not use the generic floating node:20-alpine tag'
149 );
150 });
151
152 test('Dockerfile creates and uses a non-root user', () => {
153 const content = load();
154 assert.ok(content.includes('adduser') || content.includes('useradd'), 'must create a non-root user');
155 assert.ok(/^USER\s+\w/m.test(content), 'must switch to non-root USER before CMD');
156 });
157
158 test('USER directive appears before CMD', () => {
159 const content = load();
160 const userIdx = content.indexOf('\nUSER ');
161 const cmdIdx = content.indexOf('\nCMD ');
162 assert.ok(userIdx !== -1, 'USER directive must be present');
163 assert.ok(cmdIdx !== -1, 'CMD directive must be present');
164 assert.ok(userIdx < cmdIdx, 'USER must appear before CMD');
165 });
166
167 test('Dockerfile uses npm ci instead of npm install', () => {
168 const content = load();
169 assert.ok(content.includes('npm ci'), 'Dockerfile must use npm ci for reproducible installs');
170 assert.ok(!content.includes('npm install'), 'Dockerfile must not use npm install');
171 });
172 });
173
174 // ---------------------------------------------------------------------------
175 // 2.5 Per-token random salt in GitHub token encryption
176 // ---------------------------------------------------------------------------
177 describe('2.5 per-token random salt in AES-256-GCM encryption', () => {
178 // Mirror the exact encrypt/decrypt implementation from hub/bridge/server.mjs
179 // so these tests prove the contract without importing the whole server.
180 const ALGO = 'aes-256-gcm';
181 const IV_LEN = 16;
182 const SALT_LEN = 16;
183
184 function encrypt(text, secret) {
185 const salt = crypto.randomBytes(SALT_LEN);
186 const key = crypto.scryptSync(secret, salt, 32);
187 const iv = crypto.randomBytes(IV_LEN);
188 const cipher = crypto.createCipheriv(ALGO, key, iv);
189 const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
190 const tag = cipher.getAuthTag();
191 return (
192 salt.toString('base64url') + '.' +
193 iv.toString('base64url') + '.' +
194 tag.toString('base64url') + '.' +
195 enc.toString('base64url')
196 );
197 }
198
199 function decrypt(encrypted, secret) {
200 const parts = encrypted.split('.');
201 if (parts.length !== 4) return null;
202 const [saltB, ivB, tagB, encB] = parts;
203 if (!saltB || !ivB || !tagB || !encB) return null;
204 try {
205 const key = crypto.scryptSync(secret, Buffer.from(saltB, 'base64url'), 32);
206 const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB, 'base64url'));
207 decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
208 return decipher.update(Buffer.from(encB, 'base64url')) + decipher.final('utf8');
209 } catch {
210 return null;
211 }
212 }
213
214 test('encrypt produces a 4-part ciphertext (salt.iv.tag.enc)', () => {
215 const ct = encrypt('ghp_testtoken', 'mysecret');
216 const parts = ct.split('.');
217 assert.equal(parts.length, 4, 'ciphertext must have exactly 4 dot-separated parts');
218 });
219
220 test('encrypt + decrypt round-trips correctly', () => {
221 const plaintext = 'ghp_abc123testtoken';
222 const secret = 'session-secret-value';
223 const ct = encrypt(plaintext, secret);
224 const result = decrypt(ct, secret);
225 assert.equal(result, plaintext, 'decrypted value must equal the original plaintext');
226 });
227
228 test('two encryptions of the same plaintext produce different ciphertexts (random salt + IV)', () => {
229 const plaintext = 'ghp_sametoken';
230 const secret = 'session-secret';
231 const ct1 = encrypt(plaintext, secret);
232 const ct2 = encrypt(plaintext, secret);
233 assert.notEqual(ct1, ct2, 'each encryption must produce a unique ciphertext due to random salt and IV');
234 });
235
236 test('decryption with wrong secret returns null (authentication fails)', () => {
237 const ct = encrypt('ghp_token', 'correct-secret');
238 const result = decrypt(ct, 'wrong-secret');
239 assert.equal(result, null, 'wrong secret must return null');
240 });
241
242 test('tampered ciphertext returns null (GCM authentication tag check)', () => {
243 const ct = encrypt('ghp_token', 'correct-secret');
244 const parts = ct.split('.');
245 // Flip the last byte of the enc segment
246 const encBuf = Buffer.from(parts[3], 'base64url');
247 encBuf[encBuf.length - 1] ^= 0xff;
248 const tampered = [parts[0], parts[1], parts[2], encBuf.toString('base64url')].join('.');
249 const result = decrypt(tampered, 'correct-secret');
250 assert.equal(result, null, 'tampered ciphertext must not decrypt');
251 });
252
253 test('v1 ciphertext (3-part, hardcoded salt) returns null — triggers graceful reconnect', () => {
254 // Simulate old format: iv.tag.enc (3 parts, salt was hardcoded as "salt")
255 const fakeV1 = 'aGVsbG8.d29ybGQ.dGVzdA';
256 const result = decrypt(fakeV1, 'any-secret');
257 assert.equal(result, null, 'legacy 3-part ciphertext must return null (prompt reconnect)');
258 });
259
260 test('empty string input returns null', () => {
261 assert.equal(decrypt('', 'secret'), null);
262 });
263
264 test('malformed input (only 2 parts) returns null', () => {
265 assert.equal(decrypt('part1.part2', 'secret'), null);
266 });
267
268 test('each token gets a unique salt (different tokens, same secret, different salts)', () => {
269 const secret = 'shared-secret';
270 const ct1 = encrypt('token-a', secret);
271 const ct2 = encrypt('token-b', secret);
272 const salt1 = ct1.split('.')[0];
273 const salt2 = ct2.split('.')[0];
274 assert.notEqual(salt1, salt2, 'salts must be unique per token');
275 });
276
277 test('server.mjs encrypt function embeds a 16-byte salt (base64url decoded length)', () => {
278 const ct = encrypt('test', 'secret');
279 const saltB64 = ct.split('.')[0];
280 const saltBuf = Buffer.from(saltB64, 'base64url');
281 assert.equal(saltBuf.length, SALT_LEN, `salt must be ${SALT_LEN} bytes`);
282 });
283 });
284
285 // ---------------------------------------------------------------------------
286 // 2.6 sanitizeUploadFilename — path traversal and injection prevention
287 // ---------------------------------------------------------------------------
288 describe('2.6 sanitizeUploadFilename — originalname validated before disk use', () => {
289 // Mirror the sanitizeUploadFilename function from hub/bridge/server.mjs exactly.
290 function sanitizeUploadFilename(rawName) {
291 const base = path.basename(rawName || '');
292 const safe = base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
293 return safe || 'upload';
294 }
295
296 test('normal filename is preserved (alphanumeric + dot + hyphen + underscore)', () => {
297 assert.equal(sanitizeUploadFilename('my-notes.zip'), 'my-notes.zip');
298 });
299
300 test('path traversal ../ is stripped — basename removes directory components', () => {
301 const result = sanitizeUploadFilename('../../../etc/passwd');
302 assert.ok(!result.includes('/'), 'must not contain path separators');
303 assert.ok(!result.includes('..'), 'must not contain traversal components');
304 assert.equal(result, 'passwd', 'basename of traversal path is the filename only');
305 });
306
307 test('absolute path traversal is stripped', () => {
308 const result = sanitizeUploadFilename('/etc/shadow');
309 assert.ok(!result.startsWith('/'), 'must not start with /');
310 assert.equal(result, 'shadow');
311 });
312
313 test('spaces are replaced with underscores', () => {
314 const result = sanitizeUploadFilename('my notes file.md');
315 assert.ok(!result.includes(' '), 'spaces must be replaced');
316 assert.equal(result, 'my_notes_file.md');
317 });
318
319 test('special shell characters are replaced', () => {
320 const result = sanitizeUploadFilename('file$(rm -rf /).md');
321 assert.ok(!result.includes('$'), 'dollar sign must be replaced');
322 assert.ok(!result.includes('('), 'parentheses must be replaced');
323 assert.ok(!result.includes(' '), 'spaces must be replaced');
324 });
325
326 test('null bytes are replaced', () => {
327 const result = sanitizeUploadFilename('file\x00name.md');
328 assert.ok(!result.includes('\x00'), 'null bytes must be sanitized');
329 });
330
331 test('empty string falls back to "upload"', () => {
332 assert.equal(sanitizeUploadFilename(''), 'upload');
333 });
334
335 test('null/undefined falls back to "upload"', () => {
336 assert.equal(sanitizeUploadFilename(null), 'upload');
337 assert.equal(sanitizeUploadFilename(undefined), 'upload');
338 });
339
340 test('filename longer than 200 chars is truncated', () => {
341 const longName = 'a'.repeat(300) + '.zip';
342 const result = sanitizeUploadFilename(longName);
343 assert.ok(result.length <= 200, `result must be <= 200 chars, got ${result.length}`);
344 });
345
346 test('Windows-style backslash path traversal is handled', () => {
347 // path.basename on POSIX treats the whole thing as one component if no /
348 // but we should still sanitize any remaining backslashes
349 const result = sanitizeUploadFilename('..\\..\\etc\\passwd');
350 assert.ok(!result.includes('\\'), 'backslashes must be replaced');
351 assert.ok(!result.includes('/'), 'must not contain forward slashes');
352 });
353
354 test('filename with dots and valid extension is accepted unchanged', () => {
355 assert.equal(sanitizeUploadFilename('vault-backup.2026-04-09.zip'), 'vault-backup.2026-04-09.zip');
356 });
357
358 test('unicode characters are replaced with underscores', () => {
359 const result = sanitizeUploadFilename('résumé-français.pdf');
360 assert.ok(!/[^\x00-\x7F]/.test(result), 'non-ASCII characters must be replaced');
361 });
362
363 test('safe zip filename passes through without changes', () => {
364 assert.equal(sanitizeUploadFilename('my-export.zip'), 'my-export.zip');
365 });
366 });
367
368 // ---------------------------------------------------------------------------
369 // 2.6 (continued) multer upgrade — package.json reflects multer@2
370 // ---------------------------------------------------------------------------
371 describe('2.6 multer@2 upgrade — package.json uses multer@^2', () => {
372 test('root package.json declares multer@^2', () => {
373 const pkgPath = path.join(ROOT, 'package.json');
374 const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
375 const multerVersion = (pkg.dependencies || {}).multer || (pkg.devDependencies || {}).multer;
376 assert.ok(multerVersion, 'multer must be listed as a dependency');
377 assert.ok(
378 multerVersion.startsWith('^2') || multerVersion.startsWith('2'),
379 `multer must be version 2.x, found: ${multerVersion}`
380 );
381 });
382 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago