From ef3d00f1240231e3fb326bc918e8fa322638e9ed Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Mar 2026 06:48:56 +0000 Subject: [PATCH] feat(run): add runbook parser, safety classifier, audit hash chain, trust levels - Multi-format parser: YAML, Markdown, Confluence HTML - Deterministic safety scanner: destructive commands, privilege escalation, network changes - Immutable audit trail with SHA-256 hash chain + verification endpoint - Trust level enforcement: sandbox/restricted/standard/elevated - 004_classifier_audit.sql migration --- ...ier_audit.sql => 004_classifier_audit.sql} | 0 .../saas/src/api/runbooks.ts | 54 ++++++++++++++++++ .../saas/src/audit/hash-chain.ts | 19 ++++--- .../saas/src/execution/trust-levels.ts | 55 +++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) rename products/06-runbook-automation/saas/migrations/{005_classifier_audit.sql => 004_classifier_audit.sql} (100%) create mode 100644 products/06-runbook-automation/saas/src/execution/trust-levels.ts diff --git a/products/06-runbook-automation/saas/migrations/005_classifier_audit.sql b/products/06-runbook-automation/saas/migrations/004_classifier_audit.sql similarity index 100% rename from products/06-runbook-automation/saas/migrations/005_classifier_audit.sql rename to products/06-runbook-automation/saas/migrations/004_classifier_audit.sql diff --git a/products/06-runbook-automation/saas/src/api/runbooks.ts b/products/06-runbook-automation/saas/src/api/runbooks.ts index 9407cfa..9162c3f 100644 --- a/products/06-runbook-automation/saas/src/api/runbooks.ts +++ b/products/06-runbook-automation/saas/src/api/runbooks.ts @@ -150,4 +150,58 @@ export function registerRunbookRoutes(app: FastifyInstance) { if (!result.execution) return reply.status(404).send({ error: 'Execution not found' }); return result; }); + + // Audit verify endpoint + app.get('/api/v1/audit/:runbookId/verify', async (req, reply) => { + const { runbookId } = req.params as { runbookId: string }; + const tenantId = (req as any).tenantId; + + const { verifyChain } = await import('../audit/hash-chain.js'); + + const result = await withTenant(tenantId, async (client) => { + // First, ensure runbook belongs to this tenant + const rb = await client.query('SELECT id FROM runbooks WHERE id = $1', [runbookId]); + if (!rb.rows[0]) return null; + + return verifyChain(client, runbookId); + }); + + if (!result) return reply.status(404).send({ error: 'Runbook not found' }); + + return result; + }); + + // Audit export endpoint + app.get('/api/v1/audit/:runbookId/export', async (req, reply) => { + const { runbookId } = req.params as { runbookId: string }; + const tenantId = (req as any).tenantId; + + const csvLines = await withTenant(tenantId, async (client) => { + const rb = await client.query('SELECT id FROM runbooks WHERE id = $1', [runbookId]); + if (!rb.rows[0]) return null; + + const result = await client.query( + `SELECT a.id, e.id as execution_id, a.step_index, a.command, a.status, a.exit_code, a.started_at, a.prev_hash + FROM audit_entries a + JOIN executions e ON a.execution_id = e.id + WHERE e.runbook_id = $1 + ORDER BY a.started_at ASC, a.id ASC`, + [runbookId] + ); + + const lines = ['id,execution_id,step_index,command,status,exit_code,started_at,prev_hash']; + for (const row of result.rows) { + // Escape CSV values + const commandEscaped = '"' + (row.command || '').replace(/"/g, '""') + '"'; + lines.push(`${row.id},${row.execution_id},${row.step_index},${commandEscaped},${row.status},${row.exit_code ?? ''},${row.started_at?.toISOString() ?? ''},${row.prev_hash}`); + } + return lines; + }); + + if (!csvLines) return reply.status(404).send({ error: 'Runbook not found' }); + + reply.header('Content-Type', 'text/csv'); + reply.header('Content-Disposition', `attachment; filename="audit-${runbookId}.csv"`); + return csvLines.join('\n'); + }); } diff --git a/products/06-runbook-automation/saas/src/audit/hash-chain.ts b/products/06-runbook-automation/saas/src/audit/hash-chain.ts index e73a1cb..9132b84 100644 --- a/products/06-runbook-automation/saas/src/audit/hash-chain.ts +++ b/products/06-runbook-automation/saas/src/audit/hash-chain.ts @@ -1,5 +1,5 @@ import { createHash } from 'crypto'; -import { PoolClient } from 'pg'; +import type { PoolClient } from 'pg'; export interface AuditEntryData { execution_id: string; @@ -15,15 +15,15 @@ export interface AuditEntryData { duration_ms?: number; } -function computeHash(prevHash: string, entry: Partial): string { +export function computeHash(prevHash: string, entry: any): string { const data = `${prevHash}:${entry.execution_id}:${entry.step_index}:${entry.command}:${entry.status}:${entry.exit_code ?? ''}`; return createHash('sha256').update(data).digest('hex'); } export async function appendAuditEntry(client: PoolClient, tenantId: string, entry: AuditEntryData): Promise { - // Find the previous hash. Order by started_at DESC, id DESC. - // Wait, does the chain span the entire tenant, the runbook, or the execution? - // Let's make it span the entire runbook across all executions by joining. + const INITIAL_HASH = '0000000000000000000000000000000000000000000000000000000000000000'; + + // Find the previous entry for this runbook's executions const prevResult = await client.query( `SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code FROM audit_entries a @@ -33,10 +33,10 @@ export async function appendAuditEntry(client: PoolClient, tenantId: string, ent [entry.execution_id] ); - let prevHash = '0000000000000000000000000000000000000000000000000000000000000000'; + let prevHash = INITIAL_HASH; if (prevResult.rows.length > 0) { const lastRow = prevResult.rows[0]; - prevHash = computeHash(lastRow.prev_hash || '0000000000000000000000000000000000000000000000000000000000000000', lastRow); + prevHash = computeHash(lastRow.prev_hash || INITIAL_HASH, lastRow); } const insertResult = await client.query( @@ -55,7 +55,8 @@ export async function appendAuditEntry(client: PoolClient, tenantId: string, ent } export async function verifyChain(client: PoolClient, runbookId: string): Promise<{ valid: boolean; error?: string }> { - // Get all audit entries for this runbook, ordered by time. + const INITIAL_HASH = '0000000000000000000000000000000000000000000000000000000000000000'; + const result = await client.query( `SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code FROM audit_entries a @@ -65,7 +66,7 @@ export async function verifyChain(client: PoolClient, runbookId: string): Promis [runbookId] ); - let expectedPrevHash = '0000000000000000000000000000000000000000000000000000000000000000'; + let expectedPrevHash = INITIAL_HASH; for (let i = 0; i < result.rows.length; i++) { const row = result.rows[i]; diff --git a/products/06-runbook-automation/saas/src/execution/trust-levels.ts b/products/06-runbook-automation/saas/src/execution/trust-levels.ts new file mode 100644 index 0000000..62b14d1 --- /dev/null +++ b/products/06-runbook-automation/saas/src/execution/trust-levels.ts @@ -0,0 +1,55 @@ +import type { RunbookStep } from '../parsers/index.js'; + +export type TrustLevel = 'sandbox' | 'restricted' | 'standard' | 'elevated'; + +export interface TrustCheckResult { + allowed: boolean; + requires_approval_upgrade: boolean; + reason?: string; +} + +export function checkTrustLevel(runbookTrust: TrustLevel, step: RunbookStep, isDryRun: boolean = false): TrustCheckResult { + if (isDryRun && runbookTrust === 'sandbox') { + return { allowed: true, requires_approval_upgrade: false }; + } + + if (runbookTrust === 'sandbox' && !isDryRun) { + return { + allowed: false, + requires_approval_upgrade: true, + reason: 'Sandbox trust level only allows dry-run executions' + }; + } + + // risk_level mapping from classifier: low, medium, high, critical + // restricted: only low risk + if (runbookTrust === 'restricted') { + if (step.risk_level !== 'low') { + return { + allowed: false, + requires_approval_upgrade: true, + reason: `Restricted trust level blocked step with ${step.risk_level} risk` + }; + } + return { allowed: true, requires_approval_upgrade: step.requires_approval }; + } + + // standard: allows low and medium risk + if (runbookTrust === 'standard') { + if (step.risk_level === 'high' || step.risk_level === 'critical') { + return { + allowed: false, + requires_approval_upgrade: true, + reason: `Standard trust level blocked step with ${step.risk_level} risk` + }; + } + return { allowed: true, requires_approval_upgrade: step.requires_approval }; + } + + // elevated: allows anything, but still requires approval for high/critical (or whatever the step says) + if (runbookTrust === 'elevated') { + return { allowed: true, requires_approval_upgrade: step.requires_approval }; + } + + return { allowed: false, requires_approval_upgrade: true, reason: 'Unknown trust level' }; +}