From 8de66ef32e2a4bc44273b445189a8a4626c1b9de Mon Sep 17 00:00:00 2001 From: JJcyborg Date: Mon, 8 Jun 2026 23:28:04 +0000 Subject: [PATCH 1/2] feat: add Falco rules and tests for Linux persistence techniques (refs #37) --- falco-config.yaml | 1 + rules/persistence_techniques.yaml | 787 ++++++++++++++++++++++++++++ tests/conftest.py | 6 + tests/test_persistence_detection.py | 313 +++++++++++ 4 files changed, 1107 insertions(+) create mode 100644 rules/persistence_techniques.yaml create mode 100644 tests/test_persistence_detection.py diff --git a/falco-config.yaml b/falco-config.yaml index 73a5f6b..0d67de9 100644 --- a/falco-config.yaml +++ b/falco-config.yaml @@ -1,6 +1,7 @@ # Falco configuration for local sidekick forwarding rules_files: + - /etc/falco/rules.d/persistence_techniques.yaml - /etc/falco/falco_rules.yaml - /etc/falco/falco_rules.local.yaml - /etc/falco/rules.d diff --git a/rules/persistence_techniques.yaml b/rules/persistence_techniques.yaml new file mode 100644 index 0000000..01c5c21 --- /dev/null +++ b/rules/persistence_techniques.yaml @@ -0,0 +1,787 @@ +# ============================================================================= +# Persistence Techniques Detection Rules +# Reference: https://swisskyrepo.github.io/InternalAllTheThings/redteam/persistence/linux-persistence/ +# Issue: #37 - Incorporate monitoring for persistence techniques +# +# Covers the following MITRE ATT&CK persistence techniques: +# T1053.003 - Scheduled Task/Job: Cron +# T1053.005 - Scheduled Task/Job: At +# T1098.004 - SSH Authorized Keys +# T1543.002 - Systemd Service +# T1546.004 - .bashrc / .bash_profile / Shell Profile Modification +# T1574.006 - LD_PRELOAD / LD_LIBRARY_PATH Hijacking +# T1548.001 - Setuid/Setgid Binary Abuse +# T1037.001 - Boot/Logon Initialization Scripts: Logon Scripts (Linux) +# T1546.005 - Event Triggered Execution: .bashrc and .bash_profile +# T1136.001 - Create Account: Local Account +# ============================================================================= + +# --------------------------------------------------------------------------- +# Cron Jobs / At Jobs Persistence (T1053.003, T1053.005) +# --------------------------------------------------------------------------- + +- list: cron_directories + items: + - /etc/crontab + - /etc/cron.d/ + - /etc/cron.daily/ + - /etc/cron.hourly/ + - /etc/cron.weekly/ + - /etc/cron.monthly/ + - /var/spool/cron/ + - /var/spool/cron/crontabs/ + - /var/spool/at/ + - /var/spool/at/spool/ + +- list: cron_admin_binaries + items: + - crond + - cron + - anacron + - run-parts + - systemd + - dpkg + - apt + - apt-get + - yum + - dnf + - packagekit + +- rule: Cron Directory or Spool File Write by Non-Admin + desc: > + Detect direct file writes to system cron directories or at spool directories + by processes other than known cron daemons and package managers. + Adversaries write cron job files to gain scheduled execution persistence. + Reference: T1053.003, T1053.005 + condition: > + open_write + and ( + fd.name startswith "/etc/cron.d/" + or fd.name startswith "/etc/cron.daily/" + or fd.name startswith "/etc/cron.hourly/" + or fd.name startswith "/etc/cron.weekly/" + or fd.name startswith "/etc/cron.monthly/" + or fd.name = "/etc/crontab" + or fd.name startswith "/var/spool/cron/" + or fd.name startswith "/var/spool/at/" + ) + and not proc.name in (cron_admin_binaries) + and not proc.name in (crontab, at, atq, atrm, systemctl) + output: > + Unexpected write to cron/at directory | user=%user.name user_uid=%user.uid + process=%proc.name proc_exepath=%proc.exepath parent=%proc.pname + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1053.003, + T1053.005, + ] + +- rule: At Job Scheduling Command Execution + desc: > + Detect execution of the 'at' or 'atq' command which can schedule persistent + tasks. One-time scheduled tasks via at are a common persistence mechanism. + Reference: T1053.005 + condition: > + spawned_process + and proc.name in (at, atq, atrm, batch) + output: > + At job scheduling command executed | user=%user.name user_uid=%user.uid + process=%proc.name command=%proc.cmdline parent=%proc.pname + priority: NOTICE + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + T1053.005, + ] + +- rule: Crontab Execution by Non-Standard Parent + desc: > + Detect crontab being executed from an unexpected parent process such as + a web server or reverse shell, which may indicate programmatic cron + manipulation for persistence. + Reference: T1053.003 + condition: > + spawned_process + and proc.name = crontab + and not proc.pname in (bash, sh, dash, zsh, fish, ksh, tmux, screen, systemd, sshd, cron, sudo) + output: > + Crontab executed from non-standard parent | user=%user.name user_uid=%user.uid + process=%proc.name parent=%proc.pname command=%proc.cmdline + priority: WARNING + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + T1053.003, + ] + +# --------------------------------------------------------------------------- +# SSH Authorized Keys Manipulation (T1098.004) +# --------------------------------------------------------------------------- + +- list: ssh_authorized_keys_paths + items: + - /.ssh/authorized_keys + - /.ssh/authorized_keys2 + +- list: ssh_admin_binaries + items: + - ssh-keygen + - ansible + - puppet + - chef + - salt-minion + - sshd + - sftp-server + +- rule: SSH Authorized_keys File Write + desc: > + Detect writes to user SSH authorized_keys files by any process not in the + allow-list. Attackers add their public key to maintain persistent SSH access + without needing passwords. + Reference: T1098.004 + condition: > + open_write + and fd.name endswith "/.ssh/authorized_keys" + and not proc.name in (ssh_admin_binaries) + output: > + SSH authorized_keys file written by unexpected process | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1098.004, + ] + +- rule: SSH Authorized_keys2 File Write + desc: > + Detect writes to the legacy authorized_keys2 file, which some SSH + configurations still honour. Adversaries may target this file as an + alternative persistence path. + Reference: T1098.004 + condition: > + open_write + and fd.name endswith "/.ssh/authorized_keys2" + and not proc.name in (ssh_admin_binaries) + output: > + SSH authorized_keys2 file written by unexpected process | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1098.004, + ] + +- rule: SSH Config Modification for Persistence + desc: > + Detect modifications to user or system SSH config files that could weaken + authentication (e.g., disabling password authentication is fine, but enabling + PermitRootLogin or PermitEmptyPasswords from unexpected processes is not). + Reference: T1098.004, T1078 + condition: > + open_write + and ( + fd.name = "/etc/ssh/sshd_config" + or fd.name startswith "/etc/ssh/sshd_config.d/" + or fd.name endswith "/.ssh/config" + ) + and not proc.name in (sshd, ssh-keygen, dpkg, apt, apt-get, yum, dnf, systemctl, systemd, puppet, ansible) + output: > + SSH config file modified by unexpected process | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1098.004, + T1078, + ] + +# --------------------------------------------------------------------------- +# Systemd Service Creation/Modification (T1543.002) +# --------------------------------------------------------------------------- + +- list: systemd_service_directories + items: + - /etc/systemd/system/ + - /usr/lib/systemd/system/ + - /usr/local/lib/systemd/system/ + - /run/systemd/system/ + - /.config/systemd/user/ + +- list: systemd_admin_binaries + items: + - systemctl + - systemd + - dpkg + - apt + - apt-get + - yum + - dnf + - snapd + - packagekit + +- rule: Systemd Service Unit File Creation + desc: > + Detect creation of new systemd service unit files in system or user + directories. Adversaries create persistent systemd services that execute + on boot or on a timer schedule. + Reference: T1543.002 + condition: > + open_write + and ( + fd.name startswith "/etc/systemd/system/" + or fd.name startswith "/usr/lib/systemd/system/" + or fd.name startswith "/usr/local/lib/systemd/system/" + or fd.name startswith "/run/systemd/system/" + ) + and (fd.name endswith ".service" or fd.name endswith ".socket" or fd.name endswith ".target") + and not proc.name in (systemd_admin_binaries) + and not proc.name in (ansible, puppet, chef-client, salt-minion) + output: > + Systemd service unit file created or modified | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1543.002, + ] + +- rule: Systemd Timer Unit Creation + desc: > + Detect creation or modification of systemd timer unit files. Timers are the + systemd equivalent of cron jobs and can be abused for scheduled execution + persistence. + Reference: T1543.002, T1053.006 + condition: > + open_write + and fd.name endswith ".timer" + and ( + fd.name startswith "/etc/systemd/" + or fd.name startswith "/usr/lib/systemd/" + or fd.name startswith "/usr/local/lib/systemd/" + or fd.name startswith "/run/systemd/" + or fd.name contains "/.config/systemd/user/" + ) + and not proc.name in (systemd_admin_binaries, ansible, puppet, chef-client, salt-minion) + output: > + Systemd timer unit file created or modified | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1543.002, + T1053.006, + ] + +- rule: Systemd User Service Creation + desc: > + Detect creation of systemd user-level service files. User systemd services + run under the user's session and provide persistence without root privileges. + Reference: T1543.002 + condition: > + open_write + and fd.name contains "/.config/systemd/user/" + and (fd.name endswith ".service" or fd.name endswith ".timer") + and not proc.name in (systemctl, systemd, ansible, puppet, salt-minion) + output: > + Systemd user service file created or modified | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1543.002, + ] + +- rule: Systemctl Enable or Start by Non-Admin + desc: > + Detect systemctl enable or start commands for service units that are not + standard system daemons, issued by non-root users. This can indicate an + adversary activating a persistence service. + Reference: T1543.002 + condition: > + spawned_process + and proc.name = systemctl + and (proc.args contains "enable" or proc.args contains "start") + and user.uid != 0 + output: > + Non-root user enabling or starting systemd service | + user=%user.name user_uid=%user.uid command=%proc.cmdline + priority: NOTICE + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + T1543.002, + ] + +# --------------------------------------------------------------------------- +# Bash Profile / RC Modification (T1546.004, T1546.005) +# --------------------------------------------------------------------------- + +- list: shell_profile_files + items: + - /.bashrc + - /.bash_profile + - /.bash_login + - /.bash_logout + - /.profile + - /.zshrc + - /.zprofile + - /.kshrc + +- list: shell_profile_writers + items: + - bash + - sh + - dash + - zsh + - ksh + - dpkg + - apt + - apt-get + - yum + - dnf + - setup + - ansible + - puppet + - chef-client + +- rule: Shell Profile or RC File Modification + desc: > + Detect modifications to user shell profile files (.bashrc, .bash_profile, + .profile, .zshrc, etc.) by unexpected processes. Adversaries modify these + files to execute code whenever a user logs in. + Reference: T1546.004, T1546.005 + condition: > + open_write + and ( + fd.name endswith "/.bashrc" + or fd.name endswith "/.bash_profile" + or fd.name endswith "/.bash_login" + or fd.name endswith "/.bash_logout" + or fd.name endswith "/.profile" + or fd.name endswith "/.zshrc" + or fd.name endswith "/.zprofile" + or fd.name endswith "/.kshrc" + ) + and not proc.name in (shell_profile_writers) + output: > + Shell profile file modified by unexpected process | user=%user.name + user_uid=%user.uid process=%proc.name command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1546.004, + T1546.005, + ] + +- rule: System-wide Shell Profile Modification + desc: > + Detect modifications to system-wide shell initialization files such as + /etc/profile, /etc/profile.d/, /etc/bash.bashrc, /etc/environment. + These files affect all users and are high-value persistence targets. + Reference: T1546.004, T1037.001 + condition: > + open_write + and ( + fd.name = "/etc/profile" + or fd.name = "/etc/bash.bashrc" + or fd.name = "/etc/environment" + or fd.name startswith "/etc/profile.d/" + or fd.name = "/etc/zsh/zshrc" + or fd.name = "/etc/zsh/zprofile" + ) + and not proc.name in (dpkg, apt, apt-get, yum, dnf, setup, ansible, puppet, chef-client, salt-minion) + output: > + System-wide shell profile modified by unexpected process | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1546.004, + T1037.001, + ] + +# --------------------------------------------------------------------------- +# LD_PRELOAD / LD_LIBRARY_PATH Injection (T1574.006) +# --------------------------------------------------------------------------- + +- rule: LD_LIBRARY_PATH Hijack Attempt + desc: > + Detect process executions where LD_LIBRARY_PATH is set in the environment, + which can redirect dynamic library loading to attacker-controlled paths. + This is a persistence and privilege escalation technique. + Reference: T1574.006 + condition: > + spawned_process + and proc.env contains "LD_LIBRARY_PATH=" + and not proc.name in (sudo, systemd, bash, sh, dash, zsh, fish, ksh) + output: > + Process executed with LD_LIBRARY_PATH set | user=%user.name + user_uid=%user.uid process=%proc.name command=%proc.cmdline env=%proc.env + priority: WARNING + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1574.006, + ] + +- rule: LD_PRELOAD Execution from Non-Standard Path + desc: > + Detect LD_PRELOAD environment variable pointing to a library in a + non-standard location (e.g. /tmp, /dev/shm, home directory). Attackers + commonly place shared objects in world-writable directories. + Reference: T1574.006 + condition: > + spawned_process + and proc.env contains "LD_PRELOAD=" + and ( + proc.env contains "LD_PRELOAD=/tmp/" + or proc.env contains "LD_PRELOAD=/dev/shm/" + or proc.env contains "LD_PRELOAD=/var/tmp/" + or proc.env contains "LD_PRELOAD=/home/" + or proc.env contains "LD_PRELOAD=." + ) + output: > + LD_PRELOAD pointing to suspicious path detected | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline env=%proc.env + priority: CRITICAL + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1574.006, + ] + +- rule: etc ld.so.preload File Write + desc: > + Detect writes to /etc/ld.so.preload which forces the dynamic linker to + preload shared objects for all executables system-wide. This is the most + dangerous LD_PRELOAD persistence path. + Reference: T1574.006 + condition: > + open_write + and fd.name = "/etc/ld.so.preload" + output: > + Write to /etc/ld.so.preload detected | user=%user.name user_uid=%user.uid + process=%proc.name command=%proc.cmdline + priority: CRITICAL + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1574.006, + ] + +# --------------------------------------------------------------------------- +# Setuid / Setgid Binary Creation (T1548.001) +# --------------------------------------------------------------------------- + +- rule: Setuid Binary Creation via chmod + desc: > + Detect chmod operations that set the setuid bit on any binary. Setuid + binaries execute as the file owner (often root), enabling privilege + escalation and persistence. + Reference: T1548.001 + condition: > + spawned_process + and proc.name = chmod + and ( + proc.args contains "u+s" + or proc.args contains "4755" + or proc.args contains "4777" + or proc.args contains "4711" + ) + output: > + Setuid bit set on binary via chmod | user=%user.name user_uid=%user.uid + command=%proc.cmdline + priority: WARNING + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1548.001, + ] + +- rule: Setgid Binary Creation via chmod + desc: > + Detect chmod operations that set the setgid bit on any binary. Setgid + binaries execute with the group privileges of the file, which can be + abused for persistence and privilege escalation. + Reference: T1548.001 + condition: > + spawned_process + and proc.name = chmod + and ( + proc.args contains "g+s" + or proc.args contains "2755" + or proc.args contains "2777" + or proc.args contains "2711" + ) + output: > + Setgid bit set on binary via chmod | user=%user.name user_uid=%user.uid + command=%proc.cmdline + priority: WARNING + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1548.001, + ] + +- rule: Setuid Setgid Binary Creation via install + desc: > + Detect use of the install command with -m flag setting setuid/setgid bits. + The install command can set file permissions atomically and is sometimes + used in favour of separate cp + chmod sequences. + Reference: T1548.001 + condition: > + spawned_process + and proc.name = install + and proc.args contains "-m" + and ( + proc.args contains "4755" + or proc.args contains "4711" + or proc.args contains "4777" + or proc.args contains "2755" + or proc.args contains "2711" + or proc.args contains "2777" + or proc.args contains "u+s" + or proc.args contains "g+s" + ) + output: > + install command setting setuid/setgid bits | user=%user.name + user_uid=%user.uid command=%proc.cmdline + priority: WARNING + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1548.001, + ] + +# --------------------------------------------------------------------------- +# Additional Persistence Techniques from InternalAllTheThings Reference +# --------------------------------------------------------------------------- + +- rule: PAM Module Configuration Modification + desc: > + Detect modifications to PAM (Pluggable Authentication Modules) configuration + files. Adversaries can install malicious PAM modules or modify existing + configs for backdoor authentication. + Reference: T1546.003, T1556.003 + condition: > + open_write + and ( + fd.name startswith "/etc/pam.d/" + or fd.name = "/etc/pam.conf" + ) + and not proc.name in (dpkg, apt, apt-get, yum, dnf, authconfig, pam-auth-update, systemd) + output: > + PAM configuration file modified by unexpected process | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: CRITICAL + tags: + [ + maturity_incubating, + host, + filesystem, + linux, + mitre_persistence, + T1546.003, + T1556.003, + ] + +- rule: User Account Creation via useradd or adduser + desc: > + Detect creation of new user accounts. Adversaries may create local accounts + for persistence, especially those with UID 0 (root-equivalent). + Reference: T1136.001 + condition: > + spawned_process + and proc.name in (useradd, adduser, newusers) + output: > + User account creation command executed | user=%user.name user_uid=%user.uid + process=%proc.name command=%proc.cmdline + priority: NOTICE + tags: + [ + maturity_stable, + host, + process, + linux, + mitre_persistence, + T1136.001, + ] + +- rule: Password File Direct Modification + desc: > + Detect direct writes to /etc/passwd or /etc/shadow by processes other than + standard account management utilities. This could indicate an adversary + directly adding or modifying user entries for persistence. + Reference: T1136.001 + condition: > + open_write + and (fd.name = "/etc/passwd" or fd.name = "/etc/shadow" or fd.name = "/etc/group" or fd.name = "/etc/gshadow") + and not proc.name in (useradd, adduser, usermod, passwd, chpasswd, newusers, groupadd, groupmod, vigr, vipw, dpkg, apt, apt-get, yum, dnf, systemd, login) + output: > + Direct modification of user/password database file | + user=%user.name user_uid=%user.uid process=%proc.name + command=%proc.cmdline file=%fd.name + priority: CRITICAL + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + T1136.001, + ] + +- rule: XDG Autostart Entry Modification + desc: > + Detect creation or modification of XDG autostart .desktop files, which + cause programs to run automatically on user login via the desktop + environment. + Reference: T1547.001 + condition: > + open_write + and ( + fd.name startswith "/etc/xdg/autostart/" + or fd.name contains "/.config/autostart/" + ) + and fd.name endswith ".desktop" + and not proc.name in (dpkg, apt, apt-get, yum, dnf, snap, gnome-software, kdeinit5) + output: > + XDG autostart desktop entry modified | user=%user.name user_uid=%user.uid + process=%proc.name command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_incubating, + host, + filesystem, + linux, + mitre_persistence, + T1547.001, + ] + +- rule: Shared Library Injection via /etc/ld.so.conf + desc: > + Detect modifications to /etc/ld.so.conf or files in /etc/ld.so.conf.d/ + which control the dynamic linker's library search path. Attackers can add + paths to directories containing malicious shared libraries. + Reference: T1574.006 + condition: > + open_write + and ( + fd.name = "/etc/ld.so.conf" + or fd.name startswith "/etc/ld.so.conf.d/" + ) + and not proc.name in (ldconfig, dpkg, apt, apt-get, yum, dnf) + output: > + Dynamic linker configuration file modified | user=%user.name + user_uid=%user.uid process=%proc.name command=%proc.cmdline file=%fd.name + priority: WARNING + tags: + [ + maturity_stable, + host, + filesystem, + linux, + mitre_persistence, + mitre_privilege_escalation, + T1574.006, + ] diff --git a/tests/conftest.py b/tests/conftest.py index dda6d1f..68a4145 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,3 +110,9 @@ def osquery_flags() -> list[str]: @pytest.fixture(scope="session") def otel_collector_config() -> dict: return _load_yaml("otel-collector-config.yaml") + + +@pytest.fixture(scope="session") +def persistence_rules(): + """Load and return rules/persistence_techniques.yaml.""" + return _load_yaml("rules/persistence_techniques.yaml") diff --git a/tests/test_persistence_detection.py b/tests/test_persistence_detection.py new file mode 100644 index 0000000..f2d7875 --- /dev/null +++ b/tests/test_persistence_detection.py @@ -0,0 +1,313 @@ +"""Tests for rules/persistence_techniques.yaml + +Validates that the persistence detection Falco rules file: + 1. Loads as valid YAML + 2. Contains expected rule definitions for each persistence category + 3. Each rule has the required Falco rule fields (desc, condition, output, priority, tags) + 4. MITRE ATT&CK technique tags are present and correctly formatted +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE_RULES_PATH = REPO_ROOT / "rules" / "persistence_techniques.yaml" + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def persistence_rules(): + """Load and return the persistence techniques rules YAML.""" + raw = PERSISTENCE_RULES_PATH.read_text(encoding="utf-8") + data = yaml.safe_load(raw) + assert data is not None, "persistence_techniques.yaml loaded as None" + return data + + +@pytest.fixture(scope="module") +def parsed_rules(persistence_rules): + """Return a dict mapping rule name -> rule definition for rule-type items.""" + items = persistence_rules if isinstance(persistence_rules, list) else [persistence_rules] + rules = {} + for item in items: + if isinstance(item, dict) and "rule" in item: + rules[item["rule"]] = item + return rules + + +@pytest.fixture(scope="module") +def parsed_macros(persistence_rules): + """Return a dict mapping macro name -> macro definition for macro-type items.""" + items = persistence_rules if isinstance(persistence_rules, list) else [persistence_rules] + macros = {} + for item in items: + if isinstance(item, dict) and "macro" in item: + macros[item["macro"]] = item + return macros + + +@pytest.fixture(scope="module") +def parsed_lists(persistence_rules): + """Return a dict mapping list name -> list definition for list-type items.""" + items = persistence_rules if isinstance(persistence_rules, list) else [persistence_rules] + lists = {} + for item in items: + if isinstance(item, dict) and "list" in item: + lists[item["list"]] = item + return lists + + +# --------------------------------------------------------------------------- +# Required rules by persistence category (issue #37) +# --------------------------------------------------------------------------- + +REQUIRED_RULES = { + "cron_at": { + "rule_names": [ + "Cron Directory or Spool File Write by Non-Admin", + "At Job Scheduling Command Execution", + "Crontab Execution by Non-Standard Parent", + ], + "min_match": 1, + "technique_tags": ["T1053.003", "T1053.005"], + }, + "ssh_authorized_keys": { + "rule_names": [ + "SSH Authorized_keys File Write", + "SSH Authorized_keys2 File Write", + "SSH Config Modification for Persistence", + ], + "min_match": 1, + "technique_tags": ["T1098.004"], + }, + "systemd": { + "rule_names": [ + "Systemd Service Unit File Creation", + "Systemd Timer Unit Creation", + "Systemd User Service Creation", + "Systemctl Enable or Start by Non-Admin", + ], + "min_match": 1, + "technique_tags": ["T1543.002"], + }, + "bash_profile_rc": { + "rule_names": [ + "Shell Profile or RC File Modification", + "System-wide Shell Profile Modification", + ], + "min_match": 1, + "technique_tags": ["T1546.004"], + }, + "ld_preload_library_path": { + "rule_names": [ + "LD_LIBRARY_PATH Hijack Attempt", + "LD_PRELOAD Execution from Non-Standard Path", + "etc ld.so.preload File Write", + ], + "min_match": 1, + "technique_tags": ["T1574.006"], + }, + "setuid_setgid": { + "rule_names": [ + "Setuid Binary Creation via chmod", + "Setgid Binary Creation via chmod", + "Setuid Setgid Binary Creation via install", + ], + "min_match": 1, + "technique_tags": ["T1548.001"], + }, +} + +REQUIRED_RULE_NAME_SET = set() +for _cat in REQUIRED_RULES.values(): + REQUIRED_RULE_NAME_SET.update(_cat["rule_names"]) + +VALID_PRIORITIES = { + "EMERGENCY", "ALERT", "CRITICAL", "ERROR", + "WARNING", "NOTICE", "INFORMATIONAL", "DEBUG", +} + +# --------------------------------------------------------------------------- +# Structural tests +# --------------------------------------------------------------------------- + + +class TestPersistenceRulesFile: + """Validate the persistence techniques YAML file structure.""" + + def test_file_exists(self): + assert PERSISTENCE_RULES_PATH.is_file(), ( + f"persistence_techniques.yaml not found at {PERSISTENCE_RULES_PATH}" + ) + + def test_yaml_loads_as_list(self, persistence_rules): + assert isinstance(persistence_rules, list), ( + "Expected YAML to load as a list of items" + ) + + def test_contains_required_rule_count(self, parsed_rules): + found = set(parsed_rules.keys()) + missing = REQUIRED_RULE_NAME_SET - found + assert len(missing) == 0, ( + f"Missing required rules: {missing}. " + f"Found: {sorted(found)}" + ) + + def test_all_required_categories_covered(self, parsed_rules): + """Every persistence category from issue #37 must have at least one rule.""" + for category, spec in REQUIRED_RULES.items(): + found = any(name in parsed_rules for name in spec["rule_names"]) + assert found, ( + f"Category '{category}' has no matching rules. " + f"Expected at least one of: {spec['rule_names']}" + ) + + +class TestPersistenceRuleStructure: + """Validate that every rule has the required Falco fields.""" + + @pytest.mark.parametrize("rule_name", REQUIRED_RULE_NAME_SET) + def test_rule_has_description(self, parsed_rules, rule_name): + assert rule_name in parsed_rules, f"Rule '{rule_name}' not found" + rule = parsed_rules[rule_name] + assert "desc" in rule, f"Rule '{rule_name}' missing 'desc' field" + + @pytest.mark.parametrize("rule_name", REQUIRED_RULE_NAME_SET) + def test_rule_has_condition(self, parsed_rules, rule_name): + assert rule_name in parsed_rules + rule = parsed_rules[rule_name] + assert "condition" in rule, f"Rule '{rule_name}' missing 'condition' field" + + @pytest.mark.parametrize("rule_name", REQUIRED_RULE_NAME_SET) + def test_rule_has_output(self, parsed_rules, rule_name): + assert rule_name in parsed_rules + rule = parsed_rules[rule_name] + assert "output" in rule, f"Rule '{rule_name}' missing 'output' field" + + @pytest.mark.parametrize("rule_name", REQUIRED_RULE_NAME_SET) + def test_rule_has_priority(self, parsed_rules, rule_name): + assert rule_name in parsed_rules + rule = parsed_rules[rule_name] + assert "priority" in rule, f"Rule '{rule_name}' missing 'priority' field" + assert rule["priority"] in VALID_PRIORITIES, ( + f"Rule '{rule_name}' has invalid priority: {rule['priority']}" + ) + + @pytest.mark.parametrize("rule_name", REQUIRED_RULE_NAME_SET) + def test_rule_has_tags(self, parsed_rules, rule_name): + assert rule_name in parsed_rules + rule = parsed_rules[rule_name] + assert "tags" in rule, f"Rule '{rule_name}' missing 'tags' field" + assert isinstance(rule["tags"], list), f"Rule '{rule_name}' tags must be a list" + assert len(rule["tags"]) > 0, f"Rule '{rule_name}' has empty tags list" + + +class TestPersistenceMitreTags: + """Ensure required MITRE ATT&CK technique IDs are present.""" + + @pytest.mark.parametrize("category", list(REQUIRED_RULES.keys())) + def test_category_has_mitre_technique_tags(self, parsed_rules, category): + spec = REQUIRED_RULES[category] + matching = [ + parsed_rules[name] + for name in spec["rule_names"] + if name in parsed_rules + ] + assert len(matching) > 0, f"No matching rules for category '{category}'" + + for rule in matching: + tags = rule.get("tags", []) + has_mitre_persistence = "mitre_persistence" in tags or "mitre_privilege_escalation" in tags + # At least one MITRE technique tag should be present + has_technique = any(t.startswith("T1") for t in tags) + assert has_technique, ( + f"Rule '{rule['rule']}' has no MITRE technique tags: {tags}" + ) + + def test_cron_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["cron_at"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert any(t in tags for t in ["T1053.003", "T1053.005"]), ( + f"Rule '{name}' missing expected cron technique tags" + ) + + def test_ssh_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["ssh_authorized_keys"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1098.004" in tags, ( + f"Rule '{name}' missing T1098.004 tag" + ) + + def test_systemd_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["systemd"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1543.002" in tags, ( + f"Rule '{name}' missing T1543.002 tag" + ) + + def test_bash_profile_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["bash_profile_rc"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1546.004" in tags or "T1546.005" in tags, ( + f"Rule '{name}' missing T1546.004/T1546.005 tag" + ) + + def test_ld_preload_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["ld_preload_library_path"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1574.006" in tags, ( + f"Rule '{name}' missing T1574.006 tag" + ) + + def test_setuid_rules_have_correct_technique(self, parsed_rules): + for name in REQUIRED_RULES["setuid_setgid"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1548.001" in tags, ( + f"Rule '{name}' missing T1548.001 tag" + ) + + +class TestPersistenceLists: + """Validate supporting list definitions.""" + + def test_cron_directories_list_exists(self, parsed_lists): + assert "cron_directories" in parsed_lists, "Missing 'cron_directories' list" + + def test_cron_admin_binaries_list_exists(self, parsed_lists): + assert "cron_admin_binaries" in parsed_lists, "Missing 'cron_admin_binaries' list" + + def test_ssh_admin_binaries_list_exists(self, parsed_lists): + assert "ssh_admin_binaries" in parsed_lists, "Missing 'ssh_admin_binaries' list" + + def test_systemd_service_directories_list_exists(self, parsed_lists): + assert "systemd_service_directories" in parsed_lists, "Missing 'systemd_service_directories' list" + + def test_systemd_admin_binaries_list_exists(self, parsed_lists): + assert "systemd_admin_binaries" in parsed_lists, "Missing 'systemd_admin_binaries' list" + + def test_shell_profile_files_list_exists(self, parsed_lists): + assert "shell_profile_files" in parsed_lists, "Missing 'shell_profile_files' list" + + def test_shell_profile_writers_list_exists(self, parsed_lists): + assert "shell_profile_writers" in parsed_lists, "Missing 'shell_profile_writers' list" + + +class TestNoDuplicateRuleNames: + """Ensure no duplicate rule names in the persistence rules file.""" + + def test_no_duplicate_rules(self, persistence_rules): + items = persistence_rules if isinstance(persistence_rules, list) else [persistence_rules] + names = [item["rule"] for item in items if isinstance(item, dict) and "rule" in item] + assert len(names) == len(set(names)), f"Duplicate rule names found: {[n for n in names if names.count(n) > 1]}" From 0fa6b3549d46083586ed220553d2efe18f7899d7 Mon Sep 17 00:00:00 2001 From: JJcyborg Date: Tue, 9 Jun 2026 02:37:19 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20#41=20review=20feedba?= =?UTF-8?q?ck=20=E2=80=94=20remove=20wrong=20T1546.005=20tag,=20delete=20u?= =?UTF-8?q?nused=20Falco=20lists,=20deduplicate=20fixture,=20fix=20rule=20?= =?UTF-8?q?name,=20tighten=20LD=5FPRELOAD=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rules/persistence_techniques.yaml | 49 ++++------------------------- tests/test_persistence_detection.py | 42 ++++++++++++------------- 2 files changed, 27 insertions(+), 64 deletions(-) diff --git a/rules/persistence_techniques.yaml b/rules/persistence_techniques.yaml index 01c5c21..f1a65d9 100644 --- a/rules/persistence_techniques.yaml +++ b/rules/persistence_techniques.yaml @@ -12,7 +12,6 @@ # T1574.006 - LD_PRELOAD / LD_LIBRARY_PATH Hijacking # T1548.001 - Setuid/Setgid Binary Abuse # T1037.001 - Boot/Logon Initialization Scripts: Logon Scripts (Linux) -# T1546.005 - Event Triggered Execution: .bashrc and .bash_profile # T1136.001 - Create Account: Local Account # ============================================================================= @@ -20,19 +19,6 @@ # Cron Jobs / At Jobs Persistence (T1053.003, T1053.005) # --------------------------------------------------------------------------- -- list: cron_directories - items: - - /etc/crontab - - /etc/cron.d/ - - /etc/cron.daily/ - - /etc/cron.hourly/ - - /etc/cron.weekly/ - - /etc/cron.monthly/ - - /var/spool/cron/ - - /var/spool/cron/crontabs/ - - /var/spool/at/ - - /var/spool/at/spool/ - - list: cron_admin_binaries items: - crond @@ -133,11 +119,6 @@ # SSH Authorized Keys Manipulation (T1098.004) # --------------------------------------------------------------------------- -- list: ssh_authorized_keys_paths - items: - - /.ssh/authorized_keys - - /.ssh/authorized_keys2 - - list: ssh_admin_binaries items: - ssh-keygen @@ -232,14 +213,6 @@ # Systemd Service Creation/Modification (T1543.002) # --------------------------------------------------------------------------- -- list: systemd_service_directories - items: - - /etc/systemd/system/ - - /usr/lib/systemd/system/ - - /usr/local/lib/systemd/system/ - - /run/systemd/system/ - - /.config/systemd/user/ - - list: systemd_admin_binaries items: - systemctl @@ -368,20 +341,9 @@ ] # --------------------------------------------------------------------------- -# Bash Profile / RC Modification (T1546.004, T1546.005) +# Bash Profile / RC Modification (T1546.004) # --------------------------------------------------------------------------- -- list: shell_profile_files - items: - - /.bashrc - - /.bash_profile - - /.bash_login - - /.bash_logout - - /.profile - - /.zshrc - - /.zprofile - - /.kshrc - - list: shell_profile_writers items: - bash @@ -404,7 +366,7 @@ Detect modifications to user shell profile files (.bashrc, .bash_profile, .profile, .zshrc, etc.) by unexpected processes. Adversaries modify these files to execute code whenever a user logs in. - Reference: T1546.004, T1546.005 + Reference: T1546.004 condition: > open_write and ( @@ -430,7 +392,7 @@ linux, mitre_persistence, T1546.004, - T1546.005, + ] - rule: System-wide Shell Profile Modification @@ -509,7 +471,8 @@ or proc.env contains "LD_PRELOAD=/dev/shm/" or proc.env contains "LD_PRELOAD=/var/tmp/" or proc.env contains "LD_PRELOAD=/home/" - or proc.env contains "LD_PRELOAD=." + or proc.env contains "LD_PRELOAD=./" + or proc.env contains "LD_PRELOAD=../" ) output: > LD_PRELOAD pointing to suspicious path detected | @@ -527,7 +490,7 @@ T1574.006, ] -- rule: etc ld.so.preload File Write +- rule: /etc/ld.so.preload File Write desc: > Detect writes to /etc/ld.so.preload which forces the dynamic linker to preload shared objects for all executables system-wide. This is the most diff --git a/tests/test_persistence_detection.py b/tests/test_persistence_detection.py index f2d7875..ca2c0ae 100644 --- a/tests/test_persistence_detection.py +++ b/tests/test_persistence_detection.py @@ -18,17 +18,9 @@ PERSISTENCE_RULES_PATH = REPO_ROOT / "rules" / "persistence_techniques.yaml" # --------------------------------------------------------------------------- -# Fixtures +# Fixtures (persistence_rules is defined in conftest.py) # --------------------------------------------------------------------------- -@pytest.fixture(scope="module") -def persistence_rules(): - """Load and return the persistence techniques rules YAML.""" - raw = PERSISTENCE_RULES_PATH.read_text(encoding="utf-8") - data = yaml.safe_load(raw) - assert data is not None, "persistence_techniques.yaml loaded as None" - return data - @pytest.fixture(scope="module") def parsed_rules(persistence_rules): @@ -108,7 +100,7 @@ def parsed_lists(persistence_rules): "rule_names": [ "LD_LIBRARY_PATH Hijack Attempt", "LD_PRELOAD Execution from Non-Standard Path", - "etc ld.so.preload File Write", + "/etc/ld.so.preload File Write", ], "min_match": 1, "technique_tags": ["T1574.006"], @@ -258,8 +250,17 @@ def test_bash_profile_rules_have_correct_technique(self, parsed_rules): for name in REQUIRED_RULES["bash_profile_rc"]["rule_names"]: if name in parsed_rules: tags = parsed_rules[name]["tags"] - assert "T1546.004" in tags or "T1546.005" in tags, ( - f"Rule '{name}' missing T1546.004/T1546.005 tag" + assert "T1546.004" in tags, ( + f"Rule '{name}' missing T1546.004 tag" + ) + + def test_no_t1546_005_in_shell_profile_rules(self, parsed_rules): + """Verify T1546.005 (Trap) is not mistakenly applied to shell profile rules.""" + for name in REQUIRED_RULES["bash_profile_rc"]["rule_names"]: + if name in parsed_rules: + tags = parsed_rules[name]["tags"] + assert "T1546.005" not in tags, ( + f"Rule '{name}' should not have T1546.005 tag (Trap, not shell profile)" ) def test_ld_preload_rules_have_correct_technique(self, parsed_rules): @@ -282,27 +283,26 @@ def test_setuid_rules_have_correct_technique(self, parsed_rules): class TestPersistenceLists: """Validate supporting list definitions.""" - def test_cron_directories_list_exists(self, parsed_lists): - assert "cron_directories" in parsed_lists, "Missing 'cron_directories' list" - def test_cron_admin_binaries_list_exists(self, parsed_lists): assert "cron_admin_binaries" in parsed_lists, "Missing 'cron_admin_binaries' list" def test_ssh_admin_binaries_list_exists(self, parsed_lists): assert "ssh_admin_binaries" in parsed_lists, "Missing 'ssh_admin_binaries' list" - def test_systemd_service_directories_list_exists(self, parsed_lists): - assert "systemd_service_directories" in parsed_lists, "Missing 'systemd_service_directories' list" - def test_systemd_admin_binaries_list_exists(self, parsed_lists): assert "systemd_admin_binaries" in parsed_lists, "Missing 'systemd_admin_binaries' list" - def test_shell_profile_files_list_exists(self, parsed_lists): - assert "shell_profile_files" in parsed_lists, "Missing 'shell_profile_files' list" - def test_shell_profile_writers_list_exists(self, parsed_lists): assert "shell_profile_writers" in parsed_lists, "Missing 'shell_profile_writers' list" + def test_all_lists_referenced_in_conditions(self, parsed_rules, parsed_lists): + """Every defined list must be referenced in at least one rule condition.""" + yaml_text = (REPO_ROOT / "rules" / "persistence_techniques.yaml").read_text() + for list_name in parsed_lists: + assert list_name in yaml_text.split("condition:")[0] or list_name in yaml_text, ( + f"List '{list_name}' is defined but never referenced in rule conditions" + ) + class TestNoDuplicateRuleNames: """Ensure no duplicate rule names in the persistence rules file."""