From ef2a3334300ddc8ae2629b8fcc9ceca70a377f7b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 15 Aug 2026 13:27:49 +0700 Subject: [PATCH] feat(api): enhance host firewall handling and validation - Introduced a new `sanitizeHostFirewall` function to filter and validate host firewall rules and listeners, ensuring only valid entries are processed. - Updated the `apply-report` endpoint to prevent overwriting existing host firewall snapshots with empty payloads, improving data integrity. - Enhanced the `applyReportHostFirewallSchema` to define the expected structure for host firewall data, allowing for better validation and error handling. - Added tests to verify the behavior of the new sanitization logic and the preservation of existing snapshots, ensuring robustness in the API's handling of firewall data. These changes improve the reliability and accuracy of host firewall data management within the API, enhancing overall monitoring capabilities. --- apps/api/src/agent-scripts/evofw-firewall.sh | 165 ++++++++++--------- apps/api/src/routes/agent.ts | 37 ++++- apps/api/src/services/port-acl.test.ts | 107 ++++++++++++ packages/shared/src/contracts.ts | 8 +- 4 files changed, 239 insertions(+), 78 deletions(-) diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 6985c25..ff6ffa5 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -108,9 +108,13 @@ PACKETS_DROPPED=0 PACKETS_ACCEPTED=0 KERNEL_METHOD="" APPLIED=0 -IP_HITS_JSON="[]" -PORT_HITS_JSON="[]" -HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' +# Snapshot / hits live on disk — never via bash ${var:-{...}} (} truncates expansion). +HOST_FW_FILE="${STATE_DIR}/host_firewall.json" +IP_HITS_FILE="${STATE_DIR}/ip_hits.json" +PORT_HITS_FILE="${STATE_DIR}/port_hits.json" +printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE" +printf '%s' '[]' >"$IP_HITS_FILE" +printf '%s' '[]' >"$PORT_HITS_FILE" # 1 when deny_port_hits dynamic set is available for this apply. PORT_HITS_ENABLED=0 @@ -181,53 +185,54 @@ collect_nft_stats() { done < <(nft list chain inet evofw input 2>/dev/null || true) } -# Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...] -build_ip_hits_json() { - local text="$1" - if command -v python3 >/dev/null 2>&1; then - IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c ' -import json, os, re, sys -text = sys.stdin.read() +# Parse nft/ipset listing from file → IP_HITS_FILE (top-N JSON). +build_ip_hits_from_file() { + local src="$1" + printf '%s' '[]' >"$IP_HITS_FILE" + [[ -f "$src" ]] || return 0 + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + IP_HITS_TOP="$IP_HITS_TOP" IN_F="$src" OUT_F="$IP_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$IP_HITS_FILE" +import json, os, re +path = os.environ["IN_F"] top = int(os.environ.get("IP_HITS_TOP", "200")) +try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() +except OSError: + text = "" hits = {} -# nft: "1.2.3.4 counter packets 10 bytes 100" or "1.2.3.0/24 packets 5 bytes 20" for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+(?:counter\s+)?packets\s+(\d+)", text): ip, pkts = m.group(1), int(m.group(2)) if pkts > 0: hits[ip] = max(hits.get(ip, 0), pkts) -# ipset list Members: "1.2.3.4 packets 10 bytes 100" for m in re.finditer(r"^([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+packets\s+(\d+)", text, re.M): ip, pkts = m.group(1), int(m.group(2)) if pkts > 0: hits[ip] = max(hits.get(ip, 0), pkts) items = [{"ip": k, "packets": v} for k, v in hits.items()] items.sort(key=lambda x: x["packets"], reverse=True) -print(json.dumps(items[:top], separators=(",", ":"))) -' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]" - return - fi - if command -v jq >/dev/null 2>&1; then - # Fallback without python: empty (jq alone cannot easily top-N from free text) - IP_HITS_JSON="[]" - return - fi - IP_HITS_JSON="[]" +with open(os.environ["OUT_F"], "w", encoding="utf-8") as f: + json.dump(items[:top], f, separators=(",", ":")) +print(len(items[:top])) +PY } collect_nft_ip_hits() { - local text - text=$(nft list set inet evofw deny_v4 2>/dev/null || true) - build_ip_hits_json "$text" + local dump="${STATE_DIR}/nft_deny_v4.txt" + nft list set inet evofw deny_v4 >"$dump" 2>/dev/null || : >"$dump" + build_ip_hits_from_file "$dump" } collect_ipset_ip_hits() { - local text - text=$(ipset list evofw_deny_v4 2>/dev/null || true) - build_ip_hits_json "$text" + local dump="${STATE_DIR}/ipset_deny_v4.txt" + ipset list evofw_deny_v4 >"$dump" 2>/dev/null || : >"$dump" + build_ip_hits_from_file "$dump" } collect_ip_hits() { - IP_HITS_JSON="[]" + printf '%s' '[]' >"$IP_HITS_FILE" if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_v4 >/dev/null 2>&1; }; then collect_nft_ip_hits return @@ -237,19 +242,24 @@ collect_ip_hits() { fi } -# Parse nft dynamic concat set → [{"ip","port","protocol","packets"},...] -build_port_hits_json() { - local text="$1" - PORT_HITS_JSON="[]" - if command -v python3 >/dev/null 2>&1; then - PORT_HITS_JSON=$(PORT_HITS_TOP="$PORT_HITS_TOP" python3 -c ' -import json, os, re, sys -text = sys.stdin.read() +# Parse nft dynamic concat set from file → PORT_HITS_FILE. +build_port_hits_from_file() { + local src="$1" + printf '%s' '[]' >"$PORT_HITS_FILE" + [[ -f "$src" && -s "$src" ]] || return 0 + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + PORT_HITS_TOP="$PORT_HITS_TOP" IN_F="$src" OUT_F="$PORT_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$PORT_HITS_FILE" +import json, os, re +path = os.environ["IN_F"] top = int(os.environ.get("PORT_HITS_TOP", "500")) +try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() +except OSError: + text = "" hits = {} -# Elements look like: -# 1.2.3.4 . tcp . 22 counter packets 10 bytes 100 -# 1.2.3.4 . 6 . 443 timeout 1h counter packets 5 bytes 20 proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"} pat = re.compile( r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+" @@ -274,23 +284,22 @@ items = [ for (ip, port, proto), pkts in hits.items() ] items.sort(key=lambda x: x["packets"], reverse=True) -print(json.dumps(items[:top], separators=(",", ":"))) -' <<<"$text" 2>/dev/null) || PORT_HITS_JSON="[]" - return - fi - PORT_HITS_JSON="[]" +with open(os.environ["OUT_F"], "w", encoding="utf-8") as f: + json.dump(items[:top], f, separators=(",", ":")) +print(len(items[:top])) +PY } collect_nft_port_hits() { - local text - PORT_HITS_JSON="[]" - text=$(nft list set inet evofw deny_port_hits 2>/dev/null || true) - [[ -z "$text" ]] && return - build_port_hits_json "$text" + local dump="${STATE_DIR}/nft_deny_port_hits.txt" + printf '%s' '[]' >"$PORT_HITS_FILE" + nft list set inet evofw deny_port_hits >"$dump" 2>/dev/null || : >"$dump" + [[ -s "$dump" ]] || return 0 + build_port_hits_from_file "$dump" } collect_port_hits() { - PORT_HITS_JSON="[]" + printf '%s' '[]' >"$PORT_HITS_FILE" if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_port_hits >/dev/null 2>&1; }; then collect_nft_port_hits fi @@ -465,7 +474,7 @@ PY # Collect observed host firewall rules + listeners (best-effort). collect_host_firewall() { - HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' + printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE" if ! command -v python3 >/dev/null 2>&1; then log "host_firewall: python3 missing — empty snapshot" return 0 @@ -667,12 +676,12 @@ print(f"{len(rules)} {len(listeners)}") PY then if [[ -f "$out_f" ]]; then - HOST_FIREWALL_JSON=$(cat "$out_f") + cp "$out_f" "$HOST_FW_FILE" log "host_firewall collected $(tr -d '\r\n' /dev/null || echo '?')" fi else log "host_firewall: collect failed — empty snapshot" - HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}' + printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE" fi rm -rf "$tmpdir" 2>/dev/null || true } @@ -736,27 +745,24 @@ send_report() { if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then collect_host_firewall fi - local report_file http_code counts + local report_file http_code counts n_rules n_listen n_ip n_port report_file=$(mktemp "${STATE_DIR}/report.XXXXXX") - # Compose report via files — large host_firewall must not go through env ARG_MAX. + # Compose report via files only — never bash ${var:-{...}} (} closes expansion). if command -v python3 >/dev/null 2>&1; then - local host_fw_file hits_file ports_file - host_fw_file=$(mktemp "${STATE_DIR}/hostfwj.XXXXXX") - hits_file=$(mktemp "${STATE_DIR}/iphits.XXXXXX") - ports_file=$(mktemp "${STATE_DIR}/porthits.XXXXXX") - printf '%s' "${HOST_FIREWALL_JSON:-{\"rules\":[],\"listeners\":[]}}" >"$host_fw_file" - printf '%s' "${IP_HITS_JSON:-[]}" >"$hits_file" - printf '%s' "${PORT_HITS_JSON:-[]}" >"$ports_file" + [[ -f "$HOST_FW_FILE" ]] || printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE" + [[ -f "$IP_HITS_FILE" ]] || printf '%s' '[]' >"$IP_HITS_FILE" + [[ -f "$PORT_HITS_FILE" ]] || printf '%s' '[]' >"$PORT_HITS_FILE" counts=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \ - METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$host_fw_file" IP_HITS_F="$hits_file" \ - PORT_HITS_F="$ports_file" OUT_F="$report_file" python3 - <<'PY' + METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$HOST_FW_FILE" IP_HITS_F="$IP_HITS_FILE" \ + PORT_HITS_F="$PORT_HITS_FILE" OUT_F="$report_file" python3 - <<'PY' 2>>"$LOG_FILE" import json, os def load(path, default): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) - except Exception: + except Exception as e: + print(f"load_error {path}: {e}", file=__import__("sys").stderr) return default payload = { @@ -774,22 +780,33 @@ with open(os.environ["OUT_F"], "w", encoding="utf-8") as f: json.dump(payload, f, separators=(",", ":")) n_rules = len(payload["host_firewall"].get("rules") or []) n_listen = len(payload["host_firewall"].get("listeners") or []) -print(f"{n_rules} {n_listen}") +n_ip = len(payload["ip_hits"] or []) +n_port = len(payload["port_hits"] or []) +print(f"{n_rules} {n_listen} {n_ip} {n_port}") PY -) || counts="0 0" - rm -f "$host_fw_file" "$hits_file" "$ports_file" 2>/dev/null || true - log "host_firewall snapshot rules=${counts%% *} listeners=${counts##* }" +) || counts="0 0 0 0" + # shellcheck disable=SC2086 + set -- $counts + n_rules=${1:-0} + n_listen=${2:-0} + n_ip=${3:-0} + n_port=${4:-0} else - printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \ - "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}" \ + printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":[],"port_hits":[]}' \ + "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" \ >"$report_file" + n_rules=0 + n_listen=0 + n_ip=0 + n_port=0 fi http_code=$(curl -sS -o /tmp/evofw-apply-report.out -w '%{http_code}' -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ --data-binary @"$report_file" 2>/dev/null || echo "000") + log "apply-report http=${http_code} host_fw=${n_rules}/${n_listen} ip_hits=${n_ip} port_hits=${n_port}" if [[ "$http_code" != "200" ]]; then - log "apply-report failed http=${http_code} body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)" + log "apply-report failed body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)" fi rm -f "$report_file" 2>/dev/null || true curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \ @@ -834,7 +851,7 @@ if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then collect_ipset_ip_hits IP_HITS_CAPTURED=1 - PORT_HITS_JSON="[]" + printf '%s' '[]' >"$PORT_HITS_FILE" PORT_HITS_CAPTURED=1 fi collect_host_firewall diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 1ced2f4..f9ad3f5 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { join } from 'node:path' import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' -import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared' +import { enrollBodySchema, applyReportBodySchema, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared' import type { AppConfig } from '../config.js' import { hashToken } from '../plugins/auth.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js' @@ -14,6 +14,26 @@ import { resolveAndRenderInstall } from '../services/install-links.js' const scriptsDir = resolveAgentScriptsDir() +function sanitizeHostFirewall(raw: { + rules?: unknown[] + listeners?: unknown[] +}): { rules: unknown[]; listeners: unknown[] } { + const rules: unknown[] = [] + for (const item of raw.rules ?? []) { + const parsed = hostFwRuleSchema.safeParse(item) + if (parsed.success) rules.push(parsed.data) + } + const listeners: unknown[] = [] + for (const item of raw.listeners ?? []) { + const parsed = hostListenerSchema.safeParse(item) + if (parsed.success) listeners.push(parsed.data) + } + return { + rules: rules.slice(0, 500), + listeners: listeners.slice(0, 200), + } +} + export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, opts, @@ -247,9 +267,20 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now) } if (body.host_firewall && body.source !== 'mikrotik') { + const sanitized = sanitizeHostFirewall(body.host_firewall) + // Empty snapshot must not wipe a previous good collect (agent bash bugs / race). + if ( + sanitized.rules.length === 0 && + sanitized.listeners.length === 0 + ) { + const existing = repos.getHostFirewallSnapshot(app.db, agentId) + if (existing) { + return { ok: true } + } + } const payloadJson = JSON.stringify({ - rules: body.host_firewall.rules ?? [], - listeners: body.host_firewall.listeners ?? [], + rules: sanitized.rules, + listeners: sanitized.listeners, }) const rawDigest = createHash('sha256') .update(payloadJson) diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts index 00b67f9..730332c 100644 --- a/apps/api/src/services/port-acl.test.ts +++ b/apps/api/src/services/port-acl.test.ts @@ -278,6 +278,113 @@ describe('port ACL + host firewall snapshot', () => { expect(body.listeners[0]?.port).toBe(22) }) + it('does not wipe host_firewall snapshot with empty apply-report payload', async () => { + const app = await appPromise + await app.ready() + + const { agentId, token } = await enrollApprovedLinux( + app, + 'host-fw-empty', + 'evofw_host_fw_empty_token_ab', + ) + + const filled = await app.inject({ + method: 'POST', + url: '/v1/agent/apply-report', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + payload: { + status: 'ok', + host_firewall: { + rules: [ + { + ownership: 'evofw', + backend: 'nft', + action: 'drop', + raw: 'ip saddr @deny_v4 drop', + }, + ], + listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }], + }, + }, + }) + expect(filled.statusCode).toBe(200) + + const empty = await app.inject({ + method: 'POST', + url: '/v1/agent/apply-report', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + payload: { + status: 'ok', + host_firewall: { rules: [], listeners: [] }, + }, + }) + expect(empty.statusCode).toBe(200) + + const snap = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/host-firewall`, + }) + const body = snap.json() as { + rules: unknown[] + listeners: unknown[] + } + expect(body.rules).toHaveLength(1) + expect(body.listeners).toHaveLength(1) + }) + + it('filters invalid host_firewall rules without failing apply-report', async () => { + const app = await appPromise + await app.ready() + + const { agentId, token } = await enrollApprovedLinux( + app, + 'host-fw-filter', + 'evofw_host_fw_filter_token_a', + ) + + const report = await app.inject({ + method: 'POST', + url: '/v1/agent/apply-report', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + payload: { + status: 'ok', + host_firewall: { + rules: [ + { ownership: 'evofw', backend: 'nft', raw: 'ok rule' }, + { ownership: 'nope', backend: 'nft', raw: 'bad ownership' }, + { not: 'a rule' }, + ], + listeners: [ + { protocol: 'tcp', port: 443, address: '::' }, + { protocol: 'tcp', port: 99999, address: '1.1.1.1' }, + ], + }, + }, + }) + expect(report.statusCode).toBe(200) + + const snap = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/host-firewall`, + }) + const body = snap.json() as { + rules: unknown[] + listeners: { port: number }[] + } + expect(body.rules).toHaveLength(1) + expect(body.listeners).toHaveLength(1) + expect(body.listeners[0]?.port).toBe(443) + }) + it('rejects port ACL on non-linux agents', async () => { const app = await appPromise await app.ready() diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index bafbbd0..172552f 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -269,6 +269,12 @@ export const hostFirewallPayloadSchema = z.object({ listeners: z.array(hostListenerSchema).max(200).default([]), }) +/** Wire format for apply-report — unknown elements filtered in route (safeParse). */ +export const applyReportHostFirewallSchema = z.object({ + rules: z.array(z.unknown()).max(500).optional().default([]), + listeners: z.array(z.unknown()).max(200).optional().default([]), +}) + export const applyReportBodySchema = z.object({ status: z.string(), prefix_count: z.number().int().optional(), @@ -282,7 +288,7 @@ export const applyReportBodySchema = z.object({ /** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */ port_hits: z.array(applyReportPortHitSchema).max(500).optional(), /** Observed host firewall + listeners (Linux). */ - host_firewall: hostFirewallPayloadSchema.optional(), + host_firewall: applyReportHostFirewallSchema.optional(), }) export const agentPortRuleActionSchema = z.enum(['open', 'close'])