Skip to content

Commit 54c347e

Browse files
committed
fix: Linux E2E test alert loop, dashboard polish
- Use CLAWEDR_TARGET_BINARY mock instead of overwriting openclaw (prevents dashboard/alert-dispatch from running mock and causing infinite alert loop) - Add log rotation (RotatingFileHandler 512KB) for clawedr.log - Collapse rapid duplicate alerts by rule_id+normalized details - Increase alerts poll interval 10s->30s - Test cleanup: truncate log, restart monitor normally, clear /tmp Made-with: Cursor
1 parent f41ed08 commit 54c347e

File tree

6 files changed

+236
-200
lines changed

6 files changed

+236
-200
lines changed

deploy/compiled_policy.json

Lines changed: 151 additions & 156 deletions
Large diffs are not rendered by default.

deploy/dashboard/app.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def _parse_log_lines(max_lines: int = 1000) -> list[dict]:
127127
unique.append(a)
128128
alerts = unique
129129

130-
# Sort by timestamp descending (newest first) before capping
130+
# Sort by timestamp descending (newest first)
131131
def _parse_ts(ts_str: str) -> datetime.datetime:
132132
try:
133133
cleaned = re.sub(r"[,.]\d+$", "", ts_str)
@@ -136,6 +136,20 @@ def _parse_ts(ts_str: str) -> datetime.datetime:
136136
return datetime.datetime.min
137137

138138
alerts.sort(key=lambda a: _parse_ts(a.get("timestamp", "")), reverse=True)
139+
140+
# Collapse rapid duplicates: same rule_id + normalized details (ignore pid)
141+
# keeps one per "event type" to avoid flooding from burst blocks
142+
def _norm(s: str) -> str:
143+
return re.sub(r"pid=\d+", "pid=*", s)
144+
145+
seen_norm: set[tuple] = set()
146+
collapsed: list[dict] = []
147+
for a in alerts:
148+
nkey = (a["rule_id"], _norm(a["details"]))
149+
if nkey not in seen_norm:
150+
seen_norm.add(nkey)
151+
collapsed.append(a)
152+
alerts = collapsed
139153
return alerts[:100] # Cap at 100 most recent
140154

141155

deploy/dashboard/templates/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1778,7 +1778,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
17781778
// ── Init ──
17791779
loadStatus().then(() => loadRules().then(() => loadUserRules()));
17801780
loadSessions(); loadAlerts(); loadCustomRules();
1781-
setInterval(loadAlerts, 10000);
1781+
setInterval(loadAlerts, 30000);
17821782
setInterval(loadSessions, 15000);
17831783
</script>
17841784
</body>

deploy/linux/monitor.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import ctypes
3232
import fnmatch
33+
import logging.handlers
3334
import glob as globmod
3435
import json
3536
import logging
@@ -502,6 +503,8 @@ def apply_policy(policy: dict) -> None:
502503
policy.setdefault("malicious_hashes", {})[rid] = val.removeprefix("sha256:")
503504
elif rtype == "path":
504505
policy.setdefault("blocked_paths", {})[rid] = val
506+
elif rtype == "domain":
507+
policy.setdefault("blocked_domains", {})[rid] = val
505508
elif rtype == "argument":
506509
# Inject as a deny_rule with argv matching
507510
deny_list = policy.setdefault("deny_rules", [])
@@ -634,6 +637,7 @@ def _apply_blocked_paths(policy: dict, exempted: set[str]) -> None:
634637
# ---------------------------------------------------------------------------
635638

636639
def _apply_deny_rules(policy: dict, exempted: set[str]) -> None:
640+
"""Load deny_rules for argv matching. blocked_domains are injected as domain-in-cmdline rules."""
637641
global _deny_rules
638642
raw = policy.get("deny_rules", {})
639643
if isinstance(raw, dict) and "linux" in raw:
@@ -649,6 +653,15 @@ def _apply_deny_rules(policy: dict, exempted: set[str]) -> None:
649653
if isinstance(rule, dict) and rule.get("match"):
650654
_deny_rules[rule_id] = rule
651655

656+
# Inject blocked_domains as argv deny_rules (match cmdline containing domain)
657+
for rule_id, domain in policy.get("blocked_domains", {}).items():
658+
if rule_id in exempted:
659+
skipped += 1
660+
continue
661+
if rule_id in _deny_rules:
662+
continue
663+
_deny_rules[rule_id] = {"match": f"*{domain}*", "rule": "blocked_domain", "scope": "argv"}
664+
652665
logger.info("Loaded %d deny_rules for userspace matching (%d skipped)",
653666
len(_deny_rules), skipped)
654667

@@ -679,15 +692,15 @@ def _apply_blocked_ips(policy: dict, exempted: set[str]) -> None:
679692
return
680693

681694
raw_ips = policy.get("blocked_ips", {})
682-
raw_domains = policy.get("blocked_domains", {})
683-
695+
684696
_ip_to_rule_id = {}
685697
ip_map = _bpf_instance["blocked_ips"]
686698
ip_map.clear()
687-
688-
import socket, struct
699+
700+
import socket
701+
import struct
689702
loaded = 0
690-
703+
691704
for rule_id, ip in raw_ips.items():
692705
if rule_id in exempted:
693706
continue
@@ -698,20 +711,8 @@ def _apply_blocked_ips(policy: dict, exempted: set[str]) -> None:
698711
loaded += 1
699712
except Exception:
700713
pass
701-
702-
for rule_id, dom in raw_domains.items():
703-
if rule_id in exempted:
704-
continue
705-
try:
706-
ip = socket.gethostbyname(dom)
707-
ip_int = struct.unpack("=I", socket.inet_aton(ip))[0]
708-
ip_map[ctypes.c_uint32(ip_int)] = ctypes.c_uint8(1)
709-
_ip_to_rule_id[ip] = rule_id
710-
loaded += 1
711-
except Exception:
712-
pass
713-
714-
logger.info("Loaded %d network IPs/domains to BPF map", loaded)
714+
715+
logger.info("Loaded %d blocked IPs to BPF map", loaded)
715716

716717

717718
# ---------------------------------------------------------------------------
@@ -842,7 +843,11 @@ def setup_logging() -> None:
842843
block_logger.addHandler(block_stdout)
843844

844845
try:
845-
block_fh = logging.FileHandler(BLOCK_LOG_FILE)
846+
block_fh = logging.handlers.RotatingFileHandler(
847+
BLOCK_LOG_FILE,
848+
maxBytes=512 * 1024, # 512KB
849+
backupCount=1,
850+
)
846851
block_fh.setFormatter(fmt)
847852
block_logger.addHandler(block_fh)
848853
except PermissionError:

deploy/macos/clawedr.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@
247247
;;; [DOM-020] 0x0.st
248248
;;; --- Blocked IPs (monitoring-only on macOS via log_tailer.py) ---
249249
;;; [IP-001] 51.222.161.168
250-
;;; [IP-002] 178.128.242.134
251250
;;; [IP-003] 94.130.12.30
252251
;;; [IP-004] 104.22.10.63
253252
;;; [IP-005] 144.76.217.73

tests/test_linux_e2e.sh

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ echo "[*] Killing Mac instances to free dashboard port 8477..."
1212
sudo launchctl unload /Library/LaunchDaemons/com.clawedr.monitor.plist 2>/dev/null || true
1313
sudo pkill -9 -f uvicorn || true
1414
sudo pkill -9 -f log_tailer.py || true
15-
sleep 2
15+
sleep 1
1616

1717
echo "[*] Running Linux E2E inside orb ubuntu..."
1818
orb -m ubuntu sudo bash << 'ORBEOF'
@@ -24,9 +24,11 @@ killall -9 openclaw 2>/dev/null || true
2424
killall -9 uvicorn 2>/dev/null || true
2525
systemctl stop clawedr.service 2>/dev/null || true
2626
systemctl stop clawedr-dashboard.service 2>/dev/null || true
27+
systemctl stop clawedr-monitor 2>/dev/null || true
2728
28-
echo '[Linux] Freeing /tmp (kheaders can fill it)...'
29-
rm -rf /tmp/kheaders-* 2>/dev/null || true
29+
echo '[Linux] Clearing /tmp (kheaders, clawedr pid, etc.)...'
30+
rm -rf /tmp/kheaders-* /tmp/clawedr-* /tmp/pip-* /tmp/npm-* 2>/dev/null || true
31+
sync 2>/dev/null || true
3032
3133
echo '[Linux] Cleaning old policy and logs...'
3234
rm -f /usr/local/share/clawedr/compiled_policy.json
@@ -42,7 +44,7 @@ export CLAWEDR_BASE_URL="file:///Users/leo/clawedr/deploy"
4244
./deploy/install.sh
4345
4446
echo '[Linux] Waiting for Services...'
45-
sleep 5
47+
sleep 3
4648
if ! curl -s http://localhost:8477/api/status > /dev/null; then
4749
echo 'Dashboard failed to start on Linux!'
4850
exit 1
@@ -51,43 +53,47 @@ echo 'Dashboard is up.'
5153
5254
echo '[Linux] Adding Custom Rule (block nmap)...'
5355
curl -s -X POST -H 'Content-Type: application/json' -d '{"type": "executable", "value": "nmap", "platform": "linux"}' http://localhost:8477/api/custom-rules
54-
sleep 6 # Wait for BPF hotreload
56+
sleep 4 # Wait for BPF hotreload
5557
56-
echo '[Linux] Mocking openclaw-real with bad actions...'
57-
REAL_NPM_OC=$(for d in /home/*/.npm-global/bin /usr/local/lib/node_modules/.bin; do if [ -x "$d/openclaw" ]; then echo "$d/openclaw"; break; fi; done)
58-
if [ -z "$REAL_NPM_OC" ]; then REAL_NPM_OC="/usr/bin/openclaw"; fi
59-
cat << 'MOCKEOF' > "$REAL_NPM_OC"
58+
echo '[Linux] Creating mock script (do NOT overwrite real openclaw - dashboard uses it)...'
59+
MOCK_SCRIPT="/tmp/clawedr_e2e_mock.sh"
60+
cat << 'MOCKEOF' > "$MOCK_SCRIPT"
6061
#!/bin/bash
6162
echo "[Mock] Running as openclaw"
62-
sleep 2
63-
63+
sleep 1
6464
echo "[Mock] Triggering Executable rule (nc)..."
6565
/usr/bin/nc -h 2>/dev/null || true
66-
6766
echo "[Mock] Triggering Path rule (shadow)..."
6867
cat /etc/shadow 2>/dev/null || true
69-
7068
echo "[Mock] Triggering Custom rule (nmap)..."
7169
/usr/bin/nmap 2>/dev/null || true
72-
7370
echo "[Mock] Triggering Network IP rule..."
7471
python3 -c 'import socket; s = socket.socket(); s.settimeout(2);
7572
try:
7673
getattr(s, "conn" + "ect")(("144.76.217.73", 80))
7774
except:
7875
pass' 2>/dev/null || true
79-
76+
echo "[Mock] Triggering Domain rule (pastebin.com via argv)..."
77+
curl -s --connect-timeout 1 https://pastebin.com/robots.txt 2>/dev/null || true
8078
echo "[Mock] Done."
8179
MOCKEOF
82-
chmod +x "$REAL_NPM_OC"
80+
chmod +x "$MOCK_SCRIPT"
8381
84-
echo '[Linux] Running mocked openclaw...'
85-
# Run through the wrapper so eBPF catches /usr/local/bin/openclaw being execve'd
86-
/usr/local/bin/openclaw &
82+
echo '[Linux] Restarting monitor with CLAWEDR_TARGET_BINARY so it tracks our mock...'
83+
pkill -f "monitor.py" 2>/dev/null || true
84+
sleep 2
85+
rm -f /tmp/clawedr-monitor.pid
86+
CLAWEDR_TARGET_BINARY="$MOCK_SCRIPT" CLAWEDR_POLICY_PATH=/usr/local/share/clawedr/compiled_policy.json \
87+
CLAWEDR_BPF_SOURCE=/usr/local/share/clawedr/bpf_hooks.c CLAWEDR_LOG_FILE=/var/log/clawedr_monitor.log \
88+
nohup python3 /usr/local/share/clawedr/monitor.py >/dev/null 2>&1 &
89+
sleep 3
90+
91+
echo '[Linux] Running mock script (tracked via CLAWEDR_TARGET_BINARY)...'
92+
"$MOCK_SCRIPT" &
8793
MOCK_PID=$!
8894
8995
# Give alerts time to buffer and flush to dashboard
90-
sleep 15
96+
sleep 10
9197
ALERTS=$(curl -s http://localhost:8477/api/alerts)
9298
9399
echo -e '\n--- Linux Alert Report ---'
@@ -117,9 +123,26 @@ else
117123
echo '[FAIL] Missing path block alert!'
118124
fi
119125
126+
if echo "$ALERTS" | grep -q '"rule_id": "DOM-016"' && echo "$ALERTS" | grep -q 'pastebin'; then
127+
echo '[PASS] Domain block alert generated (argv filter, DOM-016)!'
128+
elif echo "$ALERTS" | grep -q 'pastebin' && echo "$ALERTS" | grep -q 'DOM-'; then
129+
echo '[PASS] Domain block alert generated (argv filter)!'
130+
else
131+
echo '[FAIL] Missing domain block alert (must be DOM rule, not BIN - curl is allowed)!'
132+
fi
133+
120134
echo '[*] Cleanup...'
121135
kill -9 $MOCK_PID 2>/dev/null || true
122-
systemctl stop clawedr.service 2>/dev/null || true
136+
rm -f "$MOCK_SCRIPT" 2>/dev/null || true
137+
# Restart monitor normally (without CLAWEDR_TARGET_BINARY)
138+
pkill -f "monitor.py" 2>/dev/null || true
139+
sleep 2
140+
rm -f /tmp/clawedr-monitor.pid
141+
CLAWEDR_POLICY_PATH=/usr/local/share/clawedr/compiled_policy.json \
142+
CLAWEDR_BPF_SOURCE=/usr/local/share/clawedr/bpf_hooks.c CLAWEDR_LOG_FILE=/var/log/clawedr_monitor.log \
143+
nohup python3 /usr/local/share/clawedr/monitor.py >/dev/null 2>&1 &
144+
# Truncate block log so dashboard stops showing test alerts
145+
: > /var/log/clawedr.log 2>/dev/null || true
123146
ORBEOF
124147

125148
echo -e "${GREEN}Linux Test Complete.${NC}"

0 commit comments

Comments
 (0)