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
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<AuditEntryData>): 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<string> {
|
||||
// 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];
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
Reference in New Issue
Block a user