phase2-security.test.mjs
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