6262
6363_bpf_instance = None
6464_deny_rules : dict [str , dict ] = {}
65+ _blocked_domains : dict [str , str ] = {} # rule_id -> domain, for fast connect-time check
6566_malicious_hashes : dict [str , str ] = {}
6667_hash_to_rule_id : dict [int , str ] = {}
6768_ip_to_rule_id : dict [str , str ] = {}
@@ -219,6 +220,11 @@ def _print_event(cpu, data, size):
219220 pid = ns_pid ,
220221 comm = comm ,
221222 )
223+ elif event .action == 3 and filename == "CONNECT_ATTEMPT" :
224+ # Connect not in blocked_ips: check cmdline for blocked domains (argv-based)
225+ ip_int = getattr (event , "blocked_ip" , 0 )
226+ ip_str = _ip_int_to_str (ip_int ) if ip_int else "0.0.0.0"
227+ _check_connect_domain_rules (ns_pid , comm , ip_str )
222228 elif event .action == 1 :
223229 # Blocked by BPF — find the matching rule ID
224230 rule_id = _find_rule_id_for_block (filename )
@@ -235,12 +241,10 @@ def _print_event(cpu, data, size):
235241 )
236242 elif event .action == 2 :
237243 # Post-exec (sys_exit_execve): /proc/cmdline now has the new argv.
238-
239- # Layer 4: Userspace file hash check
240- _check_malicious_hashes (ns_pid , comm , filename )
241-
242- # Layer 2: Userspace deny_rules check
244+ # Check deny_rules (incl. domains) FIRST — fast, no file I/O
243245 matched_rule = _check_deny_rules (ns_pid , comm , filename )
246+ if not matched_rule :
247+ _check_malicious_hashes (ns_pid , comm , filename )
244248 if not matched_rule :
245249 logger .debug (
246250 "[observed] pid=%d uid=%d comm=%s file=%s" ,
@@ -315,6 +319,51 @@ def _check_malicious_hashes(pid: int, comm: str, filename: str) -> str | None:
315319
316320 return None
317321
322+ # ---------------------------------------------------------------------------
323+ # Connect-time domain check (argv-based, no DNS)
324+ # ---------------------------------------------------------------------------
325+
326+ def _check_connect_domain_rules (pid : int , comm : str , ip_str : str ) -> str | None :
327+ """At connect(): read cmdline, check for blocked domains. Kill if match.
328+ Provides a second chance to block when exec-time check raced."""
329+ if not _blocked_domains :
330+ return None
331+ try :
332+ with open (f"/proc/{ pid } /cmdline" , "rb" ) as f :
333+ raw = f .read (4096 )
334+ except OSError :
335+ return None
336+
337+ cmdline = raw .replace (b"\x00 " , b" " ).decode (errors = "replace" ).strip ()
338+ if not cmdline :
339+ return None
340+
341+ # Exempt our own alert dispatch
342+ if "openclaw agent" in cmdline and "ClawEDR Alert" in cmdline :
343+ return None
344+
345+ for rule_id , domain in _blocked_domains .items ():
346+ if domain in cmdline :
347+ block_logger .warning (
348+ "BLOCKED [%s] (domain=connect) pid=%d comm=%s target=%s cmdline=%s" ,
349+ rule_id , pid , comm , ip_str , cmdline [:150 ],
350+ )
351+ dispatch_alert_async (
352+ rule_id = rule_id ,
353+ action = "deny_rule" ,
354+ target = cmdline [:200 ],
355+ pid = pid ,
356+ comm = comm ,
357+ )
358+ try :
359+ os .kill (pid , 9 )
360+ except OSError :
361+ pass
362+ return rule_id
363+
364+ return None
365+
366+
318367# ---------------------------------------------------------------------------
319368# Layer 2: deny_rules — userspace argv matching
320369# ---------------------------------------------------------------------------
@@ -348,6 +397,12 @@ def _check_deny_rules(pid: int, comm: str, filename: str) -> str | None:
348397 except (OSError , IndexError , ValueError ):
349398 pass
350399
400+ # Exempt our own alert dispatch: openclaw agent --message "ClawEDR Alert..."
401+ # The message embeds the blocked cmdline and rule descriptions (e.g. "port 4444")
402+ # which can falsely match LIN-001 and cause an alert cascade.
403+ if "openclaw agent" in cmdline and "ClawEDR Alert" in cmdline :
404+ return None
405+
351406 for rule_id , rule in _deny_rules .items ():
352407 pattern = rule .get ("match" , "" )
353408 if not pattern :
@@ -637,30 +692,36 @@ def _apply_blocked_paths(policy: dict, exempted: set[str]) -> None:
637692# ---------------------------------------------------------------------------
638693
639694def _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."""
695+ """Load deny_rules for argv matching. blocked_domains are injected as domain-in-cmdline rules.
696+ Domain rules are loaded FIRST so they take precedence over generic LIN-* rules (e.g. LIN-001
697+ *4444* would otherwise match alert messages that mention port 4444)."""
641698 global _deny_rules
642699 raw = policy .get ("deny_rules" , {})
643700 if isinstance (raw , dict ) and "linux" in raw :
644701 raw = raw .get ("linux" , {})
645702
646703 _deny_rules = {}
647704 skipped = 0
648- for rule_id , rule in raw .items ():
705+
706+ # Load domain rules FIRST so they match before generic rules (curl to blocked domain -> DOM-xxx)
707+ global _blocked_domains
708+ _blocked_domains = {}
709+ for rule_id , domain in policy .get ("blocked_domains" , {}).items ():
649710 if rule_id in exempted :
650- logger .info ("Skipping exempted deny_rule %s" , rule_id )
651711 skipped += 1
652712 continue
653- if isinstance ( rule , dict ) and rule . get ( "match" ):
654- _deny_rules [rule_id ] = rule
713+ _blocked_domains [ rule_id ] = domain
714+ _deny_rules [rule_id ] = { "match" : f"* { domain } *" , " rule" : "blocked_domain" , "scope" : "argv" }
655715
656- # Inject blocked_domains as argv deny_rules (match cmdline containing domain)
657- for rule_id , domain in policy .get ("blocked_domains" , {}).items ():
716+ for rule_id , rule in raw .items ():
658717 if rule_id in exempted :
718+ logger .info ("Skipping exempted deny_rule %s" , rule_id )
659719 skipped += 1
660720 continue
661721 if rule_id in _deny_rules :
662722 continue
663- _deny_rules [rule_id ] = {"match" : f"*{ domain } *" , "rule" : "blocked_domain" , "scope" : "argv" }
723+ if isinstance (rule , dict ) and rule .get ("match" ):
724+ _deny_rules [rule_id ] = rule
664725
665726 logger .info ("Loaded %d deny_rules for userspace matching (%d skipped)" ,
666727 len (_deny_rules ), skipped )
@@ -690,7 +751,7 @@ def _apply_blocked_ips(policy: dict, exempted: set[str]) -> None:
690751 global _ip_to_rule_id
691752 if _bpf_instance is None :
692753 return
693-
754+
694755 raw_ips = policy .get ("blocked_ips" , {})
695756
696757 _ip_to_rule_id = {}
@@ -807,7 +868,7 @@ def _stop(signum, frame):
807868
808869 if _bpf_instance is not None :
809870 try :
810- _bpf_instance .perf_buffer_poll (timeout = 50 )
871+ _bpf_instance .perf_buffer_poll (timeout = 5 )
811872 except Exception :
812873 logger .exception ("Crash in perf_buffer_poll" )
813874
0 commit comments