diff --git a/interface-definitions/service_suricata.xml.in b/interface-definitions/service_suricata.xml.in index e21320bfe..e0159e2ba 100644 --- a/interface-definitions/service_suricata.xml.in +++ b/interface-definitions/service_suricata.xml.in @@ -1,246 +1,238 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="service"> <children> <node name="suricata" owner="${vyos_conf_scripts_dir}/service_suricata.py"> <properties> <help>Network IDS, IPS and Security Monitoring</help> <priority>740</priority> </properties> <children> #include <include/generic-interface-multi.xml.i> <tagNode name="address-group"> <properties> <help>Address group name</help> - <completionHelp> - <list>home-net external-net http-servers smtp-servers sql-servers dns-servers telnet-servers aim-servers dc-servers dnp3-server dnp3-client modbus-client modbus-server enip-client enip-server</list> - </completionHelp> <constraint> <regex>[a-z0-9-]+</regex> </constraint> </properties> <children> <leafNode name="address"> <properties> <help>IP address or subnet</help> <valueHelp> <format>ipv4</format> <description>IPv4 address to match</description> </valueHelp> <valueHelp> <format>ipv6</format> <description>IPv6 address to match</description> </valueHelp> <valueHelp> <format>ipv4net</format> <description>IPv4 prefix to match</description> </valueHelp> <valueHelp> <format>ipv6net</format> <description>IPv6 prefix to match</description> </valueHelp> <valueHelp> <format>!ipv4</format> <description>Exclude the specified IPv4 address from matches</description> </valueHelp> <valueHelp> <format>!ipv6</format> <description>Exclude the specified IPv6 address from matches</description> </valueHelp> <valueHelp> <format>!ipv4net</format> <description>Exclude the specified IPv6 prefix from matches</description> </valueHelp> <valueHelp> <format>!ipv6net</format> <description>Exclude the specified IPv6 prefix from matches</description> </valueHelp> <constraint> <validator name="ipv4-address"/> <validator name="ipv6-address"/> <validator name="ipv4-prefix"/> <validator name="ipv6-prefix"/> <validator name="ipv4-address-exclude"/> <validator name="ipv6-address-exclude"/> <validator name="ipv4-prefix-exclude"/> <validator name="ipv6-prefix-exclude"/> </constraint> <multi/> </properties> </leafNode> <leafNode name="group"> <properties> <help>Address group</help> <completionHelp> <path>service ids suricata address-group</path> - <list>home-net external-net http-servers smtp-servers sql-servers dns-servers telnet-servers aim-servers dc-servers dnp3-server dnp3-client modbus-client modbus-server enip-client enip-server</list> </completionHelp> <valueHelp> - <format>string</format> + <format>txt</format> <description>Address group to match</description> </valueHelp> <valueHelp> - <format>!string</format> + <format>!txt</format> <description>Exclude the specified address group from matches</description> </valueHelp> <constraint> <regex>!?[a-z0-9-]+</regex> </constraint> <multi/> </properties> </leafNode> </children> </tagNode> <tagNode name="port-group"> <properties> <help>Port group name</help> - <completionHelp> - <list>http-ports shellcode-ports oracle-ports ssh-ports dnp3-ports modbus-ports file-data-ports ftp-ports geneve-ports vxlan-ports teredo-ports</list> - </completionHelp> <constraint> <regex>[a-z0-9-]+</regex> </constraint> </properties> <children> <leafNode name="port"> <properties> <help>Port number</help> <valueHelp> <format>u32:1-65535</format> <description>Numeric port to match</description> </valueHelp> <valueHelp> <format>!u32:1-65535</format> <description>Numeric port to exclude from matches</description> </valueHelp> <valueHelp> <format>start-end</format> <description>Numbered port range (e.g. 1001-1005) to match</description> </valueHelp> <valueHelp> <format>!start-end</format> <description>Numbered port range (e.g. !1001-1005) to exclude from matches</description> </valueHelp> <constraint> <validator name="port-range"/> <validator name="port-range-exclude"/> </constraint> <multi/> </properties> </leafNode> <leafNode name="group"> <properties> <help>Port group</help> <completionHelp> <path>service ids suricata port-group</path> - <list>http-ports shellcode-ports oracle-ports ssh-ports dnp3-ports modbus-ports file-data-ports ftp-ports geneve-ports vxlan-ports teredo-ports</list> </completionHelp> <valueHelp> - <format>string</format> + <format>txt</format> <description>Port group to match</description> </valueHelp> <valueHelp> - <format>!string</format> + <format>!txt</format> <description>Exclude the specified port group from matches</description> </valueHelp> <constraint> <regex>!?[a-z0-9-]+</regex> </constraint> <multi/> </properties> </leafNode> </children> </tagNode> <node name="log"> <properties> <help>Suricata log outputs</help> </properties> <children> <node name="eve"> <properties> <help>Extensible Event Format (EVE)</help> </properties> <children> <leafNode name="filetype"> <properties> <help>EVE logging destination</help> <completionHelp> <list>regular syslog</list> </completionHelp> <valueHelp> <format>regular</format> <description>Log to filename</description> </valueHelp> <valueHelp> <format>syslog</format> <description>Log to syslog</description> </valueHelp> <constraint> <regex>(regular|syslog)</regex> </constraint> </properties> <defaultValue>regular</defaultValue> </leafNode> <leafNode name="filename"> <properties> <help>Log file</help> <valueHelp> <format>filename</format> <description>File name in default Suricata log directory</description> </valueHelp> <valueHelp> <format>/path</format> <description>Absolute file path</description> </valueHelp> </properties> <defaultValue>eve.json</defaultValue> </leafNode> <leafNode name="type"> <properties> <help>Log types</help> <completionHelp> <list>alert anomaly drop files http dns tls smtp dnp3 ftp rdp nfs smb tftp ikev2 dcerpc krb5 snmp rfb sip dhcp ssh mqtt http2 flow netflow</list> </completionHelp> <valueHelp> <format>alert</format> <description>Record events for rule matches</description> </valueHelp> <valueHelp> <format>anomaly</format> <description>Record unexpected conditions such as truncated packets, packets with invalid IP/UDP/TCP length values, and other events that render the packet invalid for further processing or describe unexpected behavior on an established stream</description> </valueHelp> <valueHelp> <format>drop</format> <description>Record events for dropped packets</description> </valueHelp> <valueHelp> <format>file</format> <description>Record file details (e.g., MD5) for files extracted from application protocols (e.g., HTTP)</description> </valueHelp> <valueHelp> <format>application (http, dns, tls, ...)</format> <description>Record application-level transactions</description> </valueHelp> <valueHelp> <format>flow</format> <description>Record bi-directional flows</description> </valueHelp> <valueHelp> <format>netflow</format> <description>Record uni-directional flows</description> </valueHelp> <constraint> <regex>(alert|anomaly|http|dns|tls|files|drop|smtp|dnp3|ftp|rdp|nfs|smb|tftp|ikev2|dcerpc|krb5|snmp|rfb|sip|dhcp|ssh|mqtt|http2|flow|netflow)</regex> </constraint> <multi/> </properties> </leafNode> </children> </node> </children> </node> </children> </node> </children> </node> </interfaceDefinition> diff --git a/src/conf_mode/service_suricata.py b/src/conf_mode/service_suricata.py index cce4de6e3..06d68a637 100755 --- a/src/conf_mode/service_suricata.py +++ b/src/conf_mode/service_suricata.py @@ -1,190 +1,161 @@ #!/usr/bin/env python3 # # Copyright (C) 2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.template import render from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() config_file = '/run/suricata/suricata.yaml' rotate_file = '/etc/logrotate.d/suricata' -address_group_defaults = { - 'home-net': {'address': ['192.168.0.0/16','10.0.0.0/8','172.16.0.0/12']}, - 'external-net': {'group': ['!home-net']}, - 'http-servers': {'group': ['home-net']}, - 'smtp-servers': {'group': ['home-net']}, - 'sql-servers': {'group': ['home-net']}, - 'dns-servers': {'group': ['home-net']}, - 'telnet-servers': {'group': ['home-net']}, - 'aim-servers': {'group': ['external-net']}, - 'dc-servers': {'group': ['home-net']}, - 'dnp3-server': {'group': ['home-net']}, - 'modbus-client': {'group': ['home-net']}, - 'modbus-server': {'group': ['home-net']}, - 'enip-client': {'group': ['home-net']}, - 'enip-server': {'group': ['home-net']}, -} - -port_group_defaults = { - 'http-ports': {'port': ['80']}, - 'shellcode-ports': {'port': ['!80']}, - 'oracle-ports': {'port': ['1521']}, - 'ssh-ports': {'port': ['22']}, - 'dnp3-ports': {'port': ['20000']}, - 'modbus-ports': {'port': ['502']}, - 'file-data-ports': {'port': ['110', '143'], 'group': ['http-ports']}, - 'ftp-ports': {'port': ['21']}, - 'geneve-ports': {'port': ['6081']}, - 'vxlan-ports': {'port': ['4789']}, - 'teredo-ports': {'port': ['3544']}, -} - def get_config(config=None): if config: conf = config else: conf = Config() base = ['service', 'suricata'] + if not conf.exists(base): return None suricata = conf.get_config_dict(base, - get_first_key=True, - with_recursive_defaults=True) - - # Ensure minimal defaults are present - suricata['address-group'] = address_group_defaults | suricata.get('address-group', {}) - suricata['port-group'] = port_group_defaults | suricata.get('port-group', {}) + get_first_key=True, with_recursive_defaults=True) return suricata # https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search def topological_sort(source): sorted_nodes = [] permanent_marks = set() temporary_marks = set() def visit(n, v): if n in permanent_marks: return if n in temporary_marks: raise ConfigError('At least one cycle exists in the referenced groups') temporary_marks.add(n) for m in v.get('group', []): m = m.lstrip('!') if m not in source: raise ConfigError(f'Undefined referenced group "{m}"') visit(m, source[m]) temporary_marks.remove(n) permanent_marks.add(n) sorted_nodes.append((n, v)) while len(permanent_marks) < len(source): n = next(n for n in source.keys() if n not in permanent_marks) visit(n, source[n]) return sorted_nodes def verify(suricata): if not suricata: return None if 'interface' not in suricata: - raise ConfigError('No interfaces configured') + raise ConfigError('No interfaces configured!') + + if 'address-group' not in suricata: + raise ConfigError('No address-group configured!') + + if 'port-group' not in suricata: + raise ConfigError('No port-group configured!') try: topological_sort(suricata['address-group']) except (ConfigError,StopIteration) as e: raise ConfigError(f'Invalid address-group: {e}') try: topological_sort(suricata['port-group']) except (ConfigError,StopIteration) as e: raise ConfigError(f'Invalid port-group: {e}') def generate(suricata): if not suricata: for file in [config_file, rotate_file]: if os.path.isfile(file): os.unlink(file) return None # Config-related formatters def to_var(s:str): return s.replace('-','_').upper() def to_val(s:str): return s.replace('-',':') def to_ref(s:str): if s[0] == '!': return '!$' + to_var(s[1:]) return '$' + to_var(s) def to_config(kind:str): def format_group(group): (name, value) = group property = [to_val(property) for property in value.get(kind,[])] group = [to_ref(group) for group in value.get('group',[])] return (to_var(name), property + group) return format_group # Format the address group suricata['address-group'] = map(to_config('address'), topological_sort(suricata['address-group'])) # Format the port group suricata['port-group'] = map(to_config('port'), topological_sort(suricata['port-group'])) render(config_file, 'ids/suricata.j2', {'suricata': suricata}) render(rotate_file, 'ids/suricata_logrotate.j2', suricata) return None def apply(suricata): systemd_service = 'suricata.service' if not suricata or 'interface' not in suricata: # Stop suricata service if removed call(f'systemctl stop {systemd_service}') else: Warning('To fetch the latest rules, use "update suricata"; ' 'To periodically fetch the latest rules, ' 'use the task scheduler!') call(f'systemctl restart {systemd_service}') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)