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