feat(cost): add zombie hunter, Slack interactions, composite scoring
Some checks failed
CI — P3 Alert / test (push) Successful in 28s
CI — P5 Cost / test (push) Successful in 42s
CI — P6 Run / saas (push) Successful in 41s
CI — P6 Run / build-push (push) Has been cancelled
CI — P3 Alert / build-push (push) Failing after 53s
CI — P5 Cost / build-push (push) Failing after 5s

- Zombie resource hunter: detects idle EC2/RDS/EBS/EIP/NAT resources
- Slack interactive handler: acknowledge, snooze, create-ticket actions
- Composite anomaly scorer: Z-Score + rate-of-change + pattern + novelty
- Cold-start fast path for new resources (<7 days data)
- 005_zombies.sql migration
This commit is contained in:
Max
2026-03-03 06:39:20 +00:00
parent cfe269a031
commit f1f4dee7ab
26 changed files with 1393 additions and 18 deletions

View File

@@ -0,0 +1,38 @@
-- 005_classifier_audit.sql
CREATE TABLE IF NOT EXISTS runbook_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
runbook_id UUID NOT NULL REFERENCES runbooks(id) ON DELETE CASCADE,
step_index INT NOT NULL,
name TEXT NOT NULL,
description TEXT,
command TEXT,
expected_output TEXT,
timeout_seconds INT DEFAULT 300,
requires_approval BOOLEAN DEFAULT false,
risk_level TEXT DEFAULT 'low' CHECK (risk_level IN ('low', 'medium', 'high', 'critical')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(runbook_id, step_index)
);
ALTER TABLE runbook_steps ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'runbook_steps' AND policyname = 'tenant_iso_runbook_steps'
) THEN
CREATE POLICY tenant_iso_runbook_steps ON runbook_steps
FOR ALL
USING (tenant_id::text = current_setting('app.tenant_id', true));
END IF;
END $$;
ALTER TABLE runbook_steps ADD COLUMN IF NOT EXISTS risk_level TEXT DEFAULT 'low' CHECK (risk_level IN ('low', 'medium', 'high', 'critical'));
ALTER TABLE audit_entries ADD COLUMN IF NOT EXISTS prev_hash TEXT;
ALTER TABLE runbooks ADD COLUMN IF NOT EXISTS trust_level TEXT DEFAULT 'standard' CHECK (trust_level IN ('sandbox', 'restricted', 'standard', 'elevated'));
ALTER TABLE runbooks ADD COLUMN IF NOT EXISTS source_format TEXT DEFAULT 'yaml' CHECK (source_format IN ('yaml', 'markdown', 'confluence'));

View File

@@ -16,6 +16,7 @@
"@slack/web-api": "^7.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0",
@@ -23,6 +24,7 @@
"zod": "^3.23.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.0",
@@ -1603,6 +1605,13 @@
"@types/node": "*"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1982,7 +1991,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-buffer-byte-length": {
@@ -4340,7 +4348,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"

View File

@@ -19,6 +19,7 @@
"@slack/web-api": "^7.1.0",
"fastify": "^4.28.0",
"ioredis": "^5.4.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.1.0",
@@ -26,6 +27,7 @@
"zod": "^3.23.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.0",

View File

@@ -0,0 +1,6 @@
import type { RunbookStep } from '../parsers/index.js';
import { classifyStep } from './safety-scanner.js';
export function classifyRunbook(steps: RunbookStep[]): RunbookStep[] {
return steps.map(classifyStep);
}

View File

@@ -0,0 +1,49 @@
import type { RunbookStep } from '../parsers/index.js';
export function classifyStep(step: RunbookStep): RunbookStep {
if (!step.command) {
return { ...step, risk_level: 'low', requires_approval: false };
}
const cmd = step.command.toLowerCase();
// Critical
if (
cmd.includes('rm -rf') ||
cmd.includes('drop table') ||
cmd.includes('delete from') ||
cmd.includes('shutdown') ||
cmd.includes('reboot') ||
cmd.includes('kill -9') ||
cmd.includes('iptables -f')
) {
return { ...step, risk_level: 'critical', requires_approval: true };
}
// High (Privilege escalation & Network)
if (
cmd.includes('sudo') ||
cmd.includes('chmod 777') ||
cmd.includes('chown root') ||
cmd.includes('iptables') ||
cmd.includes('route add') ||
cmd.includes('route del') ||
cmd.includes('/etc/resolv.conf')
) {
return { ...step, risk_level: 'high', requires_approval: true };
}
// Medium (Modifying config, restarting services)
if (
cmd.includes('systemctl restart') ||
cmd.includes('service restart') ||
cmd.includes('sed -i') ||
cmd.includes('mv ') ||
cmd.includes('cp ')
) {
return { ...step, risk_level: 'medium', requires_approval: true };
}
// Default to low
return { ...step, risk_level: 'low', requires_approval: step.requires_approval || false };
}

View File

@@ -0,0 +1,80 @@
import type { RunbookStep } from './index.js';
export function parseConfluenceRunbook(html: string): RunbookStep[] {
const steps: RunbookStep[] = [];
// Try table parsing first
// Very simplistic HTML table extraction for Node without DOMparser
// We look for <tr> with <td> elements.
const rowRegex = /<tr[^>]*>(.*?)<\/tr>/gis;
const colRegex = /<td[^>]*>(.*?)<\/td>/gis;
let match;
let order = 1;
while ((match = rowRegex.exec(html)) !== null) {
const rowHtml = match[1];
const cols: string[] = [];
let colMatch;
// reset regex index
const colRegexClone = new RegExp(colRegex);
while ((colMatch = colRegexClone.exec(rowHtml)) !== null) {
// strip inner HTML tags
cols.push(colMatch[1].replace(/<[^>]*>/g, '').trim());
}
if (cols.length >= 2) {
// Assume Column 1: Step Name/Description, Column 2: Action/Command, Column 3: Expected
const nameDesc = cols[0];
const command = cols[1];
const expected = cols[2] || '';
// Skip headers
if (nameDesc.toLowerCase().includes('step') && command.toLowerCase().includes('action')) {
continue;
}
if (!command) continue;
steps.push({
order: order++,
name: nameDesc.split('\n')[0].substring(0, 50) || `Step ${order}`,
description: nameDesc,
command: command,
expected_output: expected,
timeout_seconds: 300,
requires_approval: false,
risk_level: 'low'
});
}
}
if (steps.length > 0) return steps;
// Fallback: Numbered procedure lists
// Search for <ol> ... </ol> and extract <li>
const olRegex = /<ol[^>]*>(.*?)<\/ol>/gis;
const liRegex = /<li[^>]*>(.*?)<\/li>/gis;
let olMatch;
while ((olMatch = olRegex.exec(html)) !== null) {
let liMatch;
const liRegexClone = new RegExp(liRegex);
while ((liMatch = liRegexClone.exec(olMatch[1])) !== null) {
const text = liMatch[1].replace(/<[^>]*>/g, '').trim();
// Attempt to extract command, e.g. from <code> tags if we kept them, but we stripped them.
// We'll just put the text as description and name.
steps.push({
order: order++,
name: text.substring(0, 50) + '...',
description: text,
command: '', // Cannot easily reliably extract command from plain text without markers
timeout_seconds: 300,
requires_approval: false,
risk_level: 'low'
});
}
}
return steps;
}

View File

@@ -0,0 +1,37 @@
export interface RunbookStep {
order: number;
name: string;
description: string;
command?: string;
expected_output?: string;
timeout_seconds: number;
requires_approval: boolean;
risk_level: 'low' | 'medium' | 'high' | 'critical';
}
import { parseYamlRunbook } from './yaml-parser.js';
import { parseMarkdownRunbook } from './markdown-parser.js';
import { parseConfluenceRunbook } from './confluence-parser.js';
export function parseRunbook(content: string, format: 'yaml' | 'markdown' | 'confluence'): RunbookStep[] {
switch (format) {
case 'yaml':
return parseYamlRunbook(content);
case 'markdown':
return parseMarkdownRunbook(content);
case 'confluence':
return parseConfluenceRunbook(content);
default:
throw new Error(`Unsupported runbook format: ${format}`);
}
}
export function detectFormat(content: string): 'yaml' | 'markdown' | 'confluence' {
if (content.includes('<!DOCTYPE html>') || content.includes('<table class="confluenceTable">') || content.includes('<div id="main-content"')) {
return 'confluence';
}
if (content.trim().startsWith('#') || content.includes('```bash') || content.includes('```sh') || content.match(/^\d+\.\s+/m)) {
return 'markdown';
}
return 'yaml';
}

View File

@@ -0,0 +1,81 @@
import type { RunbookStep } from './index.js';
export function parseMarkdownRunbook(content: string): RunbookStep[] {
const steps: RunbookStep[] = [];
const lines = content.split('\n');
let currentStep: Partial<RunbookStep> | null = null;
let inCodeBlock = false;
let codeBuffer: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for code block toggle
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
if (!inCodeBlock && currentStep) {
// Just closed a code block
if (!currentStep.command) {
currentStep.command = codeBuffer.join('\n').trim();
} else {
// If we already have a command, maybe this is expected output?
currentStep.expected_output = codeBuffer.join('\n').trim();
}
codeBuffer = [];
} else if (inCodeBlock) {
// Just opened a code block, reset buffer
codeBuffer = [];
}
continue;
}
if (inCodeBlock) {
codeBuffer.push(line);
continue;
}
// Check for numbered list (e.g. "1. Do something")
const stepMatch = line.match(/^(\d+)\.\s+(.*)$/);
if (stepMatch) {
// If we have an existing step, save it
if (currentStep) {
steps.push(finalizeStep(currentStep, steps.length + 1));
}
currentStep = {
name: stepMatch[2].trim(),
description: '',
};
continue;
}
// Accumulate description
if (currentStep && line.trim() && !line.trim().startsWith('#')) {
if (currentStep.description) {
currentStep.description += '\n' + line.trim();
} else {
currentStep.description = line.trim();
}
}
}
if (currentStep) {
steps.push(finalizeStep(currentStep, steps.length + 1));
}
return steps;
}
function finalizeStep(step: Partial<RunbookStep>, index: number): RunbookStep {
return {
order: index,
name: step.name || `Step ${index}`,
description: step.description || '',
command: step.command,
expected_output: step.expected_output,
timeout_seconds: 300,
requires_approval: false,
risk_level: 'low'
};
}

View File

@@ -0,0 +1,24 @@
import yaml from 'js-yaml';
import type { RunbookStep } from './index.js';
export function parseYamlRunbook(content: string): RunbookStep[] {
const parsed = yaml.load(content) as any;
const stepsData = Array.isArray(parsed) ? parsed : (parsed?.steps || []);
if (!Array.isArray(stepsData)) {
throw new Error('YAML runbook must be an array or contain a "steps" array');
}
return stepsData.map((step: any, index: number): RunbookStep => {
return {
order: index + 1,
name: step.name || `Step ${index + 1}`,
description: step.description || '',
command: step.command,
expected_output: step.expected_output,
timeout_seconds: step.timeout_seconds || 300,
requires_approval: step.requires_approval === true,
risk_level: step.risk_level || 'low'
};
});
}