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
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:
@@ -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'));
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
37
products/06-runbook-automation/saas/src/parsers/index.ts
Normal file
37
products/06-runbook-automation/saas/src/parsers/index.ts
Normal 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';
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user