/** * Phase 2 Security Remediation Tests * * Covers all 6 Phase 2 items from docs/SECURITY-AUDIT-PLAN.md: * 2.1 — npm audit gate in CI (structural: validates audit-level flag is in ci.yml) * 2.2 — Secret scanning in CI (structural: validates trufflehog step is in ci.yml) * 2.3 — Dependency review action on PRs (structural: validates workflow file exists) * 2.4 — Dockerfile: non-root user, pinned base image tag, npm ci * 2.5 — GitHub token encryption: random per-token salt, v1 ciphertext migrated gracefully * 2.6 — multer@2 upgrade; sanitizeUploadFilename validates originalname before disk use */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); // --------------------------------------------------------------------------- // 2.1 npm audit gate — CI configuration // --------------------------------------------------------------------------- describe('2.1 npm audit gate — CI contains audit step', () => { let ciYml; test('ci.yml exists', () => { const ciPath = path.join(ROOT, '.github/workflows/ci.yml'); assert.ok(fs.existsSync(ciPath), 'ci.yml must exist'); ciYml = fs.readFileSync(ciPath, 'utf8'); }); test('ci.yml contains npm audit with --audit-level=high', () => { if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'); assert.ok( ciYml.includes('npm audit') && ciYml.includes('--audit-level=high'), 'ci.yml must run npm audit --audit-level=high' ); }); test('ci.yml audit step covers root, hub/gateway, and hub/bridge', () => { if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'); const auditBlock = ciYml.slice(ciYml.indexOf('npm audit')); assert.ok(auditBlock.includes('hub/gateway'), 'audit must cover hub/gateway'); assert.ok(auditBlock.includes('hub/bridge'), 'audit must cover hub/bridge'); }); test('audit step uses --omit=dev to mirror production install surface', () => { if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'); assert.ok(ciYml.includes('--omit=dev'), 'audit should omit devDependencies (production surface)'); }); }); // --------------------------------------------------------------------------- // 2.2 Secret scanning — TruffleHog action in CI // --------------------------------------------------------------------------- describe('2.2 secret scanning — TruffleHog step in CI', () => { let ciYml; const load = () => { if (!ciYml) ciYml = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'); return ciYml; }; test('ci.yml references trufflehog action', () => { const yml = load(); assert.ok( yml.toLowerCase().includes('trufflehog') || yml.toLowerCase().includes('trufflesecurity'), 'ci.yml must include a TruffleHog secret-scanning step' ); }); test('trufflehog job uses full checkout (fetch-depth: 0) to scan full history', () => { const yml = load(); assert.ok(yml.includes('fetch-depth: 0'), 'secret scan must use full git history (fetch-depth: 0)'); }); test('trufflehog step uses --only-verified flag to reduce noise', () => { const yml = load(); assert.ok(yml.includes('only-verified'), 'trufflehog should use --only-verified to suppress false positives'); }); test('trufflehog scan range differs for pull requests and main pushes', () => { const yml = load(); assert.ok( yml.includes("github.event.pull_request.base.sha"), 'pull request secret scan must start from the PR base sha' ); assert.ok( yml.includes("github.event.pull_request.head.sha"), 'pull request secret scan must end at the PR head sha' ); assert.ok( yml.includes('github.event.before'), 'main push secret scan must start from the previous main sha' ); assert.ok(yml.includes('github.sha'), 'main push secret scan must end at the pushed sha'); assert.ok( !yml.includes('base: ${{ github.event.repository.default_branch }}'), 'main push secret scan must not compare the checked out main branch to HEAD' ); }); }); // --------------------------------------------------------------------------- // 2.3 Dependency review — removed (requires GHAS on private repos); // npm audit in ci.yml covers the same CVEs. // --------------------------------------------------------------------------- describe('2.3 dependency review covered by npm audit in CI', () => { const ciPath = path.join(ROOT, '.github/workflows/ci.yml'); test('CI workflow includes npm audit gate for high/critical CVEs', () => { const ci = fs.readFileSync(ciPath, 'utf8'); assert.ok(ci.includes('npm audit'), 'CI must run npm audit'); assert.ok(ci.includes('audit-level'), 'CI must set audit-level threshold'); }); }); // --------------------------------------------------------------------------- // 2.4 Dockerfile hardening // --------------------------------------------------------------------------- describe('2.4 Dockerfile: non-root user, pinned tag, npm ci', () => { const dockerfilePath = path.join(ROOT, 'hub/Dockerfile'); let dockerfile; const load = () => { if (!dockerfile) dockerfile = fs.readFileSync(dockerfilePath, 'utf8'); return dockerfile; }; test('Dockerfile exists', () => { assert.ok(fs.existsSync(dockerfilePath), 'hub/Dockerfile must exist'); }); test('base image tag is pinned to a specific version (not just node:20-alpine)', () => { const content = load(); // Pinned format: node:20.X.Y-alpineX.YY — not the generic floating tag assert.ok( /FROM node:20\.\d+\.\d+-alpine/.test(content), 'Dockerfile must pin the base image to a specific patch version (e.g. node:20.19.0-alpine3.21)' ); }); test('Dockerfile does not use generic floating node:20-alpine tag', () => { const content = load(); assert.ok( !/FROM node:20-alpine\b/.test(content), 'Dockerfile must not use the generic floating node:20-alpine tag' ); }); test('Dockerfile creates and uses a non-root user', () => { const content = load(); assert.ok(content.includes('adduser') || content.includes('useradd'), 'must create a non-root user'); assert.ok(/^USER\s+\w/m.test(content), 'must switch to non-root USER before CMD'); }); test('USER directive appears before CMD', () => { const content = load(); const userIdx = content.indexOf('\nUSER '); const cmdIdx = content.indexOf('\nCMD '); assert.ok(userIdx !== -1, 'USER directive must be present'); assert.ok(cmdIdx !== -1, 'CMD directive must be present'); assert.ok(userIdx < cmdIdx, 'USER must appear before CMD'); }); test('Dockerfile uses npm ci instead of npm install', () => { const content = load(); assert.ok(content.includes('npm ci'), 'Dockerfile must use npm ci for reproducible installs'); assert.ok(!content.includes('npm install'), 'Dockerfile must not use npm install'); }); }); // --------------------------------------------------------------------------- // 2.5 Per-token random salt in GitHub token encryption // --------------------------------------------------------------------------- describe('2.5 per-token random salt in AES-256-GCM encryption', () => { // Mirror the exact encrypt/decrypt implementation from hub/bridge/server.mjs // so these tests prove the contract without importing the whole server. const ALGO = 'aes-256-gcm'; const IV_LEN = 16; const SALT_LEN = 16; function encrypt(text, secret) { const salt = crypto.randomBytes(SALT_LEN); const key = crypto.scryptSync(secret, salt, 32); const iv = crypto.randomBytes(IV_LEN); const cipher = crypto.createCipheriv(ALGO, key, iv); const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return ( salt.toString('base64url') + '.' + iv.toString('base64url') + '.' + tag.toString('base64url') + '.' + enc.toString('base64url') ); } function decrypt(encrypted, secret) { const parts = encrypted.split('.'); if (parts.length !== 4) return null; const [saltB, ivB, tagB, encB] = parts; if (!saltB || !ivB || !tagB || !encB) return null; try { const key = crypto.scryptSync(secret, Buffer.from(saltB, 'base64url'), 32); const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB, 'base64url')); decipher.setAuthTag(Buffer.from(tagB, 'base64url')); return decipher.update(Buffer.from(encB, 'base64url')) + decipher.final('utf8'); } catch { return null; } } test('encrypt produces a 4-part ciphertext (salt.iv.tag.enc)', () => { const ct = encrypt('ghp_testtoken', 'mysecret'); const parts = ct.split('.'); assert.equal(parts.length, 4, 'ciphertext must have exactly 4 dot-separated parts'); }); test('encrypt + decrypt round-trips correctly', () => { const plaintext = 'ghp_abc123testtoken'; const secret = 'session-secret-value'; const ct = encrypt(plaintext, secret); const result = decrypt(ct, secret); assert.equal(result, plaintext, 'decrypted value must equal the original plaintext'); }); test('two encryptions of the same plaintext produce different ciphertexts (random salt + IV)', () => { const plaintext = 'ghp_sametoken'; const secret = 'session-secret'; const ct1 = encrypt(plaintext, secret); const ct2 = encrypt(plaintext, secret); assert.notEqual(ct1, ct2, 'each encryption must produce a unique ciphertext due to random salt and IV'); }); test('decryption with wrong secret returns null (authentication fails)', () => { const ct = encrypt('ghp_token', 'correct-secret'); const result = decrypt(ct, 'wrong-secret'); assert.equal(result, null, 'wrong secret must return null'); }); test('tampered ciphertext returns null (GCM authentication tag check)', () => { const ct = encrypt('ghp_token', 'correct-secret'); const parts = ct.split('.'); // Flip the last byte of the enc segment const encBuf = Buffer.from(parts[3], 'base64url'); encBuf[encBuf.length - 1] ^= 0xff; const tampered = [parts[0], parts[1], parts[2], encBuf.toString('base64url')].join('.'); const result = decrypt(tampered, 'correct-secret'); assert.equal(result, null, 'tampered ciphertext must not decrypt'); }); test('v1 ciphertext (3-part, hardcoded salt) returns null — triggers graceful reconnect', () => { // Simulate old format: iv.tag.enc (3 parts, salt was hardcoded as "salt") const fakeV1 = 'aGVsbG8.d29ybGQ.dGVzdA'; const result = decrypt(fakeV1, 'any-secret'); assert.equal(result, null, 'legacy 3-part ciphertext must return null (prompt reconnect)'); }); test('empty string input returns null', () => { assert.equal(decrypt('', 'secret'), null); }); test('malformed input (only 2 parts) returns null', () => { assert.equal(decrypt('part1.part2', 'secret'), null); }); test('each token gets a unique salt (different tokens, same secret, different salts)', () => { const secret = 'shared-secret'; const ct1 = encrypt('token-a', secret); const ct2 = encrypt('token-b', secret); const salt1 = ct1.split('.')[0]; const salt2 = ct2.split('.')[0]; assert.notEqual(salt1, salt2, 'salts must be unique per token'); }); test('server.mjs encrypt function embeds a 16-byte salt (base64url decoded length)', () => { const ct = encrypt('test', 'secret'); const saltB64 = ct.split('.')[0]; const saltBuf = Buffer.from(saltB64, 'base64url'); assert.equal(saltBuf.length, SALT_LEN, `salt must be ${SALT_LEN} bytes`); }); }); // --------------------------------------------------------------------------- // 2.6 sanitizeUploadFilename — path traversal and injection prevention // --------------------------------------------------------------------------- describe('2.6 sanitizeUploadFilename — originalname validated before disk use', () => { // Mirror the sanitizeUploadFilename function from hub/bridge/server.mjs exactly. function sanitizeUploadFilename(rawName) { const base = path.basename(rawName || ''); const safe = base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200); return safe || 'upload'; } test('normal filename is preserved (alphanumeric + dot + hyphen + underscore)', () => { assert.equal(sanitizeUploadFilename('my-notes.zip'), 'my-notes.zip'); }); test('path traversal ../ is stripped — basename removes directory components', () => { const result = sanitizeUploadFilename('../../../etc/passwd'); assert.ok(!result.includes('/'), 'must not contain path separators'); assert.ok(!result.includes('..'), 'must not contain traversal components'); assert.equal(result, 'passwd', 'basename of traversal path is the filename only'); }); test('absolute path traversal is stripped', () => { const result = sanitizeUploadFilename('/etc/shadow'); assert.ok(!result.startsWith('/'), 'must not start with /'); assert.equal(result, 'shadow'); }); test('spaces are replaced with underscores', () => { const result = sanitizeUploadFilename('my notes file.md'); assert.ok(!result.includes(' '), 'spaces must be replaced'); assert.equal(result, 'my_notes_file.md'); }); test('special shell characters are replaced', () => { const result = sanitizeUploadFilename('file$(rm -rf /).md'); assert.ok(!result.includes('$'), 'dollar sign must be replaced'); assert.ok(!result.includes('('), 'parentheses must be replaced'); assert.ok(!result.includes(' '), 'spaces must be replaced'); }); test('null bytes are replaced', () => { const result = sanitizeUploadFilename('file\x00name.md'); assert.ok(!result.includes('\x00'), 'null bytes must be sanitized'); }); test('empty string falls back to "upload"', () => { assert.equal(sanitizeUploadFilename(''), 'upload'); }); test('null/undefined falls back to "upload"', () => { assert.equal(sanitizeUploadFilename(null), 'upload'); assert.equal(sanitizeUploadFilename(undefined), 'upload'); }); test('filename longer than 200 chars is truncated', () => { const longName = 'a'.repeat(300) + '.zip'; const result = sanitizeUploadFilename(longName); assert.ok(result.length <= 200, `result must be <= 200 chars, got ${result.length}`); }); test('Windows-style backslash path traversal is handled', () => { // path.basename on POSIX treats the whole thing as one component if no / // but we should still sanitize any remaining backslashes const result = sanitizeUploadFilename('..\\..\\etc\\passwd'); assert.ok(!result.includes('\\'), 'backslashes must be replaced'); assert.ok(!result.includes('/'), 'must not contain forward slashes'); }); test('filename with dots and valid extension is accepted unchanged', () => { assert.equal(sanitizeUploadFilename('vault-backup.2026-04-09.zip'), 'vault-backup.2026-04-09.zip'); }); test('unicode characters are replaced with underscores', () => { const result = sanitizeUploadFilename('résumé-français.pdf'); assert.ok(!/[^\x00-\x7F]/.test(result), 'non-ASCII characters must be replaced'); }); test('safe zip filename passes through without changes', () => { assert.equal(sanitizeUploadFilename('my-export.zip'), 'my-export.zip'); }); }); // --------------------------------------------------------------------------- // 2.6 (continued) multer upgrade — package.json reflects multer@2 // --------------------------------------------------------------------------- describe('2.6 multer@2 upgrade — package.json uses multer@^2', () => { test('root package.json declares multer@^2', () => { const pkgPath = path.join(ROOT, 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); const multerVersion = (pkg.dependencies || {}).multer || (pkg.devDependencies || {}).multer; assert.ok(multerVersion, 'multer must be listed as a dependency'); assert.ok( multerVersion.startsWith('^2') || multerVersion.startsWith('2'), `multer must be version 2.x, found: ${multerVersion}` ); }); });