feat(run): add runbook parser, safety classifier, audit hash chain, trust levels
Some checks failed
CI — P6 Run / saas (push) Successful in 29s
CI — P6 Run / build-push (push) Failing after 50s

- 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:
Max
2026-03-03 06:48:56 +00:00
parent 093890503c
commit ef3d00f124
4 changed files with 119 additions and 9 deletions

View File

@@ -150,4 +150,58 @@ export function registerRunbookRoutes(app: FastifyInstance) {
if (!result.execution) return reply.status(404).send({ error: 'Execution not found' }); if (!result.execution) return reply.status(404).send({ error: 'Execution not found' });
return result; 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');
});
} }

View File

@@ -1,5 +1,5 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { PoolClient } from 'pg'; import type { PoolClient } from 'pg';
export interface AuditEntryData { export interface AuditEntryData {
execution_id: string; execution_id: string;
@@ -15,15 +15,15 @@ export interface AuditEntryData {
duration_ms?: number; 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 ?? ''}`; const data = `${prevHash}:${entry.execution_id}:${entry.step_index}:${entry.command}:${entry.status}:${entry.exit_code ?? ''}`;
return createHash('sha256').update(data).digest('hex'); return createHash('sha256').update(data).digest('hex');
} }
export async function appendAuditEntry(client: PoolClient, tenantId: string, entry: AuditEntryData): Promise<string> { export async function appendAuditEntry(client: PoolClient, tenantId: string, entry: AuditEntryData): Promise<string> {
// Find the previous hash. Order by started_at DESC, id DESC. const INITIAL_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
// 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. // Find the previous entry for this runbook's executions
const prevResult = await client.query( const prevResult = await client.query(
`SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code `SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code
FROM audit_entries a FROM audit_entries a
@@ -33,10 +33,10 @@ export async function appendAuditEntry(client: PoolClient, tenantId: string, ent
[entry.execution_id] [entry.execution_id]
); );
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000'; let prevHash = INITIAL_HASH;
if (prevResult.rows.length > 0) { if (prevResult.rows.length > 0) {
const lastRow = prevResult.rows[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( 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 }> { 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( const result = await client.query(
`SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code `SELECT a.id, a.prev_hash, a.execution_id, a.step_index, a.command, a.status, a.exit_code
FROM audit_entries a FROM audit_entries a
@@ -65,7 +66,7 @@ export async function verifyChain(client: PoolClient, runbookId: string): Promis
[runbookId] [runbookId]
); );
let expectedPrevHash = '0000000000000000000000000000000000000000000000000000000000000000'; let expectedPrevHash = INITIAL_HASH;
for (let i = 0; i < result.rows.length; i++) { for (let i = 0; i < result.rows.length; i++) {
const row = result.rows[i]; const row = result.rows[i];

View File

@@ -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' };
}