254 lines
7.8 KiB
TypeScript
254 lines
7.8 KiB
TypeScript
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import type {
|
|
AuthPersistence,
|
|
AuthSessionRecord,
|
|
AuthUserRecord,
|
|
CreateAuthSessionRecord,
|
|
} from '../../packages/application/src/auth/auth-service'
|
|
import { AuthService } from '../../packages/application/src/auth/auth-service'
|
|
import { TokenDigester } from '../../packages/application/src/auth/token-digest'
|
|
import { loadPlaybookPackage } from '../../packages/content/src/loader'
|
|
import { createLogger } from '../../packages/observability/src/index'
|
|
import { handleAuthRequest } from '../../apps/web/src/auth/csrf'
|
|
import {
|
|
assertNonSecretConfiguration,
|
|
authorizeBootstrap,
|
|
} from '../../apps/web/src/setup/setup-policy'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, {
|
|
recursive: true,
|
|
force: true,
|
|
}),
|
|
),
|
|
)
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
async function maliciousPackage(): Promise<string> {
|
|
const directory = await mkdtemp(
|
|
path.join(tmpdir(), 'devrunbook-security-content-'),
|
|
)
|
|
temporaryDirectories.push(directory)
|
|
await cp(path.resolve('content/playbooks/root-cause-bugfix'), directory, {
|
|
recursive: true,
|
|
})
|
|
const manifestPath = path.join(directory, 'playbook.yaml')
|
|
const manifest = (await readFile(manifestPath, 'utf8')).replace(
|
|
' sensitive: false\n includeInOutput: true',
|
|
' sensitive: true\n includeInOutput: true',
|
|
)
|
|
await writeFile(manifestPath, manifest)
|
|
return directory
|
|
}
|
|
|
|
class CapturingAuthPersistence implements AuthPersistence {
|
|
readonly user: AuthUserRecord = {
|
|
id: 'user-1',
|
|
email: 'owner@example.test',
|
|
displayName: 'Owner',
|
|
passwordHash: '[password-hash]',
|
|
emailVerified: true,
|
|
image: null,
|
|
status: 'active',
|
|
createdAt: new Date('2026-07-27T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-07-27T00:00:00.000Z'),
|
|
}
|
|
|
|
sessionInput: CreateAuthSessionRecord | undefined
|
|
|
|
async findUserById(id: string) {
|
|
return id === this.user.id ? this.user : null
|
|
}
|
|
|
|
async findUserByEmail(email: string) {
|
|
return email === this.user.email ? this.user : null
|
|
}
|
|
|
|
async updateUser() {
|
|
return this.user
|
|
}
|
|
|
|
async createSession(input: CreateAuthSessionRecord) {
|
|
this.sessionInput = input
|
|
return {
|
|
id: 'session-1',
|
|
revokedAt: null,
|
|
...input,
|
|
}
|
|
}
|
|
|
|
async findSessionByTokenHash(): Promise<AuthSessionRecord | null> {
|
|
return null
|
|
}
|
|
|
|
async touchSession(): Promise<AuthSessionRecord | null> {
|
|
return null
|
|
}
|
|
|
|
async revokeSessionByTokenHash() {
|
|
return false
|
|
}
|
|
|
|
async revokeSessionsForUser() {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
describe('production security boundaries', () => {
|
|
it('uses the database workspace migration runner copied into the image', async () => {
|
|
const dockerfile = await readFile(path.resolve('Dockerfile'), 'utf8')
|
|
|
|
expect(dockerfile).toContain(
|
|
'CMD ["./packages/db/node_modules/.bin/tsx", "packages/db/src/migrate.ts"]',
|
|
)
|
|
expect(dockerfile).not.toContain(
|
|
'CMD ["./node_modules/.bin/tsx", "packages/db/src/migrate.ts"]',
|
|
)
|
|
})
|
|
|
|
it('ships the worker as a standalone bundle without production node_modules', async () => {
|
|
const dockerfile = await readFile(path.resolve('Dockerfile'), 'utf8')
|
|
const workerPackage = JSON.parse(
|
|
await readFile(path.resolve('apps/worker/package.json'), 'utf8'),
|
|
) as { scripts: { build: string } }
|
|
|
|
expect(workerPackage.scripts.build).toContain(
|
|
'esbuild src/index.ts src/operator/password-reset.ts',
|
|
)
|
|
expect(workerPackage.scripts.build).toContain('--bundle --platform=node')
|
|
expect(workerPackage.scripts.build).toContain(
|
|
'const require = createRequire(import.meta.url)',
|
|
)
|
|
expect(dockerfile).toContain(
|
|
'cp -a /app/apps/worker/dist/. /out/worker/dist/',
|
|
)
|
|
expect(dockerfile).not.toContain(
|
|
'pnpm --filter @devrunbook/worker --prod deploy',
|
|
)
|
|
})
|
|
|
|
it('rejects secret-bearing package output and nested setup configuration', async () => {
|
|
await expect(loadPlaybookPackage(await maliciousPackage())).rejects.toThrow(
|
|
'sensitive input problemStatement cannot be included in output',
|
|
)
|
|
|
|
expect(() =>
|
|
assertNonSecretConfiguration({
|
|
integration: { apiToken: 'must-never-be-persisted' },
|
|
}),
|
|
).toThrow(
|
|
'configuration.integration.apiToken must not contain secret material',
|
|
)
|
|
expect(() =>
|
|
assertNonSecretConfiguration({
|
|
integrationEncryptionKeyVersion: 'v2',
|
|
}),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('persists only a peppered HMAC session digest', async () => {
|
|
const rawToken = 'raw-session-token-with-high-entropy-0123456789'
|
|
const persistence = new CapturingAuthPersistence()
|
|
const digester = new TokenDigester(Buffer.alloc(32, 7))
|
|
const service = new AuthService(
|
|
persistence,
|
|
digester,
|
|
() => new Date('2026-07-27T12:00:00.000Z'),
|
|
)
|
|
|
|
const active = await service.createSession({
|
|
userId: persistence.user.id,
|
|
rawToken,
|
|
})
|
|
|
|
expect(active?.rawToken).toBe(rawToken)
|
|
expect(JSON.stringify(persistence.sessionInput)).not.toContain(rawToken)
|
|
expect(persistence.sessionInput?.tokenHash).toMatch(
|
|
/^hmac-sha256:v1:[a-f0-9]{64}$/u,
|
|
)
|
|
expect(
|
|
digester.matches(rawToken, persistence.sessionInput!.tokenHash),
|
|
).toBe(true)
|
|
expect(new TokenDigester(Buffer.alloc(32, 8)).digest(rawToken)).not.toBe(
|
|
persistence.sessionInput?.tokenHash,
|
|
)
|
|
})
|
|
|
|
it('denies proxy bootstrap bypass and cross-origin credential requests without reflection', async () => {
|
|
const proxied = new Request('http://127.0.0.1/api/v1/instance/setup', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '127.0.0.1' },
|
|
})
|
|
expect(authorizeBootstrap(proxied, '', undefined)).toBe(false)
|
|
expect(
|
|
authorizeBootstrap(proxied, 'attacker-token', 'operator-token'),
|
|
).toBe(false)
|
|
|
|
const handler = vi.fn(async () => new Response('should not run'))
|
|
const rawCookie = 'devrunbook.session=raw-session-secret'
|
|
const request = new Request(
|
|
'https://runbook.example.test/api/auth/session',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
origin: 'https://attacker.example.test',
|
|
'sec-fetch-site': 'cross-site',
|
|
cookie: rawCookie,
|
|
},
|
|
},
|
|
)
|
|
const response = await handleAuthRequest(
|
|
request,
|
|
handler,
|
|
'https://runbook.example.test',
|
|
)
|
|
const body = await response.text()
|
|
|
|
expect(response.status).toBe(403)
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(body).not.toContain('attacker.example.test')
|
|
expect(body).not.toContain(rawCookie)
|
|
expect(body).toContain('INVALID_ORIGIN')
|
|
})
|
|
|
|
it('redacts root and nested secret fields from structured logs', async () => {
|
|
let output = ''
|
|
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
output += String(chunk)
|
|
return true
|
|
})
|
|
const logger = createLogger('info')
|
|
|
|
logger.info(
|
|
{
|
|
password: 'root-password-secret',
|
|
token: 'root-token-secret',
|
|
req: {
|
|
headers: {
|
|
authorization: 'Bearer raw-authorization-secret',
|
|
cookie: 'session=raw-cookie-secret',
|
|
},
|
|
},
|
|
account: { secret: 'nested-account-secret' },
|
|
},
|
|
'security redaction probe',
|
|
)
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
|
|
expect(output).not.toContain('root-password-secret')
|
|
expect(output).not.toContain('root-token-secret')
|
|
expect(output).not.toContain('raw-authorization-secret')
|
|
expect(output).not.toContain('raw-cookie-secret')
|
|
expect(output).not.toContain('nested-account-secret')
|
|
expect(output).toContain('[REDACTED]')
|
|
})
|
|
})
|