feat: create n8n workflow for secure log ingestion
Implement LogWhisperer_Ingest workflow for Sprint 2 Feature 2: Workflow Components: - Webhook trigger: POST /webhook/logwhisperer/ingest - HMAC-SHA256 validation with timing-safe comparison - Anti-replay protection (5min timestamp window) - Data validation: UUID client_id, severity levels, non-empty logs - PostgreSQL storage with logs table auto-creation - Conditional routing for critical severity logs Security Features: - HMAC signature verification (X-LogWhisperer-Signature header) - Timestamp validation preventing replay attacks - Input sanitization before DB insert - Environment variable LOGWHISPERER_SECRET for shared secret Documentation: - workflows/logwhisperer_ingest.json: Export JSON workflow - workflows/README.md: Installation and usage guide - workflows/INTEGRATION.md: Bash script integration guide - workflows/REPORT.md: Implementation report - workflows/test_workflow.sh: Automated test suite Metodo Sacchi Applied: - Safety First: HMAC validation before any processing - Little Often: Modular nodes, each with single responsibility - Double Check: Test suite validates all security requirements Next Steps: - Configure LOGWHISPERER_SECRET in n8n environment - Import workflow to n8n instance - Test end-to-end with secure_logwhisperer.sh
This commit is contained in:
355
workflows/logwhisperer_ingest.json
Normal file
355
workflows/logwhisperer_ingest.json
Normal file
@@ -0,0 +1,355 @@
|
||||
{
|
||||
"name": "LogWhisperer_Ingest",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "trigger-node",
|
||||
"name": "Webhook Trigger",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
250,
|
||||
300
|
||||
],
|
||||
"webhookId": "logwhisperer-ingest",
|
||||
"path": "logwhisperer/ingest",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// HMAC Validation Node\n// Verifica la firma HMAC-SHA256 secondo il Metodo Sacchi: Safety First\n\nconst crypto = require('crypto');\n\n// Recupera headers\nconst signatureHeader = $headers['x-logwhisperer-signature'];\nconst timestampHeader = $headers['x-logwhisperer-timestamp'];\n\n// Recupera secret da variabile ambiente\nconst secret = process.env.LOGWHISPERER_SECRET;\n\nif (!secret) {\n throw new Error('LOGWHISPERER_SECRET not configured');\n}\n\nif (!signatureHeader || !timestampHeader) {\n return [{\n json: {\n valid: false,\n error: 'Missing authentication headers',\n statusCode: 401\n }\n }];\n}\n\n// Estrai timestamp e signature dal formato: timestamp:signature\nconst parts = signatureHeader.split(':');\nif (parts.length !== 2) {\n return [{\n json: {\n valid: false,\n error: 'Invalid signature format',\n statusCode: 401\n }\n }];\n}\n\nconst receivedTimestamp = parts[0];\nconst receivedSignature = parts[1];\n\n// Verifica timestamp (anti-replay: max 5 minuti di differenza)\nconst now = Math.floor(Date.now() / 1000);\nconst requestTime = parseInt(timestampHeader, 10);\nconst timeDiff = Math.abs(now - requestTime);\n\nif (timeDiff > 300) {\n return [{\n json: {\n valid: false,\n error: 'Request timestamp too old',\n statusCode: 401\n }\n }];\n}\n\n// Ottieni il payload raw\nconst payload = JSON.stringify($input.first().json);\n\n// Calcola HMAC atteso: HMAC-SHA256(timestamp:payload)\nconst expectedSignature = crypto\n .createHmac('sha256', secret)\n .update(`${timestampHeader}:${payload}`)\n .digest('hex');\n\n// Comparazione timing-safe\nconst isValid = crypto.timingSafeEqual(\n Buffer.from(receivedSignature, 'hex'),\n Buffer.from(expectedSignature, 'hex')\n);\n\nif (!isValid) {\n return [{\n json: {\n valid: false,\n error: 'Invalid signature',\n statusCode: 401\n }\n }];\n}\n\n// Validazione HMAC passata - restituisci payload per il prossimo nodo\nreturn [{\n json: {\n valid: true,\n data: $input.first().json\n }\n}];"
|
||||
},
|
||||
"id": "hmac-validation-node",
|
||||
"name": "HMAC Validation",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
450,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict"
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "condition-1",
|
||||
"leftValue": "={{ $json.valid }}",
|
||||
"rightValue": "true",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "equals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
}
|
||||
},
|
||||
"id": "hmac-check-node",
|
||||
"name": "HMAC Valid?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
650,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Data Validation Node\n// Validazione campi obbligatori secondo il Metodo Sacchi: Double Check\n\nconst data = $input.first().json.data;\nconst errors = [];\n\n// Validazione UUID per client_id\nfunction isValidUUID(uuid) {\n const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;\n return uuidRegex.test(uuid);\n}\n\n// Validazione campi obbligatori\nif (!data.client_id) {\n errors.push('Missing client_id');\n} else if (!isValidUUID(data.client_id)) {\n errors.push('Invalid client_id format (must be UUID)');\n}\n\nif (!data.raw_log || data.raw_log.trim() === '') {\n errors.push('Missing or empty raw_log');\n}\n\nconst validSeverities = ['low', 'medium', 'critical'];\nif (!data.severity) {\n errors.push('Missing severity');\n} else if (!validSeverities.includes(data.severity.toLowerCase())) {\n errors.push(`Invalid severity: ${data.severity} (must be one of: ${validSeverities.join(', ')})`);\n}\n\nif (errors.length > 0) {\n return [{\n json: {\n valid: false,\n errors: errors,\n received: data\n }\n }];\n}\n\n// Normalizza severity a lowercase\nconst normalizedData = {\n ...data,\n severity: data.severity.toLowerCase()\n};\n\nreturn [{\n json: {\n valid: true,\n data: normalizedData\n }\n}];"
|
||||
},
|
||||
"id": "data-validation-node",
|
||||
"name": "Data Validation",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
850,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "INSERT INTO logs (client_id, hostname, source, severity, timestamp, raw_log, matched_pattern)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nRETURNING id;",
|
||||
"options": {
|
||||
"queryParams": "={{ JSON.stringify([$json.data.client_id, $json.data.hostname, $json.data.source, $json.data.severity, $json.data.timestamp, $json.data.raw_log, $json.data.matched_pattern]) }}"
|
||||
}
|
||||
},
|
||||
"id": "postgres-insert-node",
|
||||
"name": "Store Log",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
1050,
|
||||
200
|
||||
],
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "postgres-credentials",
|
||||
"name": "PostgreSQL LogWhisperer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": false,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict"
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "condition-1",
|
||||
"leftValue": "={{ $json.data.severity }}",
|
||||
"rightValue": "critical",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "or"
|
||||
}
|
||||
},
|
||||
"id": "severity-check-node",
|
||||
"name": "Critical Severity?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1250,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// AI Processing Placeholder\n// Per Sprint 2 Feature 2: prepara i dati per l'elaborazione AI\n// Il nodo vero e proprio sarà implementato nello Sprint 3\n\nconst logData = $input.first().json;\n\n// Log di sicurezza: non esporre raw_log completo nei log\nconsole.log('AI Processing requested for log ID:', logData.id);\nconsole.log('Client:', logData.data.client_id);\nconsole.log('Severity:', logData.data.severity);\nconsole.log('Pattern:', logData.data.matched_pattern);\n\nreturn [{\n json: {\n status: 'queued_for_ai_processing',\n log_id: logData.id,\n client_id: logData.data.client_id,\n severity: logData.data.severity,\n pattern: logData.data.matched_pattern,\n // raw_log escluso per sicurezza\n timestamp: new Date().toISOString()\n }\n}];"
|
||||
},
|
||||
"id": "ai-processing-node",
|
||||
"name": "AI Processing (Placeholder)",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1450,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"options": {}
|
||||
},
|
||||
"id": "success-response-node",
|
||||
"name": "Success Response",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1450,
|
||||
250
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={\"error\": \"Unauthorized\", \"message\": \"Invalid HMAC signature\"}",
|
||||
"options": {
|
||||
"responseCode": 401
|
||||
}
|
||||
},
|
||||
"id": "unauthorized-response-node",
|
||||
"name": "401 Unauthorized",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
850,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={\"error\": \"Bad Request\", \"message\": $json.errors.join(\", \")}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"id": "validation-error-node",
|
||||
"name": "400 Validation Error",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1050,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Ensure Table Exists\n// Crea la tabella logs se non esiste già\n\nreturn [{\n json: {\n sql: `\n CREATE TABLE IF NOT EXISTS logs (\n id SERIAL PRIMARY KEY,\n client_id VARCHAR(36) NOT NULL,\n hostname VARCHAR(255),\n source VARCHAR(500),\n severity VARCHAR(20) CHECK (severity IN ('low', 'medium', 'critical')),\n timestamp TIMESTAMP WITH TIME ZONE,\n raw_log TEXT,\n matched_pattern VARCHAR(100),\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n );\n \n CREATE INDEX IF NOT EXISTS idx_logs_client_id ON logs(client_id);\n CREATE INDEX IF NOT EXISTS idx_logs_severity ON logs(severity);\n CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);\n `\n }\n}];"
|
||||
},
|
||||
"id": "ensure-table-node",
|
||||
"name": "Ensure Table SQL",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
650,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "={{ $json.sql }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "create-table-node",
|
||||
"name": "Create Table",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
850,
|
||||
100
|
||||
],
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "postgres-credentials",
|
||||
"name": "PostgreSQL LogWhisperer"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HMAC Validation",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Ensure Table SQL",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"HMAC Validation": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HMAC Valid?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"HMAC Valid?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Data Validation",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "401 Unauthorized",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Data Validation": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Store Log",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "400 Validation Error",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Store Log": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Critical Severity?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Success Response",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Critical Severity?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI Processing (Placeholder)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Success Response",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Ensure Table SQL": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Create Table",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"tags": [
|
||||
{
|
||||
"name": "logwhisperer",
|
||||
"id": "tag-logwhisperer",
|
||||
"createdAt": "2026-04-02T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-02T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"name": "security",
|
||||
"id": "tag-security",
|
||||
"createdAt": "2026-04-02T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-02T00:00:00.000Z"
|
||||
},
|
||||
"ingestion"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user