Episode 11: a read-only agent that reads the security logs, one agent vs a crew of three

New: ops-readonly, the read-only twin of the episode 9 ops-actions runner
(fixed question list, three read-only mounts, no docker socket, read_only,
cap_drop ALL). Four role skills in agents/skills/, loaded at run time from
this repo's Forgejo. Two workflows: E (one agent, seven tools, one skill) and
F (three agents, one skill each, the workflow is the orchestrator).

Measured over 20 runs: no run of either variant produced all numbers and
lists correct. The solo agent confuses similar counts and skips a tool; the
crew calls every tool but drops fields at each role boundary. Sharpening the
skill files did not help the crew, it made it worse. Logs are attacker text:
an instruction in a request path is carried into the report as data, not
obeyed, but carried.

Claude-Session: https://claude.ai/code/session_017iP581CYtPfxdZYPPAyWZA
This commit is contained in:
Maximilian Grimm 2026-09-13 10:01:13 +02:00
parent bed41cfaf3
commit d147f21490
13 changed files with 2088 additions and 0 deletions

View file

@ -0,0 +1,852 @@
{
"name": "F - Security report (crew)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "security-report-crew",
"responseMode": "onReceived",
"options": {}
},
"name": "Start",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
]
},
{
"parameters": {
"method": "GET",
"url": "http://forgejo:3000/api/v1/repos/opsadmin/selfhostedops-stack/raw/agents/skills/fail2ban-reader.md",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"name": "Load skill: fail2ban reader",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Forgejo API"
}
}
},
{
"parameters": {
"method": "GET",
"url": "http://forgejo:3000/api/v1/repos/opsadmin/selfhostedops-stack/raw/agents/skills/caddy-log-reader.md",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"name": "Load skill: caddy log reader",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Forgejo API"
}
}
},
{
"parameters": {
"method": "GET",
"url": "http://forgejo:3000/api/v1/repos/opsadmin/selfhostedops-stack/raw/agents/skills/report-writer.md",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"name": "Load skill: report writer",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Forgejo API"
}
}
},
{
"parameters": {
"jsCode": "\n// The skill file is markdown with a small frontmatter block (name, description,\n// tools). The frontmatter is for humans and for the README; the model gets\n// everything below it.\nfunction body(md) { return String(md ?? '').replace(/^---[\\s\\S]*?\\n---\\s*/, '').trim(); }\n\n// The orchestrator is this workflow, not a fourth model. The order is fixed,\n// each agent gets exactly one skill as its system message and one task line.\nconst b = $('Start').first().json.body ?? {};\nconst started = new Date().toISOString();\nreturn [{ json: {\n variant: 'crew',\n label: String(b.label ?? ''),\n deliver: b.deliver !== false,\n started,\n sys_f2b: body($('Load skill: fail2ban reader').first().json.data),\n sys_caddy: body($('Load skill: caddy log reader').first().json.data),\n sys_writer: body($('Load skill: report writer').first().json.data),\n task_f2b: 'Gather the SSH and fail2ban facts for the last 24 hours. Current time: ' + started + '. Call every tool once, then reply with the JSON object only.',\n task_caddy: 'Gather the web facts for the last 24 hours. Current time: ' + started + '. Call every tool once, then reply with the JSON object only.',\n}}];\n"
},
"name": "Build prompts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.task_f2b }}",
"hasOutputParser": false,
"options": {
"systemMessage": "={{ $json.sys_f2b }}",
"maxIterations": 8,
"returnIntermediateSteps": true,
"enableStreaming": false
}
},
"name": "fail2ban reader",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2.2,
"position": [
1100,
300
]
},
{
"parameters": {
"model": "qwen2.5:3b",
"options": {
"temperature": 0,
"numCtx": 8192,
"keepAlive": "30m",
"think": false
}
},
"name": "Ollama (fail2ban reader)",
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
"typeVersion": 1,
"position": [
1060,
520
],
"credentials": {
"ollamaApi": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Ollama (home lab over mesh)"
}
}
},
{
"parameters": {
"toolDescription": "fail2ban status of the sshd jail: currently failed, total failed, currently banned, total banned, banned IPs. Read only, no arguments.",
"method": "GET",
"url": "http://ops-readonly:8098/read/f2b_status?jail=sshd",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"name": "f2b_status_sshd",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1200,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"toolDescription": "fail2ban status of the caddy-probes jail (web probes): currently failed, total failed, currently banned, total banned, banned IPs. Read only, no arguments.",
"method": "GET",
"url": "http://ops-readonly:8098/read/f2b_status?jail=caddy-probes",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"name": "f2b_status_caddy",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1370,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"toolDescription": "Every IP address fail2ban is banning right now, per jail. Read only, no arguments.",
"method": "GET",
"url": "http://ops-readonly:8098/read/f2b_banned",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"name": "f2b_banned",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1540,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"toolDescription": "Summary of the sshd journal for the last 24 hours: login failures, invalid users, distinct source IPs, top 5 usernames, top 5 source IPs. Read only, no arguments.",
"method": "GET",
"url": "http://ops-readonly:8098/read/sshd_failed_summary?n=5",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"name": "sshd_failed_summary",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1710,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"jsCode": "\nfunction readObject(s) {\n if (s && typeof s === 'object') return s;\n const t = String(s ?? '');\n const a = t.indexOf('{');\n const b = t.lastIndexOf('}');\n if (a === -1 || b <= a) return {};\n for (let end = b; end > a; end = t.lastIndexOf('}', end - 1)) {\n try { return JSON.parse(t.slice(a, end + 1)); } catch (e) { /* keep trying */ }\n }\n return {};\n}\n\nconst found = readObject($json.output);\nreturn [{ json: { ssh_findings: found, ssh_raw: String($json.output ?? '').slice(0, 3000) } }];\n"
},
"name": "Parse ssh findings",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1460,
300
]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $('Build prompts').first().json.task_caddy }}",
"hasOutputParser": false,
"options": {
"systemMessage": "={{ $('Build prompts').first().json.sys_caddy }}",
"maxIterations": 8,
"returnIntermediateSteps": true,
"enableStreaming": false
}
},
"name": "Caddy log reader",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2.2,
"position": [
1680,
300
]
},
{
"parameters": {
"model": "qwen2.5:3b",
"options": {
"temperature": 0,
"numCtx": 8192,
"keepAlive": "30m",
"think": false
}
},
"name": "Ollama (caddy log reader)",
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
"typeVersion": 1,
"position": [
1640,
520
],
"credentials": {
"ollamaApi": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Ollama (home lab over mesh)"
}
}
},
{
"parameters": {
"toolDescription": "Caddy access log for the last 24 hours: total requests, requests per status code, rejected 4xx, rate limited 429, server errors 5xx, distinct client IPs. Read only, no arguments.",
"method": "GET",
"url": "http://ops-readonly:8098/read/caddy_status_counts",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"name": "caddy_status_counts",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1780,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"toolDescription": "The n request paths that were rejected most often (status 400 or higher) in the Caddy access log of the last 24 hours. One argument: n, how many entries, use 5.",
"method": "GET",
"url": "http://ops-readonly:8098/read/caddy_top_paths?n={n}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"placeholderDefinitions": {
"values": [
{
"name": "n",
"description": "how many entries, a whole number from 1 to 20, use 5",
"type": "number"
}
]
}
},
"name": "caddy_top_paths",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
1950,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"toolDescription": "The n client IPs with the most rejected requests (status 400 or higher) in the Caddy access log of the last 24 hours. One argument: n, how many entries, use 5.",
"method": "GET",
"url": "http://ops-readonly:8098/read/caddy_top_ips?n={n}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"placeholderDefinitions": {
"values": [
{
"name": "n",
"description": "how many entries, a whole number from 1 to 20, use 5",
"type": "number"
}
]
}
},
"name": "caddy_top_ips",
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
"typeVersion": 1.1,
"position": [
2120,
560
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ops-readonly token"
}
}
},
{
"parameters": {
"jsCode": "\nfunction readObject(s) {\n if (s && typeof s === 'object') return s;\n const t = String(s ?? '');\n const a = t.indexOf('{');\n const b = t.lastIndexOf('}');\n if (a === -1 || b <= a) return {};\n for (let end = b; end > a; end = t.lastIndexOf('}', end - 1)) {\n try { return JSON.parse(t.slice(a, end + 1)); } catch (e) { /* keep trying */ }\n }\n return {};\n}\n\nconst found = readObject($json.output);\nconst ssh = $('Parse ssh findings').first().json;\nconst p = $('Build prompts').first().json;\nconst task = [\n 'Write the daily security report for the last 24 hours. Current time: ' + p.started + '.',\n '',\n 'Result of the fail2ban reader:',\n JSON.stringify(ssh.ssh_findings),\n '',\n 'Result of the Caddy log reader:',\n JSON.stringify(found),\n '',\n 'Reply with the report JSON object only.',\n].join('\\n');\nreturn [{ json: { web_findings: found, web_raw: String($json.output ?? '').slice(0, 3000),\n ssh_findings: ssh.ssh_findings, writer_task: task } }];\n"
},
"name": "Parse web findings",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
300
]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.writer_task }}",
"hasOutputParser": false,
"options": {
"systemMessage": "={{ $('Build prompts').first().json.sys_writer }}",
"maxIterations": 3,
"returnIntermediateSteps": true,
"enableStreaming": false
}
},
"name": "Report writer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2.2,
"position": [
2260,
300
]
},
{
"parameters": {
"model": "qwen2.5:3b",
"options": {
"temperature": 0,
"numCtx": 8192,
"keepAlive": "30m",
"think": false
}
},
"name": "Ollama (report writer)",
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
"typeVersion": 1,
"position": [
2220,
520
],
"credentials": {
"ollamaApi": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Ollama (home lab over mesh)"
}
}
},
{
"parameters": {
"jsCode": "\nfunction readObject(s) {\n if (s && typeof s === 'object') return s;\n const t = String(s ?? '');\n const a = t.indexOf('{');\n const b = t.lastIndexOf('}');\n if (a === -1 || b <= a) return {};\n for (let end = b; end > a; end = t.lastIndexOf('}', end - 1)) {\n try { return JSON.parse(t.slice(a, end + 1)); } catch (e) { /* keep trying */ }\n }\n return {};\n}\n\n// Deterministic. The model wrote a report; this node only checks that every\n// field the schema demands is there and has the right type. It never fixes a\n// number, it only marks what is missing, so a gap stays visible in the wiki.\nconst REQUIRED = {\n 'ssh.login_failures_24h': 'number', 'ssh.invalid_user_24h': 'number',\n 'ssh.distinct_source_ips_24h': 'number', 'ssh.currently_banned': 'number',\n 'ssh.total_banned': 'number', 'ssh.top_usernames': 'array', 'ssh.top_source_ips': 'array',\n 'web.requests_24h': 'number', 'web.rejected_4xx_24h': 'number',\n 'web.rate_limited_429_24h': 'number', 'web.server_errors_5xx_24h': 'number',\n 'web.distinct_client_ips_24h': 'number', 'web.currently_banned': 'number',\n 'web.total_banned': 'number', 'web.top_probe_paths': 'array', 'web.top_probe_ips': 'array',\n 'headline': 'string', 'assessment': 'string', 'recommendations': 'array',\n};\nfunction get(o, path) { return path.split('.').reduce((x, k) => (x && typeof x === 'object') ? x[k] : undefined, o); }\nfunction typeOf(v) { return Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v); }\n\nconst raw = String($json.output ?? '');\nconst report = readObject(raw);\nconst missing = [];\nfor (const [path, type] of Object.entries(REQUIRED)) {\n if (typeOf(get(report, path)) !== type) missing.push(path);\n}\nconst meta = $('Build prompts').first().json;\nreturn [{ json: {\n report, missing, valid: missing.length === 0,\n agent_raw: raw.slice(0, 4000),\n variant: meta.variant, label: meta.label, deliver: meta.deliver, started: meta.started,\n}}];\n"
},
"name": "Check report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2480,
300
]
},
{
"parameters": {
"jsCode": "\n// Everything here came from a log a stranger can write to, or from the model.\n// Strings are cut to length and stripped of newlines before they become a\n// title (Outline takes 100 characters, episode 9) or a line in the document.\nfunction safe(s, n) { return String(s ?? '').replace(/\\s+/g, ' ').trim().slice(0, n); }\nfunction num(v) { return (typeof v === 'number') ? String(v) : 'missing'; }\nfunction list(v) { return Array.isArray(v) && v.length ? v.map(x => safe(x, 80)).join(', ') : (Array.isArray(v) ? '(none)' : 'missing'); }\n\nconst d = $json;\nconst r = d.report ?? {};\nconst ssh = r.ssh ?? {};\nconst web = r.web ?? {};\nconst stamp = new Date().toISOString().replace('T', ' ').replace(/\\..*/, ' UTC');\nconst title = safe('Security report ' + stamp + ' (' + d.variant + ')', 100);\nconst recs = Array.isArray(r.recommendations) ? r.recommendations.map(x => '- ' + safe(x, 200)) : ['- missing'];\nconst text = [\n '**' + (safe(r.headline, 200) || 'headline missing') + '**',\n '',\n safe(r.assessment, 800) || 'assessment missing',\n '',\n '## SSH (last 24 h)',\n '- Login failures: ' + num(ssh.login_failures_24h),\n '- Invalid users: ' + num(ssh.invalid_user_24h),\n '- Distinct source IPs: ' + num(ssh.distinct_source_ips_24h),\n '- Top usernames: ' + list(ssh.top_usernames),\n '- Top source IPs: ' + list(ssh.top_source_ips),\n '- fail2ban sshd: banned now ' + num(ssh.currently_banned) + ', banned total ' + num(ssh.total_banned),\n '',\n '## Web (last 24 h)',\n '- Requests: ' + num(web.requests_24h),\n '- Rejected (4xx): ' + num(web.rejected_4xx_24h),\n '- Rate limited (429): ' + num(web.rate_limited_429_24h),\n '- Server errors (5xx): ' + num(web.server_errors_5xx_24h),\n '- Distinct client IPs: ' + num(web.distinct_client_ips_24h),\n '- Top probe paths: ' + list(web.top_probe_paths),\n '- Top probe IPs: ' + list(web.top_probe_ips),\n '- fail2ban caddy-probes: banned now ' + num(web.currently_banned) + ', banned total ' + num(web.total_banned),\n '',\n '## Recommendations',\n ...recs,\n '',\n '---',\n '',\n 'Schema check: ' + (d.valid ? 'complete' : 'missing ' + d.missing.join(', ')),\n '',\n 'Written by the security report crew (' + d.variant + '). The model read counters through a read-only executor; it never had a shell.',\n].join('\\n');\nreturn [{ json: { title, deliver: d.deliver, valid: d.valid, missing: d.missing,\n headline: safe(r.headline, 200), variant: d.variant, label: d.label,\n payload: { title, text, collectionId: 'REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID', publish: true } } }];\n"
},
"name": "Build report note",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2700,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.deliver }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Deliver?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
2920,
300
]
},
{
"parameters": {
"method": "POST",
"url": "http://outline:3000/api/documents.create",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.payload) }}",
"options": {}
},
"name": "Write report note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3140,
200
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Outline API"
}
}
},
{
"parameters": {
"method": "POST",
"url": "http://ntfy/alerts",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Title",
"value": "={{ $('Build report note').first().json.title }}"
},
{
"name": "X-Priority",
"value": "3"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "text/plain",
"body": "={{ $('Build report note').first().json.headline }}\nNote: https://wiki.ops.example.com{{ $json.data.url }}",
"options": {}
},
"name": "Push the result",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3360,
200
],
"credentials": {
"httpBasicAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ntfy (n8n user)"
}
}
},
{
"parameters": {},
"name": "Not delivered (measurement run)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
3140,
400
]
}
],
"connections": {
"Ollama (fail2ban reader)": {
"ai_languageModel": [
[
{
"node": "fail2ban reader",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"f2b_status_sshd": {
"ai_tool": [
[
{
"node": "fail2ban reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"f2b_status_caddy": {
"ai_tool": [
[
{
"node": "fail2ban reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"f2b_banned": {
"ai_tool": [
[
{
"node": "fail2ban reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"sshd_failed_summary": {
"ai_tool": [
[
{
"node": "fail2ban reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"Ollama (caddy log reader)": {
"ai_languageModel": [
[
{
"node": "Caddy log reader",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"caddy_status_counts": {
"ai_tool": [
[
{
"node": "Caddy log reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"caddy_top_paths": {
"ai_tool": [
[
{
"node": "Caddy log reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"caddy_top_ips": {
"ai_tool": [
[
{
"node": "Caddy log reader",
"type": "ai_tool",
"index": 0
}
]
]
},
"Ollama (report writer)": {
"ai_languageModel": [
[
{
"node": "Report writer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "Load skill: fail2ban reader",
"type": "main",
"index": 0
}
]
]
},
"Load skill: fail2ban reader": {
"main": [
[
{
"node": "Load skill: caddy log reader",
"type": "main",
"index": 0
}
]
]
},
"Load skill: caddy log reader": {
"main": [
[
{
"node": "Load skill: report writer",
"type": "main",
"index": 0
}
]
]
},
"Load skill: report writer": {
"main": [
[
{
"node": "Build prompts",
"type": "main",
"index": 0
}
]
]
},
"Build prompts": {
"main": [
[
{
"node": "fail2ban reader",
"type": "main",
"index": 0
}
]
]
},
"fail2ban reader": {
"main": [
[
{
"node": "Parse ssh findings",
"type": "main",
"index": 0
}
]
]
},
"Parse ssh findings": {
"main": [
[
{
"node": "Caddy log reader",
"type": "main",
"index": 0
}
]
]
},
"Caddy log reader": {
"main": [
[
{
"node": "Parse web findings",
"type": "main",
"index": 0
}
]
]
},
"Parse web findings": {
"main": [
[
{
"node": "Report writer",
"type": "main",
"index": 0
}
]
]
},
"Report writer": {
"main": [
[
{
"node": "Check report",
"type": "main",
"index": 0
}
]
]
},
"Check report": {
"main": [
[
{
"node": "Build report note",
"type": "main",
"index": 0
}
]
]
},
"Build report note": {
"main": [
[
{
"node": "Deliver?",
"type": "main",
"index": 0
}
]
]
},
"Deliver?": {
"main": [
[
{
"node": "Write report note",
"type": "main",
"index": 0
}
],
[
{
"node": "Not delivered (measurement run)",
"type": "main",
"index": 0
}
]
]
},
"Write report note": {
"main": [
[
{
"node": "Push the result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}