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:
parent
bed41cfaf3
commit
d147f21490
13 changed files with 2088 additions and 0 deletions
|
|
@ -214,3 +214,46 @@ instead of 27,758.
|
|||
|
||||
Sub-workflows used as tools must be **active**, otherwise every call comes back
|
||||
as *"Workflow is not active and cannot be executed."*
|
||||
|
||||
## E and F - Security report, one agent vs a crew (episode 11)
|
||||
|
||||
The episode 9 agent read monitor state and triaged one incident. These two
|
||||
workflows read the security logs of the whole box once a day and write the
|
||||
report. They exist as a pair on purpose, to measure one question: does a 3B
|
||||
model do the whole job in one agent, or does splitting it into narrow roles
|
||||
make the report correct?
|
||||
|
||||
Both read through `ops-readonly`, the read-only twin of the episode 9
|
||||
`ops-actions` runner (see `ops-readonly/` and `HARDENING.md`). Neither agent
|
||||
gets a shell or a log file; both get a fixed list of questions.
|
||||
|
||||
| File | Shape |
|
||||
|---|---|
|
||||
| `e-security-report-solo.json` | One agent, seven tools, one skill file (`security-analyst-solo.md`). It reads everything and writes the report itself. |
|
||||
| `f-security-report-crew.json` | Three agents, one skill file each: `fail2ban-reader.md` reads SSH and fail2ban, `caddy-log-reader.md` reads the proxy log, `report-writer.md` turns both results into the report. The workflow itself is the orchestrator: the order is fixed, each agent gets exactly one skill as its system message. |
|
||||
|
||||
### Skills are files, loaded at run time
|
||||
|
||||
The four roles live in `agents/skills/*.md`. Each has frontmatter (name,
|
||||
description, tools), then the role, the allowed tools, an output schema and a
|
||||
short "what you do not do". The workflows fetch them at run time from this
|
||||
repo's own Forgejo over the docker network
|
||||
(`http://forgejo:3000/api/v1/repos/opsadmin/selfhostedops-stack/raw/agents/skills/<name>.md`,
|
||||
with the read-only Forgejo token), strip the frontmatter, and hand the body to
|
||||
the agent as its system message. Editing a role is editing a file and pushing
|
||||
it, not editing a workflow.
|
||||
|
||||
### Wiring
|
||||
|
||||
1. Create the `ops-readonly token` credential in n8n: Header Auth, name
|
||||
`x-ops-token`, value from `ops-readonly/token.env`.
|
||||
2. Import both JSON files, fill in the credential placeholders and
|
||||
`REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID`.
|
||||
3. Trigger with `POST /webhook/security-report-solo` or
|
||||
`/webhook/security-report-crew`. Body `{"deliver": false}` runs the report
|
||||
without writing to the wiki or pushing (used for the measurement runs);
|
||||
the default writes the report to the Ops Log and sends one ntfy line.
|
||||
|
||||
The measured comparison (correct numbers, invented numbers, runtime, tokens,
|
||||
and a prompt-injection test through a request path in the log) is in
|
||||
`lab-recordings-ep11/FACTS.md`.
|
||||
|
|
|
|||
596
automation/e-security-report-solo.json
Normal file
596
automation/e-security-report-solo.json
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
{
|
||||
"name": "E - Security report (solo)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "security-report-solo",
|
||||
"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/security-analyst-solo.md",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "Load skill",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
220,
|
||||
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\nconst b = $('Start').first().json.body ?? {};\nconst started = new Date().toISOString();\nreturn [{ json: {\n variant: 'solo',\n label: String(b.label ?? ''),\n deliver: b.deliver !== false,\n started,\n system: body($json.data),\n task: 'Write the daily security report for the last 24 hours. Current time: ' + started\n + '. Call every tool once, copy the numbers, then reply with the JSON object only.',\n}}];\n"
|
||||
},
|
||||
"name": "Build prompts",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "={{ $json.task }}",
|
||||
"hasOutputParser": false,
|
||||
"options": {
|
||||
"systemMessage": "={{ $json.system }}",
|
||||
"maxIterations": 12,
|
||||
"returnIntermediateSteps": true,
|
||||
"enableStreaming": false
|
||||
}
|
||||
},
|
||||
"name": "Security analyst",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "qwen2.5:3b",
|
||||
"options": {
|
||||
"temperature": 0,
|
||||
"numCtx": 8192,
|
||||
"keepAlive": "30m",
|
||||
"think": false
|
||||
}
|
||||
},
|
||||
"name": "Ollama over the mesh",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
620,
|
||||
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": [
|
||||
760,
|
||||
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": [
|
||||
930,
|
||||
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": [
|
||||
1100,
|
||||
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": [
|
||||
1270,
|
||||
560
|
||||
],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": {
|
||||
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
|
||||
"name": "ops-readonly token"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
1440,
|
||||
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": [
|
||||
1610,
|
||||
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": [
|
||||
1780,
|
||||
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\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": [
|
||||
1020,
|
||||
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": [
|
||||
1240,
|
||||
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": [
|
||||
1460,
|
||||
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": [
|
||||
1680,
|
||||
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": [
|
||||
1900,
|
||||
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": [
|
||||
1680,
|
||||
400
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"f2b_status_sshd": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"f2b_status_caddy": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"f2b_banned": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"sshd_failed_summary": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"caddy_status_counts": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"caddy_top_paths": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"caddy_top_ips": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Ollama over the mesh": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Start": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Load skill",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Load skill": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build prompts",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build prompts": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Security analyst",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Security analyst": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
852
automation/f-security-report-crew.json
Normal file
852
automation/f-security-report-crew.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue