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:
Luca Sacchi Ricciardi
2026-04-02 19:01:40 +02:00
parent 9de40fde2d
commit 3c406ef405
6 changed files with 1427 additions and 0 deletions

View 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"
]
}