Files
EvoFirewall/apps/api/src/services/agent-hardening.test.ts
T
Denozordec 9fa6269174
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
feat: harden firewall policies telemetry and operations
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
2026-10-26 13:10:58 +07:00

214 lines
15 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { renderMikrotikPolicyRsc } from './policy/mikrotik-rsc.js'
import type { EvaluatedPolicy } from './policy/evaluate.js'
const script = readFileSync(new URL('../agent-scripts/evofw-firewall.sh', import.meta.url), 'utf8')
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const python = process.platform === 'win32' ? 'python' : 'python3'
const dirs: string[] = []
function workspace() { const dir = mkdtempSync(join(tmpdir(), 'evofw-agent-test-')); dirs.push(dir); return dir }
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
function embedded(name: string) {
const body = script.slice(script.indexOf(`${name}() {`))
const start = body.indexOf("<<'PY'") + 7
return body.slice(start, body.indexOf('\nPY', start)).trimStart()
}
const policy = (patch = {}) => ({ hash: 'sha256:' + 'a'.repeat(64), default_action: 'accept', deny_cidrs: ['10.0.0.1/24'], allow_cidrs: ['192.0.2.1'], port_rules: [], ...patch })
function normalize(value: unknown) {
const dir = workspace(); const path = join(dir, 'policy.json'); writeFileSync(path, JSON.stringify(value))
return { dir, result: spawnSync(python, ['-c', embedded('parse_policy'), path, dir], { encoding: 'utf8' }) }
}
function render(value: unknown, counters = '1', hits = '1') {
const { dir, result } = normalize(value); expect(result.status, result.stderr).toBe(0)
return spawnSync(python, ['-c', embedded('render_nft_batch'), join(dir, 'normalized.json'), counters, hits, '1'], { encoding: 'utf8' })
}
const pythonExe = spawnSync(python, ['-c', 'import sys;print(sys.executable)'], { encoding: 'utf8' }).stdout.trim().replaceAll('\\', '/')
function shell(content: string) {
content = `python3() { '${pythonExe}' \"$@\"; }\n` + content
const dir = workspace(); const path = join(dir, 'test.sh'); writeFileSync(path, content)
return { dir, result: spawnSync(bash, ['--noprofile', '--norc', path.replaceAll('\\', '/')], { cwd: dir, encoding: 'utf8' }) }
}
function fn(name: string, next: string) { return script.slice(script.indexOf(`${name === "apply_ipset" ? "prepare_ipset_rules" : name}() {`), script.indexOf(next, script.indexOf(`${name}() {`))) }
describe('agent hardening policy boundary', () => {
it('normalizes and collapses IPv4 networks through data files', () => {
const { dir, result } = normalize(policy({ deny_cidrs: ['10.0.0.1/25', '10.0.0.128/25'] }))
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(dir, 'deny'), 'utf8').replaceAll('\r', '')).toBe('10.0.0.0/24\n')
expect(readFileSync(join(dir, 'allow'), 'utf8').replaceAll('\r', '')).toBe('192.0.2.1/32\n')
})
it.each([
{ deny_cidrs: ['$(touch injected)'] },
{ allow_cidrs: ['10.0.0.1; flush ruleset'] },
{ hash: '$(touch injected)' },
{ default_action: 'accept\nflush ruleset' },
{ deny_cidrs: ['999.1.1.1/24'] },
{ deny_cidrs: '10.0.0.0/8' },
{ port_rules: [{ id: 'x', action: 'open', protocol: 'tcp', port_start: true, src_cidrs: ['0.0.0.0/0'] }] },
{ port_rules: [{ id: 'x', action: 'open', protocol: 'udp', port_start: 443, port_end: 65536, src_cidrs: [] }] },
{ port_rules: [{ id: 'x', action: 'execute', protocol: 'tcp', port_start: 1, src_cidrs: [] }] },
])('rejects malformed or unsupported input before emitting files: %j', (patch) => {
const { dir, result } = normalize(policy(patch))
expect(result.status).not.toBe(0)
expect(existsSync(join(dir, 'hash'))).toBe(false)
expect(existsSync(join(dir, 'injected'))).toBe(false)
})
it('renders one nft candidate preserving deny, close, open, implicit-close and Docker scopes', () => {
const rule = { protocol: 'tcp', port_start: 443, port_end: 443, src_cidrs: ['192.0.2.0/24'] }
const result = render(policy({ port_rules: [{ ...rule, id: 'open', action: 'open' }, { ...rule, id: 'close', action: 'close' }] }))
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.replaceAll('\r', '').startsWith('delete table inet evofw\nadd table inet evofw\n')).toBe(true)
expect(result.stdout).toContain('prerouting fib daddr type local ip saddr @port_src_0_v4 tcp dport 443')
expect(result.stdout).toContain('forward ct status dnat ip saddr @port_src_0_v4 tcp dport 443')
const input = result.stdout.split('\n').filter((line) => line.startsWith('add rule inet evofw input ')).join('\n')
expect(input.indexOf('ip saddr @deny_v4 counter drop')).toBeLessThan(input.indexOf('ip saddr @port_src_1_v4'))
expect(input.indexOf('ip saddr @port_src_1_v4')).toBeLessThan(input.indexOf('ip saddr @port_src_0_v4'))
expect(input.indexOf('ip saddr @port_src_0_v4')).toBeLessThan(input.indexOf('evofw-port-implicit'))
expect(input.indexOf('evofw-port-implicit')).toBeLessThan(input.indexOf('ip saddr @allow_v4'))
})
it('encodes untrusted rule IDs without injecting nft syntax', () => {
const result = render(policy({ port_rules: [{ id: '"; flush ruleset; # $(touch injected)', action: 'close', protocol: 'tcp', port_start: 22, src_cidrs: ['0.0.0.0/0'] }] }))
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).not.toContain('flush ruleset')
expect(result.stdout).not.toContain('touch injected')
expect(result.stdout).toMatch(/comment "evofw-port-[a-f0-9]+-(tcp|udp)-v[46]"/)
})
})
describe('agent hardening shell control flow (mock kernel)', () => {
it('has valid bash syntax for the agent and installer', () => {
for (const name of ['evofw-firewall.sh', 'install.sh']) {
const path = fileURLToPath(new URL(`../agent-scripts/${name}`, import.meta.url)).replaceAll('\\', '/')
const result = spawnSync(bash, ['--noprofile', '--norc', '-n', path], { encoding: 'utf8' })
expect(result.status, result.stderr || String(result.error)).toBe(0)
}
})
it('never mutates nft when every candidate check fails', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; LOG_FILE=log; DENY=(); ALLOW=();\nlog() { :; }\nrender_nft_batch() { echo candidate; }\nnft() { printf '%s\\n' "$*" >> calls; return 1; }\n${fn('apply_nft', '# Collect observed host firewall')}\nif apply_nft; then exit 9; fi\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8')
expect(calls.match(/--check --file/g)).toHaveLength(3)
expect(calls.split('\n').filter((line) => line.startsWith('--file'))).toHaveLength(0)
})
it('commits the exact checked candidate only once', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; LOG_FILE=log; DENY=(10.0.0.0/8); ALLOW=();\nlog() { :; }\nrender_nft_batch() { echo candidate; }\nnft() { printf '%s\\n' "$*" >> calls; return 0; }\n${fn('apply_nft', '# Collect observed host firewall')}\napply_nft\n[[ "$APPLIED" == 1 && "$KERNEL_METHOD" == nft ]]\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8').split('\n')
expect(calls.filter((line) => line.startsWith('--check --file'))).toHaveLength(1)
expect(calls.filter((line) => line.startsWith('--file'))).toHaveLength(1)
})
it('rolls the first ipset back if the second swap fails', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; PORT_RULES_COUNT=0; DEFAULT_ACTION=accept; DENY=(10.0.0.0/8); ALLOW=();\nlog() { :; }\nipset() { printf '%s\\n' "$*" >> calls; if [[ "$1" == save ]]; then echo "create $2 hash:net family inet counters"; elif [[ "$*" == 'swap evofw_allow_stage_v4 evofw_allow_v4' ]]; then return 1; fi; }\niptables() { return 1; }\niptables-save() { printf '%s\\n' '-A INPUT -j EVOFW_INPUT'; }\niptables-restore() { cat >/dev/null; }\n${fn('apply_ipset', 'collect_telemetry() {')}\nif apply_ipset; then exit 9; fi\n`)
expect(result.status, result.stderr).toBe(0)
const calls = readFileSync(join(dir, 'calls'), 'utf8')
expect(calls.match(/swap evofw_deny_stage_v4 evofw_deny_v4/g)).toHaveLength(2)
expect(calls.match(/swap evofw_allow_stage_v4 evofw_allow_v4/g)).toHaveLength(1)
})
})
describe('agent hardening drift and RouterOS staging', () => {
it('ignores ordinary nft counter churn but detects changed enforcement', () => {
const dir = workspace()
const digest = (packets: number, verdict: string, elements: string[]) => {
writeFileSync(join(dir, 'live-kernel.json'), JSON.stringify({ nftables: [
{ metainfo: { version: '1.0' } },
{ set: { family: 'inet', table: 'evofw', name: 'deny_port_hits', elem: elements } },
{ rule: { chain: 'input', handle: packets + 1, expr: [{ counter: { packets, bytes: packets * 100 } }, { [verdict]: null }] } },
] }))
const result = spawnSync(python, ['-c', embedded('kernel_digest'), 'nft', dir], { encoding: 'utf8' })
expect(result.status, result.stderr).toBe(0)
return result.stdout.trim()
}
expect(digest(1, 'drop', ['a'])).toBe(digest(200, 'drop', ['b']))
expect(digest(1, 'drop', [])).not.toBe(digest(1, 'accept', []))
})
const rscPolicy = (patch = {}): EvaluatedPolicy => ({ hash: 'sha256:' + 'a'.repeat(64), generation: 1, applyVersion: 3, defaultAction: 'accept', policyMode: 'blacklist', denyCidrs: ['10.0.0.0/8'], allowCidrs: ['192.0.2.0/24'], portRules: [], conflictsDropped: 0, syncIntervalSec: 60, chain: [], summary: { sets: 0, rulesDeny: 0, rulesAllow: 0, cidrsDeny: 1, cidrsAllow: 1, overrides: 0, conflictsDropped: 0, portRules: 0 }, ...patch })
it('stages both RouterOS address families before canonical-rule activation and retains rollback snapshots', () => {
const rsc = renderMikrotikPolicyRsc(rscPolicy())
expect(rsc).not.toContain('remove [find list=EVOFW_DENY]')
expect(rsc).not.toContain('remove [find list=EVOFW_ALLOW]')
expect(rsc.indexOf('address=192.0.2.0/24')).toBeLessThan(rsc.indexOf('/ip firewall filter set [find comment=("evofw-stage-"'))
expect(rsc).toContain('disabled=($row->"disabled") comment=($row->"comment")')
expect(rsc).toContain('/ip firewall filter remove $new4')
expect(rsc).toContain('/ipv6 firewall filter remove $new6')
expect(rsc).not.toContain('on-error={}')
})
it.each([
{ denyCidrs: ['10.0.0.1; /system reboot'] },
{ hash: 'a\n/system reboot' },
{ portRules: [{ id: 'p' }] },
])('rejects invalid or unsupported RouterOS policy: %j', (patch) => {
expect(() => renderMikrotikPolicyRsc(rscPolicy(patch))).toThrow()
})
it.each([
['evofw-deny-drop-input', 'input', 'drop'],
['evofw-deny-drop-forward', 'forward', 'drop'],
['evofw-allow-accept-forward', 'forward', 'accept'],
['evofw-default-drop-forward', 'forward', 'drop'],
])('verifies repaired action/chain and refuses tampered no-op for %s', (comment, chain, action) => {
const rsc = renderMikrotikPolicyRsc(rscPolicy())
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(rsc).toContain(`filter find comment=${comment}`)
expect(rsc).toContain(`get $id chain] != "${chain}"`)
expect(rsc).toContain(`get $id action] != "${action}"`)
expect(installer).toContain(`filter find comment=${comment}`)
expect(installer).toContain(`get $rule chain] != "${chain}"`)
expect(installer).toContain(`get $rule action] != "${action}"`)
expect(installer.indexOf('owned filter chain/action drift')).toBeLessThan(installer.indexOf('$hash = $EvofwLastHash'))
})
it('verifies top-of-chain ordering before a RouterOS no-change ACK', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(installer).toContain('$ordered->0')
expect(installer).toContain('$ordered->1')
expect(installer).toContain('$ordered->2')
expect(installer.indexOf('owned filter order drift')).toBeLessThan(installer.indexOf('$hash = $EvofwLastHash'))
})
it('detects a foreign INPUT ACCEPT preempting the owned ipset jump', () => {
const dir = workspace()
writeFileSync(join(dir, 'live-ipset.txt'), 'create evofw_deny_v4 hash:net family inet\ncreate evofw_allow_v4 hash:net family inet\n')
const digest = (rules: string) => {
writeFileSync(join(dir, 'live-iptables.txt'), rules)
return spawnSync(python, ['-c', embedded('kernel_digest'), 'ipset', dir], { encoding: 'utf8' })
}
expect(digest('-A INPUT -j EVOFW_INPUT\n-A INPUT -j ACCEPT\n').status).toBe(0)
const preempted = digest('-A INPUT -j ACCEPT\n-A INPUT -j EVOFW_INPUT\n')
expect(preempted.status).not.toBe(0)
expect(preempted.stderr).toContain('owned INPUT jump is missing or preempted')
})
it('repositions the owned ipset jump without deleting foreign INPUT rules', () => {
const { dir, result } = shell(`set -euo pipefail\nRUN_DIR=.; PORT_RULES_COUNT=0; DEFAULT_ACTION=accept; DENY=(); ALLOW=();\nlog() { :; }\nipset() { if [[ "$1" == save ]]; then echo "create $2 hash:net family inet counters"; fi; }\niptables-save() { printf '%s\\n' '-A INPUT -j ACCEPT' '-A INPUT -j EVOFW_INPUT'; }\niptables-restore() { cat >rules; }\n${fn('apply_ipset', 'collect_telemetry() {')}\napply_ipset\n`)
expect(result.status, result.stderr).toBe(0)
const rules = readFileSync(join(dir, 'rules'), 'utf8')
expect(rules).toContain('-D INPUT -j EVOFW_INPUT')
expect(rules).toContain('-I INPUT 1 -j EVOFW_INPUT')
expect(rules).not.toContain('-D INPUT -j ACCEPT')
})
it('keeps RouterOS presence out of packet metrics and retries immutable pending payloads', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
expect(installer).not.toContain('packets\\":1')
expect(installer).not.toContain('/v1/agent/heartbeat')
expect(installer).toContain('"ip_counters_presence_only"')
expect(installer).toContain('"file_commit_not_powerloss_atomic"')
expect(installer).toContain('($ack->"report_id") = ($expected->"report_id")')
expect(installer).toContain('($ack->"sequence") = ($expected->"sequence")')
expect(installer).toContain('http-data=$body output=user as-value duration=1m')
expect(installer).toContain('contents=[:serialize $telemetryState to=json')
})
it('updates the RouterOS applied revision only on a structurally verified no-change path', () => {
const installer = readFileSync(new URL('../agent-scripts/mikrotik-install.rsc', import.meta.url), 'utf8')
const start = installer.indexOf(':if ($hash = $EvofwLastHash && $drift = 0)')
const block = installer.slice(start, installer.indexOf('} else={', start))
expect(block).toContain(':set EvofwAppliedRevision ($p->"generation")')
expect(block).not.toContain('/ip firewall filter set')
expect(block).not.toContain('EvofwTrafficEpoch')
})
})