This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = process.cwd()
|
||||
|
||||
function sourceFiles(relativeRoot: string): string[] {
|
||||
const absoluteRoot = path.join(repositoryRoot, relativeRoot)
|
||||
return readdirSync(absoluteRoot, { recursive: true, withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/\.(?:ts|tsx)$/u.test(entry.name) &&
|
||||
!entry.name.endsWith('.test.ts'),
|
||||
)
|
||||
.map((entry) => path.join(entry.parentPath, entry.name))
|
||||
}
|
||||
|
||||
function violations(files: readonly string[], forbidden: RegExp) {
|
||||
return files
|
||||
.filter((file) => forbidden.test(readFileSync(file, 'utf8')))
|
||||
.map((file) => path.relative(repositoryRoot, file).replaceAll('\\', '/'))
|
||||
}
|
||||
|
||||
describe('architectural dependency boundaries', () => {
|
||||
it('keeps persistence adapters out of Next.js route handlers and UI', () => {
|
||||
const routes = sourceFiles('apps/web/src/app').filter((file) =>
|
||||
file.endsWith(`${path.sep}route.ts`),
|
||||
)
|
||||
const ui = sourceFiles('packages/ui/src')
|
||||
const forbidden = /(?:@devrunbook\/db|drizzle-orm)/u
|
||||
|
||||
expect(violations(routes, forbidden)).toEqual([])
|
||||
expect(violations(ui, forbidden)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps domain and application packages independent of framework and persistence code', () => {
|
||||
expect(
|
||||
violations(
|
||||
sourceFiles('packages/domain/src'),
|
||||
/(?:@devrunbook\/(?:application|db)|drizzle-orm|from ['"]next(?:\/|['"]))/u,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
violations(
|
||||
sourceFiles('packages/application/src'),
|
||||
/(?:@devrunbook\/db|drizzle-orm|from ['"]next(?:\/|['"]))/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('prevents content and composer code from executing imported instructions', () => {
|
||||
const files = [
|
||||
...sourceFiles('packages/content/src'),
|
||||
...sourceFiles('packages/composer/src'),
|
||||
]
|
||||
expect(
|
||||
violations(
|
||||
files,
|
||||
/(?:node:child_process|child_process|Bun\.spawn|Deno\.Command)/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps operator commands attached to application use cases', () => {
|
||||
const commands = sourceFiles('apps/worker/src/operator')
|
||||
for (const command of commands) {
|
||||
const source = readFileSync(command, 'utf8')
|
||||
expect(source, path.relative(repositoryRoot, command)).toContain(
|
||||
'@devrunbook/application',
|
||||
)
|
||||
expect(source, path.relative(repositoryRoot, command)).not.toMatch(
|
||||
/(?:getSqlClient|\.execute\(|sql`)/u,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps worker orchestration downstream and job handlers adapter-free', () => {
|
||||
const upstream = [
|
||||
...sourceFiles('packages/domain/src'),
|
||||
...sourceFiles('packages/application/src'),
|
||||
...sourceFiles('packages/db/src'),
|
||||
...sourceFiles('apps/web/src'),
|
||||
]
|
||||
expect(
|
||||
violations(upstream, /(?:@devrunbook\/worker|apps\/worker)/u),
|
||||
).toEqual([])
|
||||
|
||||
const handlers = sourceFiles('apps/worker/src/jobs').filter((file) =>
|
||||
file.endsWith(`${path.sep}handlers.ts`),
|
||||
)
|
||||
expect(
|
||||
violations(
|
||||
handlers,
|
||||
/(?:@devrunbook\/db|drizzle-orm|getSqlClient|node:child_process|sql`)/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
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]')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user