From 9b33d24ceab3b7e38da948c7be1e59e9a87c89b2 Mon Sep 17 00:00:00 2001 From: ParrotXray Date: Thu, 21 May 2026 11:51:47 +0000 Subject: [PATCH] refactor: replace native vectorscan rule engine with Suricata daemon mode --- .gitignore | 1 + Cargo.lock | 440 +---- config.toml | 21 +- net-guardia/Cargo.toml | 20 +- net-guardia/build.rs | 1603 +---------------- net-guardia/src/core/ebpf/mod.rs | 6 +- net-guardia/src/core/ebpf/xsk_manager.rs | 84 +- net-guardia/src/core/infrastructure/mod.rs | 12 + net-guardia/src/core/system.rs | 2 +- .../src/detection/ml/feature_extractor.rs | 2 +- net-guardia/src/detection/mod.rs | 2 +- .../src/detection/rule/app_layer/dns.rs | 357 ---- .../src/detection/rule/app_layer/ftp.rs | 74 - .../src/detection/rule/app_layer/http.rs | 175 -- .../src/detection/rule/app_layer/http2.rs | 672 ------- .../src/detection/rule/app_layer/mod.rs | 228 --- .../src/detection/rule/app_layer/mqtt.rs | 208 --- .../src/detection/rule/app_layer/quic.rs | 773 -------- .../src/detection/rule/app_layer/smtp.rs | 86 - .../src/detection/rule/app_layer/ssh.rs | 127 -- .../src/detection/rule/app_layer/tls.rs | 613 ------- .../src/detection/rule/bounded_pcre.rs | 115 -- net-guardia/src/detection/rule/mod.rs | 5 - net-guardia/src/detection/rule/rule_engine.rs | 816 --------- .../src/detection/rule/stream_reassembler.rs | 427 ----- net-guardia/src/detection/rule/suppress.rs | 71 - net-guardia/src/detection/suricata/engine.rs | 159 ++ net-guardia/src/detection/suricata/mod.rs | 3 + net-guardia/src/detection/suricata/output.rs | 110 ++ net-guardia/src/model/config.rs | 8 +- net-guardia/src/model/error/mod.rs | 12 +- net-guardia/src/model/error/rule.rs | 13 - net-guardia/src/model/error/suricata.rs | 25 + net-guardia/src/model/log/mod.rs | 2 +- net-guardia/src/model/log/rule.rs | 18 - net-guardia/src/model/log/suricata.rs | 27 + net-guardia/src/model/rule_detection.rs | 293 --- net-guardia/static/rules/suricata.yaml | 81 + 38 files changed, 463 insertions(+), 7228 deletions(-) delete mode 100644 net-guardia/src/detection/rule/app_layer/dns.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/ftp.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/http.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/http2.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/mod.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/mqtt.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/quic.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/smtp.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/ssh.rs delete mode 100644 net-guardia/src/detection/rule/app_layer/tls.rs delete mode 100644 net-guardia/src/detection/rule/bounded_pcre.rs delete mode 100644 net-guardia/src/detection/rule/mod.rs delete mode 100644 net-guardia/src/detection/rule/rule_engine.rs delete mode 100644 net-guardia/src/detection/rule/stream_reassembler.rs delete mode 100644 net-guardia/src/detection/rule/suppress.rs create mode 100644 net-guardia/src/detection/suricata/engine.rs create mode 100644 net-guardia/src/detection/suricata/mod.rs create mode 100644 net-guardia/src/detection/suricata/output.rs delete mode 100644 net-guardia/src/model/error/rule.rs create mode 100644 net-guardia/src/model/error/suricata.rs delete mode 100644 net-guardia/src/model/log/rule.rs create mode 100644 net-guardia/src/model/log/suricata.rs create mode 100644 net-guardia/static/rules/suricata.yaml diff --git a/.gitignore b/.gitignore index 8ee9506..e2fd9ba 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ node_modules/ *.rules *.db +net-guardia/static/rules/*.txt diff --git a/Cargo.lock b/Cargo.lock index 9a398ed..7553573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,41 +259,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.12" @@ -363,45 +328,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", -] - [[package]] name = "assert_matches" version = "1.5.0" @@ -723,16 +649,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clang-sys" version = "1.8.1" @@ -744,15 +660,6 @@ dependencies = [ "libloading 0.8.9", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "common" version = "0.1.0" @@ -876,39 +783,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "data-encoding" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "deranged" version = "0.3.11" @@ -1042,18 +925,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "filetime" version = "0.2.27" @@ -1099,33 +970,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1257,16 +1101,6 @@ dependencies = [ "wasi 0.14.3+wasi-0.2.4", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "glob" version = "0.3.3" @@ -1335,15 +1169,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "http" version = "0.2.12" @@ -1540,15 +1365,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "imapprotolens" -version = "0.16.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4436252f26379481b02b023a59530310d17e1d68cee692416b162a44aa68db9" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "impl-more" version = "0.1.9" @@ -1576,21 +1392,6 @@ dependencies = [ "which", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "ipnetwork" version = "0.21.1" @@ -1734,17 +1535,6 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "libxdp-sys" version = "0.2.3+1.5.6" @@ -1910,12 +1700,6 @@ dependencies = [ "thiserror 2.0.16", ] -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - [[package]] name = "memchr" version = "2.7.4" @@ -2021,40 +1805,29 @@ dependencies = [ "actix-cors", "actix-web", "actix-ws", - "aes-gcm", "aya", "aya-log", "cargo_metadata", + "cc", "chrono", "common", "crossbeam", - "digest", "dotenvy", "futures", "futures-util", - "ipnet", "libc", "lru", "macros", "maxminddb", - "md5", "mime_guess", "ndarray 0.17.2", "network-types", - "nom 7.1.3", - "nom 8.0.0", "ort", "ort-tract", "parking_lot", - "pcre2", - "pcre2-sys", - "protolens", - "rusqlite", "rust-embed", "serde", "serde_json", - "sha1", - "sha2", "sysinfo", "thiserror 2.0.16", "tokio", @@ -2065,8 +1838,6 @@ dependencies = [ "tracing-subscriber", "tract-onnx", "url", - "vectorscan-rs", - "x509-parser", "xsk-rs", ] @@ -2137,16 +1908,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2232,27 +1993,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "ort" version = "2.0.0-rc.12" @@ -2318,28 +2064,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pcre2" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e970b0fcce0c7ee6ef662744ff711f21ccd6f11b7cf03cd187a80e89797fc67" -dependencies = [ - "libc", - "log", - "pcre2-sys", -] - -[[package]] -name = "pcre2-sys" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18b9073c1a2549bd409bf4a32c94d903bb1a09bf845bc306ae148897fa0760a4" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2389,48 +2113,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.98", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2449,18 +2131,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.0" @@ -2565,22 +2235,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "protolens" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c040ea146711a09a3e22158263859f189232b235626a922037a5dccab6926f6a" -dependencies = [ - "byteorder", - "futures", - "futures-util", - "imapprotolens", - "libc", - "memchr", - "nom 7.1.3", - "phf", -] - [[package]] name = "quote" version = "1.0.43" @@ -2724,20 +2378,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rust-embed" version = "8.7.2" @@ -2792,15 +2432,6 @@ dependencies = [ "transpose", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "rustix" version = "1.0.8" @@ -2978,12 +2609,6 @@ dependencies = [ "libc", ] -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.9" @@ -3048,12 +2673,6 @@ dependencies = [ "serde", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -3601,16 +3220,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "url" version = "2.5.7" @@ -3647,36 +3256,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "vectorscan-rs" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "657b43a26c41ea3e5b0de95240a518a011561d204b83f8ea213fddf2cc224fb0" -dependencies = [ - "bitflags", - "foreign-types", - "libc", - "thiserror 1.0.69", - "vectorscan-rs-sys", -] - -[[package]] -name = "vectorscan-rs-sys" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "195d07e20b78923c2e8542f95a9bfd31d0cacf19f9134181c292b84055c5c7c1" -dependencies = [ - "cmake", - "flate2", - "tar", -] - [[package]] name = "version_check" version = "0.9.5" @@ -4026,23 +3605,6 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - [[package]] name = "xattr" version = "1.6.1" diff --git a/config.toml b/config.toml index 99cbd91..01884f0 100644 --- a/config.toml +++ b/config.toml @@ -19,29 +19,26 @@ frame_count = 4096 # Umem Used (Should not modify) http_server_bind_port = 8080 # Http Server Listen Port refresh_interval = 5 # Statistics Refresh Time -max_concurrent_flows = 10000 # max_flows: track up to 10000 concurrent flows -inference_interval_secs = 5 # interval_secs: run inference every 5 seconds -min_signature_matches = 3 +max_concurrent_flows = 10000 # track up to N concurrent flows +inference_interval_secs = 5 # run ML inference every N seconds aggregator_window_secs = 30 inference_batch_size = 200 flow_timeout_us = 60_000_000 -traffic_logging_mode = false # When true, disables ML inference and records all ingress/egress packets to CSV -traffic_log_csv_path = "traffic_log.csv" # Output CSV file path for traffic logging mode - -home_net = ["140.130.34.0/24"] +traffic_logging_mode = false # when true, disables ML inference and records packets to CSV +traffic_log_csv_path = "traffic_log.csv" # tls_keylog_path = "/tmp/tls_keys.log" # NSS key log file for TLS decryption (SSLKEYLOGFILE) # CPU affinity (Linux only). Uncomment and tune for your hardware. # Distribute XSK packet threads across a core range [start, end] (inclusive). # Threads are assigned round-robin: core = start + (queue_id % (end - start + 1)). -# Example: xsk_cpu_set=[0, 3] with combined_queue_count=8 spreads 16 threads -# across cores 0-3, with 4 threads per core. xsk_cpu_set = [0, 3] -# + # Pin ML inference (ONNX spawn_blocking) to this core. -# Example: on an 8-core machine, reserve core 7 for inference. ml_cpu = 7 -ae_threshold_method = "94" \ No newline at end of file +ae_threshold_method = "94" + +# Suricata daemon mode. Remove or comment out to disable the rule engine. +suricata_name = "suricata.yaml" \ No newline at end of file diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index a420b1f..cc84780 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -37,20 +37,6 @@ lru = "0.16.2" futures = "0.3.31" tract-onnx = "0.22.1" chrono = "0.4" -vectorscan-rs = "0.0.6" -protolens = "0.2.3" -rusqlite = { version = "0.32", features = ["bundled"] } -pcre2 = "0.2" -pcre2-sys = "0.2" -md5 = "0.7" -ipnet = "2" -x509-parser = "0.16" -sha1 = "0.10" -sha2 = "0.10" -digest = "0.10" -nom7 = { version = "7.1", package = "nom" } -nom8 = { version = "8.0", package = "nom" } -aes-gcm = "0.10" ort-tract = { version = "0.3.0+0.22", optional = true } ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "ndarray"] } @@ -60,14 +46,12 @@ ndarray = "0.17" default = ["native-ort-backend"] tract-backend = ["dep:ort-tract", "ort/alternative-backend"] native-ort-backend = ["ort/load-dynamic", "ort/api-18"] -#csv = "1.4.0" -#anyhow = "1.0.100" [build-dependencies] cargo_metadata = { workspace = true } +cc = "1" dotenvy = "0.15.7" -rusqlite = { version = "0.32", features = ["bundled"] } [[bin]] name = "net-guardia" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/net-guardia/build.rs b/net-guardia/build.rs index e9b23a7..7528c31 100644 --- a/net-guardia/build.rs +++ b/net-guardia/build.rs @@ -1,163 +1,20 @@ use std::env; use std::fs; use std::io::{BufRead as _, BufReader}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::SystemTime; use cargo_metadata::{Artifact, CompilerMessage, Message, Metadata, MetadataCommand, Package, Target, TargetKind}; -/// Buffer IDs — must stay in sync with app_layer/mod.rs and rule_engine.rs. -const BUF_RAW: u8 = 0; -const BUF_HTTP_URI: u8 = 1; -const BUF_HTTP_HEADER: u8 = 2; -const BUF_HTTP_CLIENT_BODY: u8 = 3; -const BUF_HTTP_SERVER_BODY: u8 = 4; -const BUF_HTTP_METHOD: u8 = 5; -const BUF_HTTP_USER_AGENT: u8 = 6; -const BUF_HTTP_HOST: u8 = 7; -const BUF_HTTP_STAT_CODE: u8 = 8; -const BUF_HTTP_COOKIE: u8 = 9; -const BUF_HTTP_RAW_URI: u8 = 10; -const BUF_TLS_SNI: u8 = 11; -const BUF_DNS_QUERY: u8 = 12; -const BUF_TLS_VERSION: u8 = 13; -const BUF_TLS_JA3: u8 = 14; -const BUF_DNS_RCODE: u8 = 15; -const BUF_DNS_QTYPE: u8 = 16; -const BUF_DNS_ANSWER: u8 = 17; -// SSH -const BUF_SSH_PROTO: u8 = 18; -const BUF_SSH_SOFTWARE: u8 = 19; -const BUF_SSH_HASSH: u8 = 20; -// SMTP -const BUF_SMTP_HELO: u8 = 21; -const BUF_SMTP_FROM: u8 = 22; -const BUF_SMTP_RCPT: u8 = 23; -const BUF_SMTP_REPLY: u8 = 24; -// FTP -const BUF_FTP_COMMAND: u8 = 25; -const BUF_FTP_FILENAME: u8 = 26; -const BUF_FTP_REPLY: u8 = 27; -// MQTT -const BUF_MQTT_CLIENTID: u8 = 28; -const BUF_MQTT_TOPIC: u8 = 29; -const BUF_MQTT_USERNAME: u8 = 30; -// HTTP/2 -const BUF_HTTP2_METHOD: u8 = 31; -const BUF_HTTP2_PATH: u8 = 32; -const BUF_HTTP2_AUTHORITY: u8 = 33; -const BUF_HTTP2_STATUS: u8 = 34; -const BUF_HTTP2_UA: u8 = 35; -// TLS extended -const BUF_TLS_JA3S: u8 = 36; -const BUF_TLS_JA4: u8 = 37; -const BUF_TLS_CERT_SUBJECT: u8 = 38; -const BUF_TLS_CERT_ISSUER: u8 = 39; -const BUF_TLS_CERT_FINGERPRINT: u8 = 40; -const BUF_TLS_CERT_NOT_BEFORE: u8 = 41; -const BUF_TLS_CERT_NOT_AFTER: u8 = 42; -const BUF_TLS_ALPN: u8 = 43; -// QUIC -const BUF_QUIC_SNI: u8 = 44; -const BUF_QUIC_VERSION: u8 = 45; - -struct ContentMatch { - pattern: Vec, - nocase: bool, - negated: bool, - is_fast_pattern: bool, - has_distance: bool, - has_within: bool, - has_offset: bool, - has_depth: bool, - distance: i32, - within: i32, - offset: u16, - depth: u16, - /// Target buffer (0 = raw payload, 1 = http_uri, etc.) - buffer: u8, -} - -struct PcreEntry { - /// Regex pattern string including inline modifiers, e.g. `(?i)pattern`. - pattern: String, - negated: bool, - /// Buffer to match against (same IDs as ContentMatch::buffer). - buffer: u8, -} - -/// Flowbit operation codes — stored as u8 in rules.db. -/// 0=set 1=isset 2=unset 3=isnotset 4=toggle 5=noalert -struct FlowbitEntry { - op: u8, - name: String, -} - -/// Build-time mirror of rule_detection::ByteOp. Fields use i64 for SQLite. -struct ByteOp { - kind: u8, // 0=byte_test 1=byte_jump 2=byte_extract 3=isdataat - count: u8, - offset: i32, - relative: bool, - endian_little: bool, - string_mode: bool, - string_base: u8, // 0=dec 1=hex 2=oct - buffer: u8, - // byte_test - bt_op: u8, // 0=< 1=> 2=eq 3=ne 4=le 5=ge 6=band 7=bxor - bt_value: i64, // bit pattern stored as i64; reinterpreted as u64 at runtime - bt_uses_var: bool, - bt_bitmask: i64, - bt_negated: bool, - // byte_jump - bj_align: bool, - bj_multiplier: i64, - bj_from_beginning: bool, - bj_post_offset: i32, - // byte_extract - be_var_idx: u8, - be_multiplier: i64, -} - -struct ThresholdEntry { - /// 1=limit 2=both 3=threshold - kind: u8, - /// 1=TRACK_DST 2=TRACK_SRC 3=TRACK_RULE 5=TRACK_BOTH 6=TRACK_FLOW - track: u8, - count: u32, - seconds: u32, -} - -struct SigEntry { - sid: u32, - flow_dir: u8, - /// True when the rule requires flow:established. - flow_established: bool, - /// Source IP constraint: 0=any, 1=$HOME_NET, 2=$EXTERNAL_NET. - src_ip_flag: u8, - /// Destination IP constraint: 0=any, 1=$HOME_NET, 2=$EXTERNAL_NET. - dst_ip_flag: u8, - dports: Vec, - msg: String, - chain: Vec, - fast_pattern_idx: usize, - pcres: Vec, - flowbits: Vec, - alproto: u8, // 0 = any - alproto_neg: bool, - byte_ops: Vec, - threshold: Option, -} - fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let db_dir = manifest_dir.join("static").join("db"); let artifact_dir = manifest_dir.join("static").join("artifacts"); + let rule_dir = manifest_dir.join("static").join("rules"); + let suricata_eve_socket = PathBuf::from("/tmp").join("suricata-alerts.sock"); let csv_dir = manifest_dir.parent().unwrap().join("records"); let onnxruntime_dir = manifest_dir.parent().unwrap().join("onnxruntime").join("libonnxruntime.so"); - let rules_dir = manifest_dir.join("static").join("rules"); let static_web = manifest_dir.join("static").join("web"); let project_name = manifest_dir.file_name().unwrap().to_string_lossy().into_owned(); let frontend_dir = manifest_dir @@ -167,10 +24,11 @@ fn main() { // ── All cargo: directives in one place ──────────────────────────────── - println!("cargo:rustc-env=RULES_DB_PATH={}", db_dir.display()); println!("cargo:rustc-env=ARTIFACTCS_PATH={}", artifact_dir.display()); println!("cargo:rustc-env=CSV_RECORD_PATH={}", csv_dir.display()); println!("cargo:rustc-env=ONNXRUNTIME_PATH={}", onnxruntime_dir.display()); + println!("cargo:rustc-env=RULE_PATH={}", rule_dir.display()); + println!("cargo:rustc-env=RULE_EVE_PATH={}", suricata_eve_socket.display()); for item in &[ "src", @@ -185,17 +43,6 @@ fn main() { println!("cargo:rerun-if-changed={}", frontend_dir.join(item).display()); } - println!("cargo:rerun-if-changed={}", rules_dir.display()); - println!("cargo:rerun-if-changed={}", rules_dir.join("suppress.conf").display()); - if let Ok(rd) = fs::read_dir(&rules_dir) { - for entry in rd.flatten() { - let p = entry.path(); - if p.extension().and_then(|e| e.to_str()) == Some("rules") { - println!("cargo:rerun-if-changed={}", p.display()); - } - } - } - // ───────────────────────────────────────────────────────────────────── if env::var_os("SKIP_EBPF_BUILD").is_some() { @@ -211,7 +58,6 @@ fn main() { build_ingress_ebpf(); build_egress_ebpf(); build_frontend(&frontend_dir, &static_web); - build_vectorscan_db(&db_dir, &rules_dir); } fn build_ingress_ebpf() { @@ -588,1442 +434,3 @@ fn copy_dir_all(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> { Ok(()) } -fn build_vectorscan_db(db_dir: &PathBuf, rules_dir: &PathBuf) { - let sigs = collect_sig_entries(rules_dir); - let suppress_rows = parse_suppress_conf(&rules_dir.join("suppress.conf")); - - let db_path = db_dir.join("rules.db"); - write_rules_db(&db_path, &sigs, &suppress_rows); - - let total_contents: usize = sigs.iter().map(|s| s.chain.len()).sum(); - println!( - "cargo:warning=NetGuardia: {} signatures ({} content entries) ready", - sigs.len(), - total_contents - ); -} - -fn collect_sig_entries(rules_dir: &PathBuf) -> Vec { - if !rules_dir.exists() { - return Vec::new(); - } - - let mut paths: Vec<_> = fs::read_dir(rules_dir) - .unwrap() - .flatten() - .map(|e| e.path()) - .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rules")) - .collect(); - paths.sort(); - - let mut sigs = Vec::new(); - - for path in paths { - let text = match fs::read_to_string(&path) { - Ok(t) => t, - Err(_) => continue, - }; - for line in text.lines() { - let line = line.trim(); - if line.starts_with('#') || line.is_empty() { - continue; - } - if line.contains("noalert") { - continue; - } - - let chain = extract_content_chain(line); - if chain.is_empty() { - continue; - } - - let Some(fast_idx) = select_fast_pattern(&chain) else { - continue; - }; - - let (alproto, alproto_neg) = extract_alproto(line); - - sigs.push(SigEntry { - sid: extract_sid(line).unwrap_or(0), - flow_dir: extract_flow_dir(line), - flow_established: extract_flow_established(line), - src_ip_flag: extract_ip_flag(line, 2), - dst_ip_flag: extract_ip_flag(line, 5), - dports: extract_dst_ports(line), - msg: extract_msg(line).unwrap_or_default(), - chain, - fast_pattern_idx: fast_idx, - pcres: extract_pcre_entries(line), - flowbits: extract_flowbit_entries(line), - alproto, - alproto_neg, - byte_ops: extract_byte_ops(line), - threshold: extract_threshold(line), - }); - } - } - - sigs -} - -/// Map a Suricata/Snort buffer keyword to its buffer ID. -/// Handles both new-style (`http.uri`) and old-style (`http_uri`) forms. -fn detect_buffer_keyword(kw: &str) -> Option { - match kw { - "http.uri" | "http_uri" => Some(BUF_HTTP_URI), - "http.header" | "http_header" => Some(BUF_HTTP_HEADER), - "http.request_body" | "http_client_body" => Some(BUF_HTTP_CLIENT_BODY), - "http.response_body" | "http_server_body" => Some(BUF_HTTP_SERVER_BODY), - "http.method" | "http_method" => Some(BUF_HTTP_METHOD), - "http.user_agent" | "http_user_agent" => Some(BUF_HTTP_USER_AGENT), - "http.host" | "http_host" => Some(BUF_HTTP_HOST), - "http.stat_code" | "http_stat_code" => Some(BUF_HTTP_STAT_CODE), - "http.cookie" | "http_cookie" => Some(BUF_HTTP_COOKIE), - "http.raw_uri" | "http_raw_uri" => Some(BUF_HTTP_RAW_URI), - "tls.sni" | "tls_sni" => Some(BUF_TLS_SNI), - "dns_query" | "dns.query" => Some(BUF_DNS_QUERY), - "tls.version" | "tls_version" => Some(BUF_TLS_VERSION), - "tls.ja3_hash" | "tls_ja3_hash" => Some(BUF_TLS_JA3), - "dns.rcode" | "dns_rcode" => Some(BUF_DNS_RCODE), - "dns.query.type" | "dns_query_type" => Some(BUF_DNS_QTYPE), - "dns.answer" | "dns_answer" => Some(BUF_DNS_ANSWER), - // SSH - "ssh.proto" | "ssh_proto" => Some(BUF_SSH_PROTO), - "ssh.software" | "ssh_software" => Some(BUF_SSH_SOFTWARE), - "ssh.hassh" | "ssh_hassh" => Some(BUF_SSH_HASSH), - // SMTP - "smtp.helo" | "smtp_helo" => Some(BUF_SMTP_HELO), - "smtp.mail_from" | "smtp_from" => Some(BUF_SMTP_FROM), - "smtp.rcpt_to" | "smtp_rcpt" => Some(BUF_SMTP_RCPT), - "smtp.reply_code" | "smtp_reply" => Some(BUF_SMTP_REPLY), - // FTP - "ftp.command" | "ftp_command" => Some(BUF_FTP_COMMAND), - "ftp.filename" | "ftp_filename" => Some(BUF_FTP_FILENAME), - "ftp.reply_code" | "ftp_reply" => Some(BUF_FTP_REPLY), - // MQTT - "mqtt.client_id" | "mqtt_client_id" => Some(BUF_MQTT_CLIENTID), - "mqtt.topic" | "mqtt_topic" => Some(BUF_MQTT_TOPIC), - "mqtt.username" | "mqtt_username" => Some(BUF_MQTT_USERNAME), - // HTTP/2 - "http2.method" | "http2_method" => Some(BUF_HTTP2_METHOD), - "http2.path" | "http2_path" => Some(BUF_HTTP2_PATH), - "http2.authority" | "http2_authority" => Some(BUF_HTTP2_AUTHORITY), - "http2.status_code" | "http2_status" => Some(BUF_HTTP2_STATUS), - "http2.user_agent" | "http2_user_agent" => Some(BUF_HTTP2_UA), - // TLS extended - "tls.ja3s_hash" | "tls_ja3s_hash" => Some(BUF_TLS_JA3S), - "tls.ja4_hash" | "tls_ja4_hash" => Some(BUF_TLS_JA4), - "tls.cert_subject" | "tls_cert_subject" => Some(BUF_TLS_CERT_SUBJECT), - "tls.cert_issuer" | "tls_cert_issuer" => Some(BUF_TLS_CERT_ISSUER), - "tls.fingerprint" | "tls_fingerprint" => Some(BUF_TLS_CERT_FINGERPRINT), - "tls.notbefore" | "tls_notbefore" => Some(BUF_TLS_CERT_NOT_BEFORE), - "tls.notafter" | "tls_notafter" => Some(BUF_TLS_CERT_NOT_AFTER), - "tls.alpn" | "tls_alpn" => Some(BUF_TLS_ALPN), - // QUIC - "quic.sni" | "quic_sni" => Some(BUF_QUIC_SNI), - "quic.version" | "quic_version" => Some(BUF_QUIC_VERSION), - _ => None, - } -} - -/// Tokenise the rule options section into `(keyword, value?)` pairs, -/// splitting on `;` while respecting double-quoted strings. -fn tokenize_options(rule: &str) -> Vec { - let opts_start = rule.find('(').map(|p| p + 1).unwrap_or(0); - let opts_end = rule.rfind(')').unwrap_or(rule.len()); - let opts = &rule[opts_start..opts_end.max(opts_start)]; - - let mut tokens: Vec = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - - for ch in opts.chars() { - match ch { - '"' => { - in_quotes = !in_quotes; - current.push(ch); - } - ';' if !in_quotes => { - let t = current.trim().to_string(); - if !t.is_empty() { - tokens.push(t); - } - current.clear(); - } - _ => current.push(ch), - } - } - let t = current.trim().to_string(); - if !t.is_empty() { - tokens.push(t); - } - tokens -} - -fn extract_content_chain(rule: &str) -> Vec { - let tokens = tokenize_options(rule); - let mut entries: Vec = Vec::new(); - let mut sticky_buffer: u8 = BUF_RAW; - let mut i = 0; - - while i < tokens.len() { - let tok = tokens[i].as_str(); - - // New-style sticky buffer keyword (e.g. `http.uri`) - if let Some(buf) = detect_buffer_keyword(tok) { - sticky_buffer = buf; - i += 1; - continue; - } - - // content: keyword - let content_rest = if let Some(r) = tok.strip_prefix("content:") { - r - } else { - i += 1; - continue; - }; - - let negated = content_rest.starts_with('!'); - let quoted = if negated { &content_rest[1..] } else { content_rest }; - if !quoted.starts_with('"') { - i += 1; - continue; - } - - // Unquote: content token already has the full quoted string because - // tokenize_options preserves quotes inside options. - let inner = unquote_content(quoted); - let Some(raw_bytes) = parse_content_bytes(&inner) else { - i += 1; - continue; - }; - if raw_bytes.contains(&0u8) || raw_bytes.len() < 2 { - i += 1; - continue; - } - - let mut entry = ContentMatch { - pattern: raw_bytes, - nocase: false, - negated, - is_fast_pattern: false, - has_distance: false, - has_within: false, - has_offset: false, - has_depth: false, - distance: 0, - within: 0, - offset: 0, - depth: 0, - buffer: sticky_buffer, - }; - - i += 1; - - // Collect modifier tokens until the next content: or new buffer keyword. - while i < tokens.len() { - let mod_tok = tokens[i].as_str(); - - // Stop at next content: keyword — it will be handled in the outer loop. - if mod_tok.starts_with("content:") { - break; - } - - // Old-style buffer keyword overrides the sticky buffer for this entry. - if let Some(buf) = detect_buffer_keyword(mod_tok) { - entry.buffer = buf; - // A new-style sticky keyword also updates the global sticky state. - if mod_tok.contains('.') { - sticky_buffer = buf; - } - i += 1; - // Stop if new-style (becomes sticky for next content). - if mod_tok.contains('.') { - break; - } - continue; - } - - // Apply other modifiers. - match mod_tok { - "nocase" => entry.nocase = true, - "fast_pattern" => entry.is_fast_pattern = true, - _ => { - if let Some(v) = mod_tok.strip_prefix("distance:") { - if let Ok(n) = v.trim().parse::() { - entry.distance = n; - entry.has_distance = true; - } - } else if let Some(v) = mod_tok.strip_prefix("within:") { - if let Ok(n) = v.trim().parse::() { - entry.within = n; - entry.has_within = true; - } - } else if let Some(v) = mod_tok.strip_prefix("offset:") { - if let Ok(n) = v.trim().parse::() { - entry.offset = n; - entry.has_offset = true; - } - } else if let Some(v) = mod_tok.strip_prefix("depth:") { - if let Ok(n) = v.trim().parse::() { - entry.depth = n; - entry.has_depth = true; - } - } else if mod_tok.starts_with("fast_pattern:") { - entry.is_fast_pattern = true; - } - } - } - i += 1; - } - - entries.push(entry); - } - - entries -} - -/// Strip the surrounding double-quotes from a content token like `"foo|0d0a|"`. -fn unquote_content(s: &str) -> String { - if s.starts_with('"') && s.len() >= 2 { - // Find the closing unescaped quote. - let inner = &s[1..]; - let mut out = String::new(); - let mut chars = inner.chars(); - loop { - match chars.next() { - None | Some('"') => break, - Some('\\') => { - if let Some(c) = chars.next() { - out.push('\\'); - out.push(c); - } - } - Some(c) => out.push(c), - } - } - out - } else { - s.to_string() - } -} - -/// Select the fast-pattern index for vectorscan prefilter. -/// Prefers explicit fast_pattern keyword, then the longest non-negated pattern. -fn select_fast_pattern(chain: &[ContentMatch]) -> Option { - if let Some(idx) = chain.iter().position(|e| e.is_fast_pattern && !e.negated) { - return Some(idx); - } - chain - .iter() - .enumerate() - .filter(|(_, e)| !e.negated && e.pattern.len() >= 4) - .max_by_key(|(_, e)| e.pattern.len()) - .map(|(i, _)| i) -} - -/// Write all signature data to a SQLite rules.db. -/// -/// Schema: -/// patterns(id, expression BLOB, nocase) — vectorscan prefilter; id = sig index -/// signatures(id, sid, flow_dir, msg) -/// signature_ports(sig_id, dport) -/// content_entries(sig_id, entry_order, pattern BLOB, nocase, negated, -/// has_distance, has_within, has_offset, has_depth, -/// distance, within, off, depth, buffer) -struct SuppressRow { - sid: u32, - /// 0=any(TRACK_RULE) 1=TRACK_SRC 2=TRACK_DST 4=TRACK_EITHER - track: u8, - ip_net: String, -} - -fn parse_suppress_conf(path: &Path) -> Vec { - let text = match fs::read_to_string(path) { - Ok(t) => t, - Err(_) => return Vec::new(), - }; - let mut rows = Vec::new(); - for line in text.lines() { - let line = line.trim(); - if line.starts_with('#') || line.is_empty() { - continue; - } - if !line.starts_with("suppress") { - continue; - } - let mut sid = 0u32; - let mut track = 0u8; - let mut ip_net = String::new(); - for part in line.split(',') { - let part = part.trim(); - if let Some(v) = part.strip_prefix("sig_id ") { - sid = v.trim().parse().unwrap_or(0); - } else if part.contains("by_src") { - track = 1; - } else if part.contains("by_dst") { - track = 2; - } else if part.contains("by_either") { - track = 4; - } else if let Some(v) = part.strip_prefix("ip ") { - ip_net = v.trim().to_string(); - } - } - if sid == 0 { - continue; - } - rows.push(SuppressRow { sid, track, ip_net }); - } - rows -} - -fn write_rules_db(path: &Path, sigs: &[SigEntry], suppress: &[SuppressRow]) { - let _ = fs::remove_file(path); - - let conn = rusqlite::Connection::open(path).unwrap_or_else(|e| panic!("failed to open rules.db at {path:?}: {e}")); - - conn.execute_batch( - " - CREATE TABLE patterns ( - id INTEGER PRIMARY KEY, - expression BLOB NOT NULL, - nocase INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE signatures ( - id INTEGER PRIMARY KEY, - sid INTEGER NOT NULL, - flow_dir INTEGER NOT NULL, - flow_established INTEGER NOT NULL DEFAULT 0, - src_ip_flag INTEGER NOT NULL DEFAULT 0, - dst_ip_flag INTEGER NOT NULL DEFAULT 0, - msg TEXT NOT NULL, - alproto INTEGER NOT NULL DEFAULT 0, - alproto_neg INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE signature_ports ( - sig_id INTEGER NOT NULL, - dport INTEGER NOT NULL - ); - CREATE TABLE content_entries ( - sig_id INTEGER NOT NULL, - entry_order INTEGER NOT NULL, - pattern BLOB NOT NULL, - nocase INTEGER NOT NULL DEFAULT 0, - negated INTEGER NOT NULL DEFAULT 0, - has_distance INTEGER NOT NULL DEFAULT 0, - has_within INTEGER NOT NULL DEFAULT 0, - has_offset INTEGER NOT NULL DEFAULT 0, - has_depth INTEGER NOT NULL DEFAULT 0, - distance INTEGER NOT NULL DEFAULT 0, - within INTEGER NOT NULL DEFAULT 0, - off INTEGER NOT NULL DEFAULT 0, - depth INTEGER NOT NULL DEFAULT 0, - buffer INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (sig_id, entry_order) - ); - CREATE TABLE pcre_entries ( - sig_id INTEGER NOT NULL, - entry_order INTEGER NOT NULL, - pattern TEXT NOT NULL, - negated INTEGER NOT NULL DEFAULT 0, - buffer INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (sig_id, entry_order) - ); - CREATE TABLE flowbit_entries ( - sig_id INTEGER NOT NULL, - op INTEGER NOT NULL, - name TEXT NOT NULL - ); - CREATE TABLE byte_ops ( - sig_id INTEGER NOT NULL, - op_order INTEGER NOT NULL, - kind INTEGER NOT NULL, - count INTEGER NOT NULL DEFAULT 0, - offset INTEGER NOT NULL DEFAULT 0, - relative INTEGER NOT NULL DEFAULT 0, - endian_little INTEGER NOT NULL DEFAULT 0, - string_mode INTEGER NOT NULL DEFAULT 0, - string_base INTEGER NOT NULL DEFAULT 0, - buffer INTEGER NOT NULL DEFAULT 0, - bt_op INTEGER NOT NULL DEFAULT 0, - bt_value INTEGER NOT NULL DEFAULT 0, - bt_uses_var INTEGER NOT NULL DEFAULT 0, - bt_bitmask INTEGER NOT NULL DEFAULT 0, - bt_negated INTEGER NOT NULL DEFAULT 0, - bj_align INTEGER NOT NULL DEFAULT 0, - bj_multiplier INTEGER NOT NULL DEFAULT 1, - bj_from_beg INTEGER NOT NULL DEFAULT 0, - bj_post_offset INTEGER NOT NULL DEFAULT 0, - be_var_idx INTEGER NOT NULL DEFAULT 0, - be_multiplier INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (sig_id, op_order) - ); - CREATE TABLE suppress ( - id INTEGER PRIMARY KEY, - sid INTEGER NOT NULL, - track INTEGER NOT NULL DEFAULT 0, - ip_net TEXT NOT NULL DEFAULT '' - ); - CREATE TABLE threshold_entries ( - sig_id INTEGER NOT NULL PRIMARY KEY, - kind INTEGER NOT NULL, - track INTEGER NOT NULL DEFAULT 3, - count INTEGER NOT NULL, - seconds INTEGER NOT NULL - ); - ", - ) - .unwrap_or_else(|e| panic!("rules.db schema failed: {e}")); - - let tx = conn - .unchecked_transaction() - .unwrap_or_else(|e| panic!("rules.db transaction failed: {e}")); - - for (sig_idx, sig) in sigs.iter().enumerate() { - let id = sig_idx as i64; - - let fp = &sig.chain[sig.fast_pattern_idx]; - let expression = regex_escape(&fp.pattern); - tx.execute( - "INSERT INTO patterns (id, expression, nocase) VALUES (?1, ?2, ?3)", - rusqlite::params![id, expression, fp.nocase as i64], - ) - .unwrap(); - - tx.execute( - "INSERT INTO signatures (id, sid, flow_dir, flow_established, src_ip_flag, dst_ip_flag, msg, alproto, alproto_neg) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - rusqlite::params![ - id, - sig.sid as i64, - sig.flow_dir as i64, - sig.flow_established as i64, - sig.src_ip_flag as i64, - sig.dst_ip_flag as i64, - &sig.msg, - sig.alproto as i64, - sig.alproto_neg as i64, - ], - ).unwrap(); - - for &dport in &sig.dports { - tx.execute( - "INSERT INTO signature_ports (sig_id, dport) VALUES (?1, ?2)", - rusqlite::params![id, dport as i64], - ) - .unwrap(); - } - - for (order, entry) in sig.chain.iter().enumerate() { - tx.execute( - "INSERT INTO content_entries \ - (sig_id, entry_order, pattern, nocase, negated, \ - has_distance, has_within, has_offset, has_depth, \ - distance, within, off, depth, buffer) \ - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", - rusqlite::params![ - id, - order as i64, - &entry.pattern, - entry.nocase as i64, - entry.negated as i64, - entry.has_distance as i64, - entry.has_within as i64, - entry.has_offset as i64, - entry.has_depth as i64, - entry.distance as i64, - entry.within as i64, - entry.offset as i64, - entry.depth as i64, - entry.buffer as i64, - ], - ) - .unwrap(); - } - - for (order, entry) in sig.pcres.iter().enumerate() { - tx.execute( - "INSERT INTO pcre_entries (sig_id, entry_order, pattern, negated, buffer) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![ - id, - order as i64, - &entry.pattern, - entry.negated as i64, - entry.buffer as i64, - ], - ) - .unwrap(); - } - - for entry in &sig.flowbits { - tx.execute( - "INSERT INTO flowbit_entries (sig_id, op, name) VALUES (?1, ?2, ?3)", - rusqlite::params![id, entry.op as i64, &entry.name], - ) - .unwrap(); - } - - if let Some(t) = &sig.threshold { - tx.execute( - "INSERT INTO threshold_entries (sig_id, kind, track, count, seconds) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![id, t.kind as i64, t.track as i64, t.count as i64, t.seconds as i64], - ) - .unwrap(); - } - - for (order, op) in sig.byte_ops.iter().enumerate() { - tx.execute( - "INSERT INTO byte_ops \ - (sig_id, op_order, kind, count, offset, relative, endian_little, \ - string_mode, string_base, buffer, bt_op, bt_value, bt_uses_var, \ - bt_bitmask, bt_negated, bj_align, bj_multiplier, bj_from_beg, \ - bj_post_offset, be_var_idx, be_multiplier) \ - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)", - rusqlite::params![ - id, - order as i64, - op.kind as i64, - op.count as i64, - op.offset as i64, - op.relative as i64, - op.endian_little as i64, - op.string_mode as i64, - op.string_base as i64, - op.buffer as i64, - op.bt_op as i64, - op.bt_value, - op.bt_uses_var as i64, - op.bt_bitmask, - op.bt_negated as i64, - op.bj_align as i64, - op.bj_multiplier, - op.bj_from_beginning as i64, - op.bj_post_offset as i64, - op.be_var_idx as i64, - op.be_multiplier, - ], - ) - .unwrap(); - } - } - - tx.commit().unwrap_or_else(|e| panic!("rules.db commit failed: {e}")); - - let tx2 = conn - .unchecked_transaction() - .unwrap_or_else(|e| panic!("rules.db suppress transaction failed: {e}")); - for (i, row) in suppress.iter().enumerate() { - tx2.execute( - "INSERT INTO suppress (id, sid, track, ip_net) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![i as i64, row.sid as i64, row.track as i64, &row.ip_net], - ) - .unwrap(); - } - tx2.commit() - .unwrap_or_else(|e| panic!("rules.db suppress commit failed: {e}")); -} - -fn extract_sid(rule: &str) -> Option { - let pos = rule.find("sid:")?; - let rest = &rule[pos + 4..]; - let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); - rest[..end].parse().ok() -} - -fn extract_msg(rule: &str) -> Option { - let pos = rule.find("msg:\"")?; - let rest = &rule[pos + 5..]; - let mut out = String::new(); - let mut chars = rest.chars(); - loop { - match chars.next() { - None | Some('"') => break, - Some('\\') => { - if let Some(c) = chars.next() { - out.push(c); - } - } - Some(c) => out.push(c), - } - } - Some(out) -} - -/// Decode a Snort/Suricata content string into raw bytes. -/// Handles `|XX XX|` hex sections and `\;` / `\\` escapes. -fn parse_content_bytes(s: &str) -> Option> { - let mut out = Vec::new(); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - match c { - '|' => { - let mut hex = String::new(); - loop { - match chars.next() { - Some('|') | None => break, - Some(h) => hex.push(h), - } - } - let hex = hex.replace(' ', ""); - if hex.len() % 2 != 0 { - return None; - } - for i in (0..hex.len()).step_by(2) { - out.push(u8::from_str_radix(&hex[i..i + 2], 16).ok()?); - } - } - '\\' => match chars.next() { - Some(';') => out.push(b';'), - Some('\\') => out.push(b'\\'), - Some(c) => { - out.push(b'\\'); - out.push(c as u8); - } - None => {} - }, - c => out.push(c as u8), - } - } - Some(out) -} - -/// Escape regex metacharacters so literal byte patterns work in vectorscan. -fn regex_escape(bytes: &[u8]) -> Vec { - let mut out = Vec::with_capacity(bytes.len() * 2); - for &b in bytes { - if matches!( - b, - b'.' | b'^' | b'$' | b'*' | b'+' | b'?' | b'(' | b')' | b'[' | b']' | b'{' | b'}' | b'\\' | b'|' - ) { - out.push(b'\\'); - } - out.push(b); - } - out -} - -/// Returns flow direction: 0=any, 1=to_server (ingress), 2=to_client (egress). -fn extract_flow_dir(rule: &str) -> u8 { - let Some(pos) = rule.find("flow:") else { return 0 }; - let rest = &rule[pos + 5..]; - let seg_end = rest.find(';').unwrap_or(rest.len()); - let opts = &rest[..seg_end]; - if opts.contains("to_server") { - 1 - } else if opts.contains("to_client") { - 2 - } else { - 0 - } -} - -/// Parse destination ports from the rule header (token index 6, before the '('). -/// Returns empty Vec for "any" or negation-only groups (= match all). -/// Expands common Suricata/Snort port variables. -fn extract_dst_ports(rule: &str) -> Vec { - // Rule header ends at '(' - let header = match rule.find('(') { - Some(pos) => &rule[..pos], - None => return Vec::new(), - }; - - // Tokenise the header — we need token index 6 (0-based): - // action proto src_ip src_port direction dst_ip dst_port - let tokens: Vec<&str> = header.split_whitespace().collect(); - let port_token = match tokens.get(6) { - Some(t) => *t, - None => return Vec::new(), - }; - - expand_port_token(port_token) -} - -fn expand_port_token(token: &str) -> Vec { - match token { - // HTTP / Web - "$HTTP_PORTS" => return vec![80, 443, 8080, 8443, 8000, 8888], - "$HTTP_PORTS2" => return vec![80, 8080], - "$HTTPS_PORTS" => return vec![443, 8443], - "$FILE_DATA_PORTS" => return vec![80, 443, 8080, 8443, 110, 143], - "$PROXY_PORTS" => return vec![3128, 8080, 8118, 8888], - // Mail - "$SMTP_PORTS" => return vec![25, 587, 465], - "$IMAP_PORTS" => return vec![143, 993], - "$POP3_PORTS" => return vec![110, 995], - // File transfer / Remote - "$FTP_PORTS" => return vec![21], - "$FTP_DATA" => return vec![20], - "$SSH_PORTS" => return vec![22], - "$TELNET_PORTS" => return vec![23], - "$RDP_PORTS" => return vec![3389], - "$VNC_PORTS" => return vec![5900, 5901, 5902, 5903], - // Database - "$SQL_PORTS" => return vec![3306, 5432, 1433, 1521], - "$ORACLE_PORTS" => return vec![1521, 1526], - "$MSSQL_PORTS" => return vec![1433, 1434], - "$MYSQL_PORTS" => return vec![3306], - "$PGSQL_PORTS" => return vec![5432], - "$MONGODB_PORTS" => return vec![27017, 27018], - "$REDIS_PORTS" => return vec![6379], - "$MEMCACHED_PORTS" => return vec![11211], - "$ELASTICSEARCH_PORTS" => return vec![9200, 9300], - // DNS / Directory - "$DNS_PORTS" => return vec![53], - "$LDAP_PORTS" => return vec![389, 636, 3268, 3269], - "$KERBEROS_PORTS" => return vec![88, 464], - // VoIP / Messaging - "$SIP_PORTS" => return vec![5060, 5061], - "$IRC_PORTS" => return vec![6667, 6668, 6669, 7000], - "$JABBER_PORTS" => return vec![5222, 5223], - "$MSN_PORTS" => return vec![1863], - "$AIM_PORTS" => return vec![5190], - // Network management - "$SNMP_PORTS" => return vec![161, 162], - "$SYSLOG_PORTS" => return vec![514], - // Industrial / SCADA - "$MODBUS_PORTS" => return vec![502], - "$DNP3_PORTS" => return vec![20000], - "$ENIP_PORTS" => return vec![44818], - "$BACnet_PORTS" => return vec![47808], - "$VXLAN_PORTS" => return vec![4789], - "$TEREDO_PORTS" => return vec![3544], - // Shellcode / generic catch-alls - // Suricata default: !80 — we can't enumerate the complement, treat as any - "$SHELLCODE_PORTS" => return Vec::new(), - // Wildcard - "any" | "!any" => return Vec::new(), - _ => {} - } - - let token = token; // keep the &str binding - - // Any remaining unknown $VAR — treat as any (no port filter) to avoid - // infinite recursion in the group-expansion loop below. - if token.starts_with('$') { - return Vec::new(); - } - - // Negation-only → treat as any (we can't enumerate the complement) - if token.starts_with('!') && !token.starts_with("![") { - return Vec::new(); - } - - // Port group: [80,443,!8080] — strip outer brackets - let inner = if token.starts_with('[') && token.ends_with(']') { - &token[1..token.len() - 1] - } else { - token - }; - - let mut ports = Vec::new(); - let mut has_negation_only = true; - for part in inner.split(',') { - let part = part.trim(); - if part.is_empty() { - continue; - } - if part.starts_with('!') { - // negated entry — skip but don't block the group - continue; - } - has_negation_only = false; - // Recurse only for variable names (start with '$') or nested groups - if part.starts_with('$') || part.starts_with('[') { - ports.extend(expand_port_token(part)); - continue; - } - // Port range: 1024:2048 - if let Some(colon) = part.find(':') { - let lo: u16 = part[..colon].parse().unwrap_or(0); - let hi: u16 = part[colon + 1..].parse().unwrap_or(65535); - // Don't expand huge ranges — treat as any - if hi.saturating_sub(lo) > 1024 { - return Vec::new(); - } - for p in lo..=hi { - ports.push(p); - } - continue; - } - if let Ok(p) = part.parse::() { - ports.push(p); - } - } - - // If the group contained only negations, fall back to any - if has_negation_only && ports.is_empty() { - return Vec::new(); - } - - ports -} - -/// True when the rule's flow: keyword includes "established". -fn extract_flow_established(rule: &str) -> bool { - let Some(pos) = rule.find("flow:") else { return false }; - let rest = &rule[pos + 5..]; - let end = rest.find(';').unwrap_or(rest.len()); - rest[..end].contains("established") -} - -/// Extract the IP constraint flag for the header token at `token_idx`. -/// token_idx 2 = src_ip, token_idx 5 = dst_ip. -/// Returns 0=any, 1=$HOME_NET, 2=$EXTERNAL_NET. -fn extract_ip_flag(rule: &str, token_idx: usize) -> u8 { - let header = rule.find('(').map(|p| &rule[..p]).unwrap_or(rule); - let tokens: Vec<&str> = header.split_whitespace().collect(); - let tok = tokens.get(token_idx).copied().unwrap_or("any"); - ip_token_to_flag(tok) -} - -fn ip_token_to_flag(token: &str) -> u8 { - // Strip outer brackets for group notation like [$HOME_NET] - let t = if token.starts_with('[') && token.ends_with(']') { - &token[1..token.len() - 1] - } else { - token - }; - match t { - "$HOME_NET" => 1, - "!$HOME_NET" => 2, - "$EXTERNAL_NET" => 2, - "!$EXTERNAL_NET" => 1, - _ => 0, - } -} - -/// Parse all `pcre:"..."` options from a rule line. -/// Handles flags: i (caseless), s (dotall), m (multiline); -/// buffer flags: U=http_uri, H=http_header, P=http_client_body, -/// Q=http_server_body, M=http_method, C=http_cookie, S=http_stat_code, -/// V=http_user_agent, W=http_host, I=http_raw_uri. -fn extract_pcre_entries(rule: &str) -> Vec { - let tokens = tokenize_options(rule); - let mut entries = Vec::new(); - - for tok in &tokens { - let rest = if let Some(r) = tok.strip_prefix("pcre:") { - r - } else { - continue; - }; - - let negated = rest.starts_with('!'); - let quoted = if negated { &rest[1..] } else { rest }; - if !quoted.starts_with('"') { - continue; - } - - let inner = unquote_content(quoted); - if !inner.starts_with('/') { - continue; - } - - let body = &inner[1..]; - let Some(close) = body.rfind('/') else { continue }; - let pattern_str = &body[..close]; - let flags_str = &body[close + 1..]; - - let mut buffer: u8 = BUF_RAW; - let mut regex_mods = String::new(); - - for c in flags_str.chars() { - match c { - 'i' => regex_mods.push('i'), - 's' => regex_mods.push('s'), - 'm' => regex_mods.push('m'), - 'U' | 'I' => buffer = BUF_HTTP_URI, - 'H' | 'D' => buffer = BUF_HTTP_HEADER, - 'P' => buffer = BUF_HTTP_CLIENT_BODY, - 'Q' => buffer = BUF_HTTP_SERVER_BODY, - 'M' => buffer = BUF_HTTP_METHOD, - 'C' => buffer = BUF_HTTP_COOKIE, - 'S' => buffer = BUF_HTTP_STAT_CODE, - 'V' => buffer = BUF_HTTP_USER_AGENT, - 'W' => buffer = BUF_HTTP_HOST, - _ => {} - } - } - - let full_pattern = if regex_mods.is_empty() { - pattern_str.to_string() - } else { - format!("(?{}){}", regex_mods, pattern_str) - }; - - entries.push(PcreEntry { - pattern: full_pattern, - negated, - buffer, - }); - } - - entries -} - -/// Parse the app-layer-protocol: keyword from a rule line. -/// Returns (proto_id, negated). proto_id 0 means no keyword or unrecognised value. -fn extract_alproto(rule: &str) -> (u8, bool) { - let prefix = "app-layer-protocol:"; - let pos = match rule.find(prefix) { - Some(p) => p + prefix.len(), - None => return (0, false), - }; - let rest = &rule[pos..]; - let end = rest.find(';').unwrap_or(rest.len()); - let val = rest[..end].trim(); - let (negated, name) = if val.starts_with('!') { - (true, val[1..].trim()) - } else { - (false, val) - }; - let id = match name { - "http" | "http-any" => 1u8, - "http2" => 2, - "tls" | "ssl" => 3, - "dns" => 4, - "ssh" => 5, - "smtp" => 6, - "ftp" => 7, - "mqtt" => 8, - "quic" => 9, - _ => 0, - }; - (id, negated) -} - -/// Parse `threshold: type T, track K, count N, seconds S` from a rule option. -/// -/// Ports Suricata detect-threshold.c DetectThresholdParse. -/// kind: 1=limit 2=both 3=threshold -/// track: 1=TRACK_DST 2=TRACK_SRC 3=TRACK_RULE 5=TRACK_BOTH 6=TRACK_FLOW -fn extract_threshold(rule: &str) -> Option { - let pos = rule.find("threshold:")?; - let rest = &rule[pos + 10..]; - let end = rest.find(';').unwrap_or(rest.len()); - let opts = &rest[..end]; - - let mut kind = 0u8; - let mut track = 3u8; - let mut count = 0u32; - let mut seconds = 0u32; - - for part in opts.split(',') { - let part = part.trim(); - if let Some(v) = part.strip_prefix("type ") { - kind = match v.trim() { - "limit" => 1, - "both" => 2, - "threshold" => 3, - _ => 0, - }; - } else if let Some(v) = part.strip_prefix("track ") { - track = match v.trim() { - "by_dst" => 1, - "by_src" => 2, - "by_rule" => 3, - "by_both" => 5, - "by_flow" => 6, - _ => 3, - }; - } else if let Some(v) = part.strip_prefix("count ") { - count = v.trim().parse().unwrap_or(0); - } else if let Some(v) = part.strip_prefix("seconds ") { - seconds = v.trim().parse().unwrap_or(0); - } - } - - if kind == 0 || count == 0 || seconds == 0 { - return None; - } - - Some(ThresholdEntry { - kind, - track, - count, - seconds, - }) -} - -/// Parse all byte_test / byte_jump / byte_extract / isdataat keywords from a rule. -/// Returns them in declaration order; they are evaluated after the content chain passes. -fn extract_byte_ops(rule: &str) -> Vec { - let tokens = tokenize_options(rule); - let mut ops: Vec = Vec::new(); - let mut var_names: Vec = Vec::new(); - let mut sticky_buffer: u8 = BUF_RAW; - - for tok in &tokens { - let tok = tok.as_str(); - if let Some(buf) = detect_buffer_keyword(tok) { - sticky_buffer = buf; - continue; - } - if let Some(rest) = tok.strip_prefix("byte_test:") { - if let Some(op) = parse_byte_test(rest, &var_names, sticky_buffer) { - ops.push(op); - } - } else if let Some(rest) = tok.strip_prefix("byte_jump:") { - if let Some(op) = parse_byte_jump(rest, sticky_buffer) { - ops.push(op); - } - } else if let Some(rest) = tok.strip_prefix("byte_extract:") { - if let Some((op, name)) = parse_byte_extract(rest, &var_names, sticky_buffer) { - if var_names.len() < 8 { - var_names.push(name); - } - ops.push(op); - } - } else if let Some(rest) = tok.strip_prefix("isdataat:") { - if let Some(op) = parse_isdataat(rest, sticky_buffer) { - ops.push(op); - } - } - } - ops -} - -/// Parse a `byte_test:count,op,value,offset[,options...]` argument string. -fn parse_byte_test(args: &str, var_names: &[String], buffer: u8) -> Option { - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - if parts.len() < 4 { - return None; - } - - let count = parts[0].parse::().ok()?; - - let op_str = parts[1].trim(); - let (bt_negated, op_clean) = if op_str.starts_with('!') { - (true, op_str[1..].trim()) - } else { - (false, op_str) - }; - let bt_op: u8 = match op_clean { - "<" => 0, - ">" => 1, - "=" | "==" => 2, - "!=" => 3, - "<=" => 4, - ">=" => 5, - "&" => 6, - "^" => 7, - _ => return None, - }; - - let val_str = parts[2].trim(); - let (bt_value, bt_uses_var) = if let Some(idx) = var_names.iter().position(|n| n == val_str) { - (idx as i64, true) - } else { - let n = parse_num_u64(val_str).unwrap_or(0); - (n as i64, false) - }; - - let offset = parts[3].trim().parse::().unwrap_or(0); - - let mut relative = false; - let mut endian_little = false; - let mut string_mode = false; - let mut string_base: u8 = 0; - let mut bt_bitmask: i64 = 0; - - let mut i = 4; - while i < parts.len() { - match parts[i] { - "relative" => relative = true, - "little" => endian_little = true, - "big" => endian_little = false, - "string" => string_mode = true, - "hex" => { - string_mode = true; - string_base = 1; - } - "dec" => { - string_mode = true; - string_base = 0; - } - "oct" => { - string_mode = true; - string_base = 2; - } - "bitmask" => { - i += 1; - if let Some(mask_str) = parts.get(i) { - bt_bitmask = parse_num_u64(mask_str.trim()).unwrap_or(0) as i64; - } - } - _ => {} - } - i += 1; - } - - Some(ByteOp { - kind: 0, - count, - offset, - relative, - endian_little, - string_mode, - string_base, - buffer, - bt_op, - bt_value, - bt_uses_var, - bt_bitmask, - bt_negated, - bj_align: false, - bj_multiplier: 1, - bj_from_beginning: false, - bj_post_offset: 0, - be_var_idx: 0, - be_multiplier: 1, - }) -} - -/// Parse a `byte_jump:count,offset[,options...]` argument string. -fn parse_byte_jump(args: &str, buffer: u8) -> Option { - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - if parts.len() < 2 { - return None; - } - - let count = parts[0].parse::().ok()?; - let offset = parts[1].parse::().unwrap_or(0); - - let mut relative = false; - let mut endian_little = false; - let mut string_mode = false; - let mut string_base: u8 = 0; - let mut bj_align = false; - let mut bj_multiplier: i64 = 1; - let mut bj_from_beginning = false; - let mut bj_post_offset: i32 = 0; - - let mut i = 2; - while i < parts.len() { - match parts[i] { - "relative" => relative = true, - "little" => endian_little = true, - "big" => endian_little = false, - "string" => string_mode = true, - "hex" => { - string_mode = true; - string_base = 1; - } - "dec" => { - string_mode = true; - string_base = 0; - } - "oct" => { - string_mode = true; - string_base = 2; - } - "align" => bj_align = true, - "from_beginning" => bj_from_beginning = true, - "multiplier" => { - i += 1; - if let Some(v) = parts.get(i) { - bj_multiplier = v.trim().parse::().unwrap_or(1); - } - } - "post_offset" => { - i += 1; - if let Some(v) = parts.get(i) { - bj_post_offset = v.trim().parse::().unwrap_or(0); - } - } - _ => {} - } - i += 1; - } - - Some(ByteOp { - kind: 1, - count, - offset, - relative, - endian_little, - string_mode, - string_base, - buffer, - bt_op: 0, - bt_value: 0, - bt_uses_var: false, - bt_bitmask: 0, - bt_negated: false, - bj_align, - bj_multiplier, - bj_from_beginning, - bj_post_offset, - be_var_idx: 0, - be_multiplier: 1, - }) -} - -/// Parse a `byte_extract:count,offset,name[,options...]` argument string. -/// Returns (ByteOp, variable_name) so the caller can register the slot. -fn parse_byte_extract(args: &str, var_names: &[String], buffer: u8) -> Option<(ByteOp, String)> { - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - if parts.len() < 3 { - return None; - } - - let count = parts[0].parse::().ok()?; - let offset = parts[1].parse::().unwrap_or(0); - let name = parts[2].trim().to_string(); - - let be_var_idx = var_names.iter().position(|n| n == &name).unwrap_or(var_names.len()) as u8; - - let mut relative = false; - let mut endian_little = false; - let mut string_mode = false; - let mut string_base: u8 = 0; - let mut be_multiplier: i64 = 1; - - let mut i = 3; - while i < parts.len() { - match parts[i] { - "relative" => relative = true, - "little" => endian_little = true, - "big" => endian_little = false, - "string" => string_mode = true, - "hex" => { - string_mode = true; - string_base = 1; - } - "dec" => { - string_mode = true; - string_base = 0; - } - "oct" => { - string_mode = true; - string_base = 2; - } - "multiplier" => { - i += 1; - if let Some(v) = parts.get(i) { - be_multiplier = v.trim().parse::().unwrap_or(1); - } - } - _ => {} - } - i += 1; - } - - Some(( - ByteOp { - kind: 2, - count, - offset, - relative, - endian_little, - string_mode, - string_base, - buffer, - bt_op: 0, - bt_value: 0, - bt_uses_var: false, - bt_bitmask: 0, - bt_negated: false, - bj_align: false, - bj_multiplier: 1, - bj_from_beginning: false, - bj_post_offset: 0, - be_var_idx, - be_multiplier, - }, - name, - )) -} - -/// Parse an `isdataat:[!]offset[,relative]` argument string. -fn parse_isdataat(args: &str, buffer: u8) -> Option { - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - if parts.is_empty() { - return None; - } - - let first = parts[0].trim(); - let (bt_negated, offset_str) = if first.starts_with('!') { - (true, first[1..].trim()) - } else { - (false, first) - }; - - let offset = offset_str.parse::().ok()?; - - let mut relative = false; - for opt in parts.iter().skip(1) { - if *opt == "relative" { - relative = true; - } - } - - Some(ByteOp { - kind: 3, - count: 0, - offset, - relative, - endian_little: false, - string_mode: false, - string_base: 0, - buffer, - bt_op: 0, - bt_value: 0, - bt_uses_var: false, - bt_bitmask: 0, - bt_negated, - bj_align: false, - bj_multiplier: 1, - bj_from_beginning: false, - bj_post_offset: 0, - be_var_idx: 0, - be_multiplier: 1, - }) -} - -/// Parse a numeric literal that may be decimal (`123`) or hex (`0x1A`). -fn parse_num_u64(s: &str) -> Option { - let s = s.trim(); - if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { - u64::from_str_radix(hex, 16).ok() - } else { - s.parse::().ok() - } -} - -/// Parse all `flowbits:op[,name]` options from a rule line. -/// Mirrors Suricata detect-flowbits.h: -/// 0=set 1=toggle 2=unset 3=isnotset 4=isset 5=noalert(internal) -fn extract_flowbit_entries(rule: &str) -> Vec { - let tokens = tokenize_options(rule); - let mut entries = Vec::new(); - - for tok in &tokens { - let rest = if let Some(r) = tok.strip_prefix("flowbits:") { - r - } else { - continue; - }; - - let mut parts = rest.splitn(2, ','); - let op_str = parts.next().unwrap_or("").trim(); - let name = parts.next().unwrap_or("").trim().to_string(); - - let op = match op_str { - "set" => 0u8, - "toggle" => 1, - "unset" => 2, - "isnotset" => 3, - "isset" => 4, - "noalert" => 5, - _ => continue, - }; - - entries.push(FlowbitEntry { op, name }); - } - - entries -} diff --git a/net-guardia/src/core/ebpf/mod.rs b/net-guardia/src/core/ebpf/mod.rs index ff32e3d..15e9573 100644 --- a/net-guardia/src/core/ebpf/mod.rs +++ b/net-guardia/src/core/ebpf/mod.rs @@ -15,8 +15,8 @@ use crate::core::ebpf::service::Service; use crate::core::ebpf::statistics::Statistics; use crate::core::ebpf::xsk_manager::XskManager; use crate::core::infrastructure::app_config::AppConfig; -use crate::detection::fusion::FusionEngine; use crate::detection::ml::engine::Engine; +use crate::detection::suricata::SuricataEngine; use crate::model::error::system::SystemError; use crate::model::error::Error; @@ -51,12 +51,12 @@ impl EbpfServices { pub async fn run( self: Arc, ml_engine: Arc, - fusion_engine: Arc, + suricata_engine: Arc, ) -> Result<(), Error> { let xsk_manager = self.xsk_manager.clone(); let statistics = self.statistics.clone(); - xsk_manager.run(Some(ml_engine), Some(fusion_engine), &self.shutdowns)?; + xsk_manager.run(Some(ml_engine), Some(suricata_engine), &self.shutdowns)?; let statistics_shutdown = statistics.run().await; self.shutdowns.push(statistics_shutdown); diff --git a/net-guardia/src/core/ebpf/xsk_manager.rs b/net-guardia/src/core/ebpf/xsk_manager.rs index ad6fd96..3f67667 100644 --- a/net-guardia/src/core/ebpf/xsk_manager.rs +++ b/net-guardia/src/core/ebpf/xsk_manager.rs @@ -17,18 +17,14 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem}; use crate::core::infrastructure::app_config::AppConfig; -use crate::detection::fusion::FusionEngine; use crate::detection::ml::engine::Engine; -use crate::detection::rule::rule_engine::RuleEngine; -use crate::detection::rule::stream_reassembler::StreamReassembler; +use crate::detection::suricata::SuricataEngine; use crate::model::config::Config; use crate::model::direction::Direction; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; use crate::model::error::Error; use crate::model::log::ebpf::EbpfLog; -use crate::model::log::ml::MLLog; -use crate::model::log::rule::RuleLog; use crate::utils::cpu_affinity::set_cpu_affinity; pub struct XskManager { @@ -57,25 +53,12 @@ impl XskManager { pub fn run( &self, engine: Option>, - fusion_engine: Option>, + suricata_engine: Option>, shutdowns: &SegQueue>, ) -> Result<(), Error> { let config = self.app_config.config.clone(); let combined_queue_count = config.combined_queue_count; - let min_sig = config.min_signature_matches; - - let rule_engine = match RuleEngine::new(&config.home_net) { - Some(re) => { - log!(RuleLog::RulesLoaded(re.pattern_count())); - Some(Arc::new(re)) - } - None => { - log!(RuleLog::RulesDisabled); - None - } - }; - for queue_id in 0..combined_queue_count { let (ingress_to_egress_tx, ingress_to_egress_rx) = bounded(config.channel_size); let (egress_to_ingress_tx, egress_to_ingress_rx) = bounded(config.channel_size); @@ -84,24 +67,18 @@ impl XskManager { config.clone(), queue_id, &config.ingress_ifname, - &config.egress_ifname, Direction::Ingress, engine.clone(), - rule_engine.clone(), - fusion_engine.clone(), - min_sig, + suricata_engine.clone(), )?; let egress_xsk = XskPair::new( config.clone(), queue_id, &config.egress_ifname, - &config.ingress_ifname, Direction::Egress, engine.clone(), - rule_engine.clone(), - fusion_engine.clone(), - min_sig, + suricata_engine.clone(), )?; let mut xsk_map = self.xsk_map.lock(); @@ -142,9 +119,7 @@ pub struct XskPair { rx: RxQueue, frame_pool: Arc>>, engine: Option>, - rule_engine: Option>, - fusion_engine: Option>, - min_signature_matches: u32, + suricata_engine: Option>, } impl XskPair { @@ -152,12 +127,9 @@ impl XskPair { config: Config, queue_id: u32, rx_ifname: &str, - tx_ifname: &str, direction: Direction, engine: Option>, - rule_engine: Option>, - fusion_engine: Option>, - min_signature_matches: u32, + suricata_engine: Option>, ) -> Result { let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::UnknownError)?; @@ -215,9 +187,7 @@ impl XskPair { rx, frame_pool: Arc::new(Mutex::new(pool_frames)), engine, - rule_engine, - fusion_engine, - min_signature_matches, + suricata_engine, }; Ok(xsk_pair) @@ -240,13 +210,6 @@ impl XskPair { set_cpu_affinity((start + self.queue_id % num_cores) as usize); } - // StreamReassembler is !Send (Rc inside protolens), so create it here. - let min_sig_matches = self.min_signature_matches; - let is_ingress = self.direction == Direction::Ingress; - let mut reassembler: Option = self - .rule_engine - .take() - .map(|re| StreamReassembler::new(re, min_sig_matches, is_ingress)); let mut shutdown_rx = Some(shutdown_rx); let mut idle_count: u32 = 0; @@ -269,7 +232,7 @@ impl XskPair { Err(e) => log!(EbpfLog::CompQueueError(format!("{:?}", e))), } - match self.process_rx_queue(&forward_tx, &mut reassembler) { + match self.process_rx_queue(&forward_tx) { Ok(count) => total_activity += count, Err(e) => log!(EbpfLog::RXQueueError(format!("{:?}", e))), } @@ -285,17 +248,7 @@ impl XskPair { idle_count = 0; } - // Purge idle TCP flows every 60 s of wall time. if last_cleanup.elapsed() >= Duration::from_secs(60) { - if let Some(r) = &mut reassembler { - r.cleanup_stale(Duration::from_secs(60)); - log!(RuleLog::SignatureStats( - format!("{:?}", self.direction), - r.flow_count(), - r.min_matches(), - format!("{:?}", r.match_counts()), - )); - } last_cleanup = std::time::Instant::now(); } @@ -336,7 +289,6 @@ impl XskPair { fn process_rx_queue( &mut self, forward_tx: &Sender>, - reassembler: &mut Option, ) -> Result { let mut rx_descs = vec![FrameDesc::default(); 64]; let rx_count = unsafe { self.rx.consume(&mut rx_descs) }; @@ -349,24 +301,8 @@ impl XskPair { let data = unsafe { self.umem.data(rx_desc) }; let packet_data = data.contents()[..packet_len].to_vec(); - if let Some(r) = &mut *reassembler { - match r.process(&packet_data) { - Ok(matches) => { - for m in &matches { - log!(RuleLog::SignatureMatch( - format!("{:?}", self.direction), - m.src.clone(), - m.dst.clone(), - m.sid, - m.msg.clone(), - )); - if let Some(ref fe) = self.fusion_engine { - fe.record_rule(m); - } - } - } - Err(e) => log!(e), - } + if let Some(ref se) = self.suricata_engine { + se.inject(packet_data.clone()); } if let Some(ref engine) = self.engine { diff --git a/net-guardia/src/core/infrastructure/mod.rs b/net-guardia/src/core/infrastructure/mod.rs index cf4b18b..b7ab6cd 100644 --- a/net-guardia/src/core/infrastructure/mod.rs +++ b/net-guardia/src/core/infrastructure/mod.rs @@ -21,6 +21,7 @@ use crate::detection::ml::engine::Engine; use crate::detection::ml::feature_extractor::FlowFeatures; use crate::detection::ml::model_loader::MLModels; use crate::detection::ml::traffic_logger::TrafficLogger; +use crate::detection::suricata::SuricataEngine; use crate::model::error::misc::MiscError; use crate::model::error::Error; use crate::model::error::system::SystemError; @@ -32,6 +33,7 @@ pub struct AppServices { pub fusion_engine: Arc, pub ml_models: Arc, pub ml_engine: Arc, + pub suricata_engine: Arc, shutdowns: SegQueue>, } @@ -75,12 +77,22 @@ impl AppServices { app_config.ml_cpu, )); + let rule_path = PathBuf::from(env!("RULE_PATH")).join(&app_config.suricata_name); + let eve_socket = PathBuf::from(env!("RULE_EVE_PATH")); + + let suricata_engine = SuricataEngine::start( + rule_path, + eve_socket, + fusion_engine.clone(), + )?; + Ok(Self { health: Arc::new(health), detection_alert, fusion_engine, ml_models, ml_engine, + suricata_engine, shutdowns: SegQueue::new(), }) } diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/core/system.rs index 02c7b65..3cd61cc 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/core/system.rs @@ -82,7 +82,7 @@ impl System { log!(SystemLog::InitializeComplete); self.attach_ebpf()?; - ebpf_services.run(app_services.ml_engine.clone(), app_services.fusion_engine.clone()).await?; + ebpf_services.run(app_services.ml_engine.clone(), app_services.suricata_engine.clone()).await?; app_services.run().await?; self.run_http_server().await?; Ok(()) diff --git a/net-guardia/src/detection/ml/feature_extractor.rs b/net-guardia/src/detection/ml/feature_extractor.rs index 1344681..3b2e236 100644 --- a/net-guardia/src/detection/ml/feature_extractor.rs +++ b/net-guardia/src/detection/ml/feature_extractor.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use chrono::{DateTime, Utc, TimeZone}; +use chrono::{Utc, TimeZone}; use super::flow_tracker::FlowData; use crate::model::ml_detection::{ClipParams, PacketData}; diff --git a/net-guardia/src/detection/mod.rs b/net-guardia/src/detection/mod.rs index ebe9df7..12c65bf 100644 --- a/net-guardia/src/detection/mod.rs +++ b/net-guardia/src/detection/mod.rs @@ -1,3 +1,3 @@ pub mod fusion; pub mod ml; -pub mod rule; \ No newline at end of file +pub mod suricata; \ No newline at end of file diff --git a/net-guardia/src/detection/rule/app_layer/dns.rs b/net-guardia/src/detection/rule/app_layer/dns.rs deleted file mode 100644 index aff0367..0000000 --- a/net-guardia/src/detection/rule/app_layer/dns.rs +++ /dev/null @@ -1,357 +0,0 @@ -/* Ported from Suricata rust/src/dns/parser.rs - * Copyright (C) 2017-2022 Open Information Security Foundation - * Licensed under GPLv2. - * - * Rewritten to use nom8 (nom = "8.0" aliased as nom8). - * Safety limits match Suricata: - * MAX_NAME_LEN = 1025 (same as DNSMasq) - * MAX_LABEL_COUNT = 255 - */ - -use nom8::error::Error as NomError; -use nom8::multi::length_data; -use nom8::number::streaming::{be_u16, be_u8}; -use nom8::Parser; - -const MAX_NAME_LEN: usize = 1025; -const MAX_LABEL_COUNT: usize = 255; - -#[derive(Debug, Default)] -pub struct DnsInfo { - pub query_name: Vec, - pub qtype: Vec, - pub rcode: Vec, - pub answer: Vec, -} - -pub fn parse_dns(data: &[u8]) -> Option { - if data.len() < 12 { - return None; - } - - let base = if has_tcp_length_prefix(data) { 2 } else { 0 }; - let msg = data.get(base..)?; - if msg.len() < 12 { - return None; - } - - // Parse DNS header with nom8 - let (rest, _id) = be_u16::<_, NomError<_>>(msg).ok()?; - let (rest, flags) = be_u16::<_, NomError<_>>(rest).ok()?; - let (rest, qdcount) = be_u16::<_, NomError<_>>(rest).ok()?; - let (rest, ancount) = be_u16::<_, NomError<_>>(rest).ok()?; - let (rest, _nscount) = be_u16::<_, NomError<_>>(rest).ok()?; - let (_rest, _arcount) = be_u16::<_, NomError<_>>(rest).ok()?; - - let rcode_val = (flags & 0x000F) as u8; - - if qdcount == 0 { - return None; - } - - // Work with absolute offsets into msg for pointer resolution - let mut pos = 12usize; - - // Question section: QNAME + QTYPE(2) + QCLASS(2) - let (query_name, next_pos) = dns_parse_name(msg, pos)?; - pos = next_pos; - - if pos + 4 > msg.len() { - return None; - } - let qtype_val = u16::from_be_bytes([msg[pos], msg[pos + 1]]); - pos += 4; // skip QTYPE + QCLASS - - let mut info = DnsInfo { - query_name, - qtype: qtype_str(qtype_val).as_bytes().to_vec(), - rcode: rcode_val.to_string().into_bytes(), - answer: Vec::new(), - }; - - // Answer section - for _ in 0..ancount { - let (_, next_pos) = match dns_parse_name(msg, pos) { - Some(r) => r, - None => break, - }; - pos = next_pos; - - // rrtype(2) + rrclass(2) + ttl(4) + rdlength(2) = 10 bytes - if pos + 10 > msg.len() { - break; - } - let rtype = u16::from_be_bytes([msg[pos], msg[pos + 1]]); - let rdlength = u16::from_be_bytes([msg[pos + 8], msg[pos + 9]]) as usize; - pos += 10; - - if pos + rdlength > msg.len() { - break; - } - - if info.answer.is_empty() { - if let Some(s) = parse_rdata(rtype, &msg[pos..pos + rdlength], msg, pos) { - info.answer = s; - } - } - - pos += rdlength; - } - - Some(info) -} - -// ─── Name parser (Suricata dns_parse_name algorithm, ported to nom8) ────────── - -/// Parse a DNS name starting at `start` within `msg`. -/// -/// Uses nom8 for label parsing (length_data + be_u8, be_u16 for pointers). -/// Returns (name_bytes, end_pos_in_msg). -/// end_pos is positioned after the first pointer encountered, or after the -/// null terminator if no pointer was used. Matches Suricata's pivot logic. -/// -/// Safety limits: -/// - MAX_LABEL_COUNT = 255 segments -/// - MAX_NAME_LEN = 1025 characters; name is truncated if exceeded -fn dns_parse_name(msg: &[u8], start: usize) -> Option<(Vec, usize)> { - let mut pos = start; - let mut pivot: Option = None; - let mut name: Vec = Vec::with_capacity(32); - let mut count = 0usize; - let mut truncated = false; - - loop { - if pos >= msg.len() { - return None; - } - - let len = msg[pos]; - - if len == 0x00 { - // Null terminator: end of name - pos += 1; - break; - } else if len & 0b1100_0000 == 0 { - // Plain label: use nom8 length_data(be_u8) to consume length + bytes - let input = &msg[pos..]; - let (_, label) = length_data(be_u8::<_, NomError<_>>).parse(input).ok()?; - if !truncated { - if !name.is_empty() { - name.push(b'.'); - } - name.extend_from_slice(label); - } - // Advance: 1 byte (length) + label bytes - pos += 1 + label.len(); - } else if len & 0b1100_0000 == 0b1100_0000 { - // Pointer: use nom8 be_u16 to read the two-byte leader - let input = &msg[pos..]; - let (_, leader) = be_u16::<_, NomError<_>>(input).ok()?; - let offset = (leader & 0x3fff) as usize; - if offset >= msg.len() { - return None; - } - // Self-reference detection (Suricata: compare slice pointers) - if offset == pos { - if pivot.is_some() { - break; - } - return None; - } - if pivot.is_none() { - pivot = Some(pos + 2); - } - pos = offset; - } else { - return None; - } - - count += 1; - if count > MAX_LABEL_COUNT { - if pivot.is_some() { - // We know where the name ends; truncate and stop - break; - } - // Without a pointer we cannot locate the next unit; error out - return None; - } - - if name.len() > MAX_NAME_LEN { - name.truncate(MAX_NAME_LEN); - truncated = true; - if pivot.is_some() { - break; - } - // Continue scanning to find null terminator, but stop appending - } - } - - // Return position after first pointer, or after null terminator - let end = pivot.unwrap_or(pos); - Some((name, end)) -} - -// ─── RDATA decoders ─────────────────────────────────────────────────────────── - -/// Decode RDATA into a printable form for rule matching. -/// `rdata_pos` is the absolute position of `rdata` within `msg`. -fn parse_rdata(rtype: u16, rdata: &[u8], msg: &[u8], rdata_pos: usize) -> Option> { - match rtype { - // A: dotted-decimal IPv4 - 1 => { - if rdata.len() != 4 { - return None; - } - Some(format!("{}.{}.{}.{}", rdata[0], rdata[1], rdata[2], rdata[3]).into_bytes()) - } - // AAAA: colon-hex IPv6 - 28 => { - if rdata.len() != 16 { - return None; - } - let groups: Vec = rdata - .chunks(2) - .map(|g| format!("{:02x}{:02x}", g[0], g[1])) - .collect(); - Some(groups.join(":").into_bytes()) - } - // CNAME (5), NS (2), PTR (12): name starting at rdata - 5 | 2 | 12 => dns_parse_name(msg, rdata_pos).map(|(n, _)| n), - // MX (15): skip 2-byte preference, then name - 15 => { - if rdata.len() < 2 { - return None; - } - dns_parse_name(msg, rdata_pos + 2).map(|(n, _)| n) - } - // SOA (6): mname + rname - 6 => { - let (mname, pos1) = dns_parse_name(msg, rdata_pos)?; - let (rname, _) = dns_parse_name(msg, pos1)?; - Some( - format!( - "{} {}", - String::from_utf8_lossy(&mname), - String::from_utf8_lossy(&rname) - ) - .into_bytes(), - ) - } - // SRV (33): priority + weight + port + target name - 33 => { - if rdata.len() < 6 { - return None; - } - let priority = u16::from_be_bytes([rdata[0], rdata[1]]); - let weight = u16::from_be_bytes([rdata[2], rdata[3]]); - let port = u16::from_be_bytes([rdata[4], rdata[5]]); - let (target, _) = dns_parse_name(msg, rdata_pos + 6)?; - Some( - format!( - "{} {} {} {}", - priority, - weight, - port, - String::from_utf8_lossy(&target) - ) - .into_bytes(), - ) - } - // TXT (16): one or more length-prefixed character strings (nom8 length_data loop) - 16 => { - let mut out = Vec::new(); - let mut input = rdata; - while !input.is_empty() { - match length_data(be_u8::<_, NomError<_>>).parse(input) { - Ok((rem, s)) => { - out.extend_from_slice(s); - input = rem; - } - Err(_) => break, - } - } - if out.is_empty() { None } else { Some(out) } - } - // SSHFP (44): algo + fp_type + fingerprint hex - 44 => { - if rdata.len() < 2 { - return None; - } - let algo = rdata[0]; - let fp_type = rdata[1]; - let hex: String = rdata[2..].iter().map(|b| format!("{:02x}", b)).collect(); - Some(format!("{} {} {}", algo, fp_type, hex).into_bytes()) - } - // NULL (10): raw bytes verbatim - 10 => Some(rdata.to_vec()), - // OPT (41): extended DNS metadata, not suitable for rule matching - 41 => None, - _ => None, - } -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/// Full QTYPE string table matching Suricata's DNSRecordType enum. -fn qtype_str(t: u16) -> &'static str { - match t { - 1 => "A", - 2 => "NS", - 3 => "MD", - 4 => "MF", - 5 => "CNAME", - 6 => "SOA", - 7 => "MB", - 8 => "MG", - 9 => "MR", - 10 => "NULL", - 11 => "WKS", - 12 => "PTR", - 13 => "HINFO", - 14 => "MINFO", - 15 => "MX", - 16 => "TXT", - 17 => "RP", - 18 => "AFSDB", - 28 => "AAAA", - 33 => "SRV", - 35 => "NAPTR", - 36 => "KX", - 37 => "CERT", - 38 => "A6", - 39 => "DNAME", - 41 => "OPT", - 42 => "APL", - 43 => "DS", - 44 => "SSHFP", - 45 => "IPSECKEY", - 46 => "RRSIG", - 47 => "NSEC", - 48 => "DNSKEY", - 50 => "NSEC3", - 51 => "NSEC3PARAM", - 52 => "TLSA", - 53 => "SMIMEA", - 55 => "HIP", - 59 => "CDS", - 60 => "CDNSKEY", - 61 => "OPENPGPKEY", - 62 => "CSYNC", - 65 => "HTTPS", - 99 => "SPF", - 255 => "ANY", - 256 => "URI", - 257 => "CAA", - 32768 => "TA", - 32769 => "DLV", - _ => "", - } -} - -fn has_tcp_length_prefix(data: &[u8]) -> bool { - if data.len() < 14 { - return false; - } - let len = u16::from_be_bytes([data[0], data[1]]) as usize; - len >= 12 && len + 2 <= data.len() -} diff --git a/net-guardia/src/detection/rule/app_layer/ftp.rs b/net-guardia/src/detection/rule/app_layer/ftp.rs deleted file mode 100644 index 0199d82..0000000 --- a/net-guardia/src/detection/rule/app_layer/ftp.rs +++ /dev/null @@ -1,74 +0,0 @@ -/// FTP protocol parser. -/// -/// Extracts the first command verb, filename argument (for data-transfer commands), -/// and the first 3-digit reply code from a single TCP payload chunk. -/// -/// FTP commands and their arguments are described in RFC 959. - -pub struct FtpInfo { - /// FTP command in uppercase, e.g. "RETR". - pub command: Vec, - /// Filename argument for data-transfer commands (RETR, STOR, APPE, etc.). - pub filename: Vec, - /// First 3-digit reply code, e.g. "220". - pub reply: Vec, -} - -/// Commands that carry a filename or path argument. -static FILE_CMDS: &[&[u8]] = &[ - b"RETR", b"STOR", b"APPE", b"RNFR", b"RNTO", b"DELE", - b"MKD", b"RMD", b"NLST", b"LIST", b"MLST", b"MLSD", - b"SIZE", b"MDTM", -]; - -pub fn parse_ftp(data: &[u8]) -> Option { - let mut info = FtpInfo { - command: Vec::new(), - filename: Vec::new(), - reply: Vec::new(), - }; - let mut found = false; - - for raw_line in data.split(|&b| b == b'\n') { - let line = strip_cr(raw_line); - if line.is_empty() { - continue; - } - - // Reply: line starts with three ASCII digits. - if line.len() >= 3 && line[..3].iter().all(|b| b.is_ascii_digit()) { - if info.reply.is_empty() { - info.reply = line[..3].to_vec(); - found = true; - } - continue; - } - - // Command: first token of all-alpha bytes. - let sp = line.iter().position(|&b| b == b' '); - let cmd = sp.map_or(line, |i| &line[..i]); - if cmd.is_empty() || !cmd.iter().all(|b| b.is_ascii_alphabetic()) { - continue; - } - - if info.command.is_empty() { - info.command = cmd.to_ascii_uppercase(); - found = true; - - if let Some(arg_start) = sp { - let arg = &line[arg_start + 1..]; - if !arg.is_empty() - && FILE_CMDS.iter().any(|&fc| cmd.eq_ignore_ascii_case(fc)) - { - info.filename = arg.to_vec(); - } - } - } - } - - if found { Some(info) } else { None } -} - -fn strip_cr(line: &[u8]) -> &[u8] { - line.strip_suffix(b"\r").unwrap_or(line) -} diff --git a/net-guardia/src/detection/rule/app_layer/http.rs b/net-guardia/src/detection/rule/app_layer/http.rs deleted file mode 100644 index 131b87b..0000000 --- a/net-guardia/src/detection/rule/app_layer/http.rs +++ /dev/null @@ -1,175 +0,0 @@ -/// Lightweight HTTP/1.x request and response parser. -/// -/// Extracts the fields used by Suricata/Snort HTTP sticky buffers so that -/// content rules targeted at `http.uri`, `http.user_agent`, etc. can be -/// verified against the correct byte slice instead of the raw TCP payload. - -#[derive(Debug, Default)] -pub struct HttpRequest { - pub method: Vec, - pub uri: Vec, - pub raw_uri: Vec, - pub host: Vec, - pub user_agent: Vec, - pub cookie: Vec, - /// Full request header section (everything before the blank line). - pub headers: Vec, - /// Request body (everything after the blank line). - pub client_body: Vec, -} - -#[derive(Debug, Default)] -pub struct HttpResponse { - pub stat_code: Vec, - /// Full response header section. - pub headers: Vec, - /// Response body. - pub server_body: Vec, -} - -static HTTP_METHODS: &[&[u8]] = &[ - b"GET", b"POST", b"PUT", b"DELETE", b"HEAD", - b"OPTIONS", b"PATCH", b"CONNECT", b"TRACE", -]; - -/// Try to parse `data` as an HTTP/1.x request. -/// Returns `None` when the data does not start with a recognised method. -pub fn parse_http_request(data: &[u8]) -> Option { - // Find first space — end of method token. - let sp1 = data.iter().position(|&b| b == b' ')?; - let method = &data[..sp1]; - - // Validate method - if !HTTP_METHODS.contains(&method) { - return None; - } - - // Find second space — end of URI. - let after_sp1 = sp1 + 1; - let sp2 = data[after_sp1..].iter().position(|&b| b == b' ')?; - let uri = data[after_sp1..after_sp1 + sp2].to_vec(); - - // Find end of first line. - let crlf1 = find_crlf(data, after_sp1 + sp2)?; - let headers_start = crlf1 + 2; - - // Find end of headers (blank line). - let (headers_end, body_start) = find_header_end(data, headers_start); - let headers_bytes = &data[..headers_end]; // includes request line - - let mut req = HttpRequest { - method: method.to_vec(), - raw_uri: uri.clone(), - uri: decode_uri(&uri), - headers: headers_bytes.to_vec(), - client_body: data.get(body_start..).unwrap_or(&[]).to_vec(), - ..Default::default() - }; - - // Walk individual header lines to extract named fields. - let mut pos = headers_start; - while pos < headers_end { - let line_end = find_crlf(data, pos).unwrap_or(headers_end); - let line = &data[pos..line_end]; - extract_header_field(line, &mut req); - pos = line_end + 2; - if pos >= headers_end { break; } - } - - Some(req) -} - -/// Try to parse `data` as an HTTP/1.x response. -/// Returns `None` when the data does not start with `HTTP/`. -pub fn parse_http_response(data: &[u8]) -> Option { - if !data.starts_with(b"HTTP/") { - return None; - } - - // "HTTP/1.x NNN " — status code starts after version + space. - let sp1 = data.iter().position(|&b| b == b' ')?; - let code_start = sp1 + 1; - let sp2 = data[code_start..].iter().position(|&b| b == b' ' || b == b'\r' || b == b'\n') - .unwrap_or(3); // 3-digit code - let stat_code = data[code_start..code_start + sp2].to_vec(); - - let crlf1 = find_crlf(data, code_start + sp2)?; - let headers_start = crlf1 + 2; - let (headers_end, body_start) = find_header_end(data, headers_start); - - Some(HttpResponse { - stat_code, - headers: data[..headers_end].to_vec(), - server_body: data.get(body_start..).unwrap_or(&[]).to_vec(), - }) -} - -/// Populate named fields from a single HTTP header line (`Name: value`). -fn extract_header_field(line: &[u8], req: &mut HttpRequest) { - let colon = match line.iter().position(|&b| b == b':') { - Some(p) => p, - None => return, - }; - let name = &line[..colon]; - let value = line.get(colon + 1..).map(|v| v.trim_ascii_start()).unwrap_or(&[]); - - if name.eq_ignore_ascii_case(b"Host") { - req.host = value.to_vec(); - } else if name.eq_ignore_ascii_case(b"User-Agent") { - req.user_agent = value.to_vec(); - } else if name.eq_ignore_ascii_case(b"Cookie") { - req.cookie = value.to_vec(); - } -} - -/// Find the first CRLF (`\r\n`) in `data` starting at `from`. -/// Returns the offset of `\r`. -fn find_crlf(data: &[u8], from: usize) -> Option { - let slice = data.get(from..)?; - let pos = slice.windows(2).position(|w| w == b"\r\n")?; - Some(from + pos) -} - -/// Find end-of-headers (blank line `\r\n\r\n`) starting at `from`. -/// Returns `(headers_end, body_start)` where `headers_end` is the offset of -/// the first `\r` of the blank line, and `body_start` is after the blank line. -fn find_header_end(data: &[u8], from: usize) -> (usize, usize) { - let slice = match data.get(from..) { - Some(s) => s, - None => return (data.len(), data.len()), - }; - if let Some(pos) = slice.windows(4).position(|w| w == b"\r\n\r\n") { - let abs = from + pos; - (abs + 2, abs + 4) // end at blank line start; body after blank line - } else { - (data.len(), data.len()) - } -} - -/// Percent-decode a URI byte slice. -fn decode_uri(uri: &[u8]) -> Vec { - let mut out = Vec::with_capacity(uri.len()); - let mut i = 0; - while i < uri.len() { - if uri[i] == b'%' && i + 2 < uri.len() { - if let (Some(h), Some(l)) = (hex_val(uri[i + 1]), hex_val(uri[i + 2])) { - out.push((h << 4) | l); - i += 3; - continue; - } - } - out.push(uri[i]); - i += 1; - } - out -} - -#[inline] -fn hex_val(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} diff --git a/net-guardia/src/detection/rule/app_layer/http2.rs b/net-guardia/src/detection/rule/app_layer/http2.rs deleted file mode 100644 index da8d3e7..0000000 --- a/net-guardia/src/detection/rule/app_layer/http2.rs +++ /dev/null @@ -1,672 +0,0 @@ -/* HTTP/2 frame parser with HPACK header decompression. - * - * Huffman decoder ported from Suricata rust/src/http2/huffman.rs - * Copyright (C) 2020 Open Information Security Foundation - * Licensed under GPLv2. - * - * Uses nom7 bits::streaming::take and branch::alt to replicate Suricata's - * http2_decode_huffman exactly. The public entry point is http2_decode_huffman - * (nom7 IResult interface); huffman_decode wraps it for use by hpack_str. - * - * Decoding order (ascending code length): - * 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30 - * - * EOS (0x3fffffff, 30 bits) is excluded from the table; remaining padding bits - * are validated as all-ones after the main decode loop, matching Suricata's - * many0 termination behaviour. - */ - -use nom7::bits::streaming::take as take_bits; -use nom7::branch::alt; -use nom7::combinator::{complete, map_opt}; -use nom7::error::{make_error, ErrorKind}; -use nom7::{Err as NomErr, IResult}; - -// HTTP/2 client connection preface (RFC 7540 §3.5) -const H2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; - -const FRAME_HEADERS: u8 = 0x1; -const FLAG_PADDED: u8 = 0x08; -const FLAG_PRIORITY: u8 = 0x20; - -pub struct Http2Info { - pub method: Vec, - pub path: Vec, - pub authority: Vec, - pub status: Vec, - pub user_agent: Vec, -} - -pub fn parse_http2(data: &[u8]) -> Option { - let mut pos = 0usize; - - if data.starts_with(H2_PREFACE) { - pos += H2_PREFACE.len(); - } - - while pos + 9 <= data.len() { - let frame_len = (data[pos] as usize) << 16 - | (data[pos + 1] as usize) << 8 - | data[pos + 2] as usize; - let frame_type = data[pos + 3]; - let flags = data[pos + 4]; - pos += 9; - - if pos + frame_len > data.len() { - break; - } - let payload = &data[pos..pos + frame_len]; - pos += frame_len; - - if frame_type == FRAME_HEADERS { - if let Some(info) = parse_headers_frame(payload, flags) { - return Some(info); - } - } - } - None -} - -fn parse_headers_frame(data: &[u8], flags: u8) -> Option { - let mut pos = 0usize; - - let pad_len = if flags & FLAG_PADDED != 0 { - if pos >= data.len() { - return None; - } - let p = data[pos] as usize; - pos += 1; - p - } else { - 0 - }; - - if flags & FLAG_PRIORITY != 0 { - pos += 5; - } - - let frag_end = data.len().saturating_sub(pad_len); - if pos > frag_end { - return None; - } - - decode_hpack(&data[pos..frag_end]) -} - -// ─── HPACK decoder ──────────────────────────────────────────────────────────── - -fn decode_hpack(data: &[u8]) -> Option { - let mut info = Http2Info { - method: Vec::new(), - path: Vec::new(), - authority: Vec::new(), - status: Vec::new(), - user_agent: Vec::new(), - }; - let mut pos = 0usize; - let mut dynamic: Vec<(Vec, Vec)> = Vec::new(); - let mut found = false; - - while pos < data.len() { - let b = data[pos]; - - if b & 0x80 != 0 { - // 6.1 Indexed Header Field - let (idx, adv) = hpack_int(data, pos, 7)?; - pos += adv; - if idx > 0 { - if let Some((name, value)) = table_lookup(&dynamic, idx) { - apply(&mut info, &name, &value); - found = true; - } - } - } else if b & 0xC0 == 0x40 { - // 6.2.1 Literal Header Field with Incremental Indexing - let (idx, adv) = hpack_int(data, pos, 6)?; - pos += adv; - let name = if idx == 0 { - let (n, nadv) = hpack_str(data, pos)?; - pos += nadv; - n - } else { - let (n, _) = table_lookup(&dynamic, idx)?; - n - }; - let (value, vadv) = hpack_str(data, pos)?; - pos += vadv; - apply(&mut info, &name, &value); - found = true; - dynamic.insert(0, (name, value)); - } else if b & 0xE0 == 0x20 { - // 6.3 Dynamic Table Size Update - let (_, adv) = hpack_int(data, pos, 5)?; - pos += adv; - } else { - // 6.2.2 / 6.2.3 Literal without / never Indexing - let (idx, adv) = hpack_int(data, pos, 4)?; - pos += adv; - let name = if idx == 0 { - let (n, nadv) = hpack_str(data, pos)?; - pos += nadv; - n - } else { - let (n, _) = table_lookup(&dynamic, idx)?; - n - }; - let (value, vadv) = hpack_str(data, pos)?; - pos += vadv; - apply(&mut info, &name, &value); - found = true; - } - } - - if found { Some(info) } else { None } -} - -fn apply(info: &mut Http2Info, name: &[u8], value: &[u8]) { - match name { - b":method" => { if info.method.is_empty() { info.method = value.to_vec(); } } - b":path" => { if info.path.is_empty() { info.path = value.to_vec(); } } - b":authority" => { if info.authority.is_empty() { info.authority = value.to_vec(); } } - b":status" => { if info.status.is_empty() { info.status = value.to_vec(); } } - b"user-agent" => { if info.user_agent.is_empty() { info.user_agent = value.to_vec(); } } - _ => {} - } -} - -// ─── HPACK static table (RFC 7541 Appendix A, 1-based) ─────────────────────── - -static STATIC_TABLE: &[(&[u8], &[u8])] = &[ - (b":authority", b""), // 1 - (b":method", b"GET"), // 2 - (b":method", b"POST"), // 3 - (b":path", b"/"), // 4 - (b":path", b"/index.html"), // 5 - (b":scheme", b"http"), // 6 - (b":scheme", b"https"), // 7 - (b":status", b"200"), // 8 - (b":status", b"204"), // 9 - (b":status", b"206"), // 10 - (b":status", b"304"), // 11 - (b":status", b"400"), // 12 - (b":status", b"404"), // 13 - (b":status", b"500"), // 14 - (b"accept-charset", b""), // 15 - (b"accept-encoding", b"gzip, deflate"), // 16 - (b"accept-language", b""), // 17 - (b"accept-ranges", b""), // 18 - (b"accept", b""), // 19 - (b"access-control-allow-origin", b""), // 20 - (b"age", b""), // 21 - (b"allow", b""), // 22 - (b"authorization", b""), // 23 - (b"cache-control", b""), // 24 - (b"content-disposition", b""), // 25 - (b"content-encoding", b""), // 26 - (b"content-language", b""), // 27 - (b"content-length", b""), // 28 - (b"content-location", b""), // 29 - (b"content-range", b""), // 30 - (b"content-type", b""), // 31 - (b"cookie", b""), // 32 - (b"date", b""), // 33 - (b"etag", b""), // 34 - (b"expect", b""), // 35 - (b"expires", b""), // 36 - (b"from", b""), // 37 - (b"host", b""), // 38 - (b"if-match", b""), // 39 - (b"if-modified-since", b""), // 40 - (b"if-none-match", b""), // 41 - (b"if-range", b""), // 42 - (b"if-unmodified-since", b""), // 43 - (b"last-modified", b""), // 44 - (b"link", b""), // 45 - (b"location", b""), // 46 - (b"max-forwards", b""), // 47 - (b"proxy-authenticate", b""), // 48 - (b"proxy-authorization", b""), // 49 - (b"range", b""), // 50 - (b"referer", b""), // 51 - (b"refresh", b""), // 52 - (b"retry-after", b""), // 53 - (b"server", b""), // 54 - (b"set-cookie", b""), // 55 - (b"strict-transport-security", b""), // 56 - (b"transfer-encoding", b""), // 57 - (b"user-agent", b""), // 58 - (b"vary", b""), // 59 - (b"via", b""), // 60 - (b"www-authenticate", b""), // 61 -]; - -fn table_lookup(dynamic: &[(Vec, Vec)], idx: usize) -> Option<(Vec, Vec)> { - if idx == 0 { - return None; - } - let static_len = STATIC_TABLE.len(); - if idx <= static_len { - let (n, v) = STATIC_TABLE[idx - 1]; - Some((n.to_vec(), v.to_vec())) - } else { - let dyn_idx = idx - static_len - 1; - dynamic.get(dyn_idx).cloned() - } -} - -// ─── HPACK integer encoding (RFC 7541 §5.1) ────────────────────────────────── - -fn hpack_int(data: &[u8], pos: usize, n: u8) -> Option<(usize, usize)> { - if pos >= data.len() { - return None; - } - let mask = (1u8 << n).wrapping_sub(1); - let prefix = (data[pos] & mask) as usize; - let max = mask as usize; - - if prefix < max { - return Some((prefix, 1)); - } - - let mut value = max; - let mut shift = 0u32; - let mut i = 1usize; - loop { - if pos + i >= data.len() || i > 4 { - return None; - } - let b = data[pos + i]; - value += ((b & 0x7F) as usize) << shift; - shift += 7; - i += 1; - if b & 0x80 == 0 { - break; - } - } - Some((value, i)) -} - -// ─── HPACK string literal (RFC 7541 §5.2) ──────────────────────────────────── - -fn hpack_str(data: &[u8], pos: usize) -> Option<(Vec, usize)> { - if pos >= data.len() { - return None; - } - let huffman = data[pos] & 0x80 != 0; - let (str_len, hdr) = hpack_int(data, pos, 7)?; - let body_start = pos + hdr; - if body_start + str_len > data.len() { - return None; - } - let raw = &data[body_start..body_start + str_len]; - let result = if huffman { huffman_decode(raw)? } else { raw.to_vec() }; - Some((result, hdr + str_len)) -} - -// ─── HPACK Huffman decoder ──────────────────────────────────────────────────── -// -// Ported from Suricata rust/src/http2/huffman.rs using nom7 bits. -// -// Each http2_decode_huffman_lenN function is: -// complete(map_opt(take_bits(N u32), http2_huffman_table_lenN)) -// matching Suricata exactly. -// -// http2_decode_huffman is the top-level nom7 combinator (Suricata's alt(...)). -// huffman_decode wraps it via nom7::multi::many0 for use by hpack_str. -// -// EOS (0x3fffffff, 30 bits) excluded; remaining padding verified as all-ones. - -/// Entry point: decode a full Huffman-coded byte string. -/// -/// Wraps http2_decode_huffman via many0, then validates EOS padding. -/// Matches Suricata's http2_decode_huffman_block behaviour. -fn huffman_decode(data: &[u8]) -> Option> { - use nom7::multi::many0; - let bit_input: (&[u8], usize) = (data, 0); - match many0(http2_decode_huffman)(bit_input) { - Ok(((remaining_bytes, remaining_bit_offset), out)) => { - // Validate EOS padding: remaining bits must all be 1s, at most 7. - let remaining_bits = remaining_bytes.len() * 8 - remaining_bit_offset; - if remaining_bits > 7 { - return None; - } - if !remaining_bytes.is_empty() { - // Check that the last partial byte's remaining bits are all 1s. - let shift = remaining_bit_offset; - if shift < 8 { - let mask = (1u8 << (8 - shift)) - 1; - if remaining_bytes[0] & mask != mask { - return None; - } - } - } - Some(out) - } - Err(_) => None, - } -} - -/// Nom7 Huffman symbol decoder — matches Suricata http2_decode_huffman. -/// -/// Tries each code length in ascending order using nested alt() because -/// nom7::branch::Alt is implemented for tuples up to size 20. -pub fn http2_decode_huffman( - input: (&[u8], usize), -) -> IResult<(&[u8], usize), u8> { - alt(( - http2_decode_huffman_len5, - http2_decode_huffman_len6, - http2_decode_huffman_len7, - http2_decode_huffman_len8, - http2_decode_huffman_len10, - http2_decode_huffman_len11, - http2_decode_huffman_len12, - http2_decode_huffman_len13, - http2_decode_huffman_len14, - http2_decode_huffman_len15, - http2_decode_huffman_len19, - http2_decode_huffman_len20, - http2_decode_huffman_len21, - http2_decode_huffman_len21, - http2_decode_huffman_len22, - http2_decode_huffman_len23, - http2_decode_huffman_len24, - http2_decode_huffman_len25, - http2_decode_huffman_len26, - http2_decode_huffman_len27, - alt(( - http2_decode_huffman_len28, - http2_decode_huffman_len30, - http2_decode_huffman_end, - )), - ))(input) -} - -// Hack to end many0 even if some bits remain (Suricata: http2_decode_huffman_end). -fn http2_decode_huffman_end( - input: (&[u8], usize), -) -> IResult<(&[u8], usize), u8> { - Err(NomErr::Error(make_error(input, ErrorKind::Eof))) -} - -// Per-length nom7 decoders — each wraps its table with complete(map_opt(take_bits(N), table)). - -fn http2_decode_huffman_len5(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(5u32), http2_huffman_table_len5))(input) -} -fn http2_decode_huffman_len6(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(6u32), http2_huffman_table_len6))(input) -} -fn http2_decode_huffman_len7(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(7u32), http2_huffman_table_len7))(input) -} -fn http2_decode_huffman_len8(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(8u32), http2_huffman_table_len8))(input) -} -fn http2_decode_huffman_len10(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(10u32), http2_huffman_table_len10))(input) -} -fn http2_decode_huffman_len11(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(11u32), http2_huffman_table_len11))(input) -} -fn http2_decode_huffman_len12(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(12u32), http2_huffman_table_len12))(input) -} -fn http2_decode_huffman_len13(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(13u32), http2_huffman_table_len13))(input) -} -fn http2_decode_huffman_len14(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(14u32), http2_huffman_table_len14))(input) -} -fn http2_decode_huffman_len15(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(15u32), http2_huffman_table_len15))(input) -} -fn http2_decode_huffman_len19(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(19u32), http2_huffman_table_len19))(input) -} -fn http2_decode_huffman_len20(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(20u32), http2_huffman_table_len20))(input) -} -fn http2_decode_huffman_len21(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(21u32), http2_huffman_table_len21))(input) -} -fn http2_decode_huffman_len22(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(22u32), http2_huffman_table_len22))(input) -} -fn http2_decode_huffman_len23(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(23u32), http2_huffman_table_len23))(input) -} -fn http2_decode_huffman_len24(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(24u32), http2_huffman_table_len24))(input) -} -fn http2_decode_huffman_len25(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(25u32), http2_huffman_table_len25))(input) -} -fn http2_decode_huffman_len26(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(26u32), http2_huffman_table_len26))(input) -} -fn http2_decode_huffman_len27(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(27u32), http2_huffman_table_len27))(input) -} -fn http2_decode_huffman_len28(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(28u32), http2_huffman_table_len28))(input) -} -fn http2_decode_huffman_len30(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> { - complete(map_opt(take_bits(30u32), http2_huffman_table_len30))(input) -} - -// Per-length decode tables — verbatim from Suricata http2_huffman_table_lenN. - -fn http2_huffman_table_len5(n: u32) -> Option { - match n { - 0 => Some(48), 1 => Some(49), 2 => Some(50), 3 => Some(97), - 4 => Some(99), 5 => Some(101), 6 => Some(105), 7 => Some(111), - 8 => Some(115), 9 => Some(116), - _ => None, - } -} - -fn http2_huffman_table_len6(n: u32) -> Option { - match n { - 0x14 => Some(32), 0x15 => Some(37), 0x16 => Some(45), 0x17 => Some(46), - 0x18 => Some(47), 0x19 => Some(51), 0x1a => Some(52), 0x1b => Some(53), - 0x1c => Some(54), 0x1d => Some(55), 0x1e => Some(56), 0x1f => Some(57), - 0x20 => Some(61), 0x21 => Some(65), 0x22 => Some(95), 0x23 => Some(98), - 0x24 => Some(100), 0x25 => Some(102), 0x26 => Some(103), 0x27 => Some(104), - 0x28 => Some(108), 0x29 => Some(109), 0x2a => Some(110), 0x2b => Some(112), - 0x2c => Some(114), 0x2d => Some(117), - _ => None, - } -} - -fn http2_huffman_table_len7(n: u32) -> Option { - match n { - 0x5c => Some(58), 0x5d => Some(66), 0x5e => Some(67), 0x5f => Some(68), - 0x60 => Some(69), 0x61 => Some(70), 0x62 => Some(71), 0x63 => Some(72), - 0x64 => Some(73), 0x65 => Some(74), 0x66 => Some(75), 0x67 => Some(76), - 0x68 => Some(77), 0x69 => Some(78), 0x6a => Some(79), 0x6b => Some(80), - 0x6c => Some(81), 0x6d => Some(82), 0x6e => Some(83), 0x6f => Some(84), - 0x70 => Some(85), 0x71 => Some(86), 0x72 => Some(87), 0x73 => Some(89), - 0x74 => Some(106), 0x75 => Some(107), 0x76 => Some(113), 0x77 => Some(118), - 0x78 => Some(119), 0x79 => Some(120), 0x7a => Some(121), 0x7b => Some(122), - _ => None, - } -} - -fn http2_huffman_table_len8(n: u32) -> Option { - match n { - 0xf8 => Some(38), 0xf9 => Some(42), 0xfa => Some(44), - 0xfb => Some(59), 0xfc => Some(88), 0xfd => Some(90), - _ => None, - } -} - -fn http2_huffman_table_len10(n: u32) -> Option { - match n { - 0x3f8 => Some(33), 0x3f9 => Some(34), 0x3fa => Some(40), - 0x3fb => Some(41), 0x3fc => Some(63), - _ => None, - } -} - -fn http2_huffman_table_len11(n: u32) -> Option { - match n { - 0x7fa => Some(39), 0x7fb => Some(43), 0x7fc => Some(124), - _ => None, - } -} - -fn http2_huffman_table_len12(n: u32) -> Option { - match n { - 0xffa => Some(35), 0xffb => Some(62), - _ => None, - } -} - -fn http2_huffman_table_len13(n: u32) -> Option { - match n { - 0x1ff8 => Some(0), 0x1ff9 => Some(36), 0x1ffa => Some(64), - 0x1ffb => Some(91), 0x1ffc => Some(93), 0x1ffd => Some(126), - _ => None, - } -} - -fn http2_huffman_table_len14(n: u32) -> Option { - match n { - 0x3ffc => Some(94), 0x3ffd => Some(125), - _ => None, - } -} - -fn http2_huffman_table_len15(n: u32) -> Option { - match n { - 0x7ffc => Some(60), 0x7ffd => Some(96), 0x7ffe => Some(123), - _ => None, - } -} - -fn http2_huffman_table_len19(n: u32) -> Option { - match n { - 0x7fff0 => Some(92), 0x7fff1 => Some(195), 0x7fff2 => Some(208), - _ => None, - } -} - -fn http2_huffman_table_len20(n: u32) -> Option { - match n { - 0xfffe6 => Some(128), 0xfffe7 => Some(130), 0xfffe8 => Some(131), - 0xfffe9 => Some(162), 0xfffea => Some(184), 0xfffeb => Some(194), - 0xfffec => Some(224), 0xfffed => Some(226), - _ => None, - } -} - -fn http2_huffman_table_len21(n: u32) -> Option { - match n { - 0x1fffdc => Some(153), 0x1fffdd => Some(161), 0x1fffde => Some(167), - 0x1fffdf => Some(172), 0x1fffe0 => Some(176), 0x1fffe1 => Some(177), - 0x1fffe2 => Some(179), 0x1fffe3 => Some(209), 0x1fffe4 => Some(216), - 0x1fffe5 => Some(217), 0x1fffe6 => Some(227), 0x1fffe7 => Some(229), - 0x1fffe8 => Some(230), - _ => None, - } -} - -fn http2_huffman_table_len22(n: u32) -> Option { - match n { - 0x3fffd2 => Some(129), 0x3fffd3 => Some(132), 0x3fffd4 => Some(133), - 0x3fffd5 => Some(134), 0x3fffd6 => Some(136), 0x3fffd7 => Some(146), - 0x3fffd8 => Some(154), 0x3fffd9 => Some(156), 0x3fffda => Some(160), - 0x3fffdb => Some(163), 0x3fffdc => Some(164), 0x3fffdd => Some(169), - 0x3fffde => Some(170), 0x3fffdf => Some(173), 0x3fffe0 => Some(178), - 0x3fffe1 => Some(181), 0x3fffe2 => Some(185), 0x3fffe3 => Some(186), - 0x3fffe4 => Some(187), 0x3fffe5 => Some(189), 0x3fffe6 => Some(190), - 0x3fffe7 => Some(196), 0x3fffe8 => Some(198), 0x3fffe9 => Some(228), - 0x3fffea => Some(232), 0x3fffeb => Some(233), - _ => None, - } -} - -fn http2_huffman_table_len23(n: u32) -> Option { - match n { - 0x7fffd8 => Some(1), 0x7fffd9 => Some(135), 0x7fffda => Some(137), - 0x7fffdb => Some(138), 0x7fffdc => Some(139), 0x7fffdd => Some(140), - 0x7fffde => Some(141), 0x7fffdf => Some(143), 0x7fffe0 => Some(147), - 0x7fffe1 => Some(149), 0x7fffe2 => Some(150), 0x7fffe3 => Some(151), - 0x7fffe4 => Some(152), 0x7fffe5 => Some(155), 0x7fffe6 => Some(157), - 0x7fffe7 => Some(158), 0x7fffe8 => Some(165), 0x7fffe9 => Some(166), - 0x7fffea => Some(168), 0x7fffeb => Some(174), 0x7fffec => Some(175), - 0x7fffed => Some(180), 0x7fffee => Some(182), 0x7fffef => Some(183), - 0x7ffff0 => Some(188), 0x7ffff1 => Some(191), 0x7ffff2 => Some(197), - 0x7ffff3 => Some(231), 0x7ffff4 => Some(239), - _ => None, - } -} - -fn http2_huffman_table_len24(n: u32) -> Option { - match n { - 0xffffea => Some(9), 0xffffeb => Some(142), 0xffffec => Some(144), - 0xffffed => Some(145), 0xffffee => Some(148), 0xffffef => Some(159), - 0xfffff0 => Some(171), 0xfffff1 => Some(206), 0xfffff2 => Some(215), - 0xfffff3 => Some(225), 0xfffff4 => Some(236), 0xfffff5 => Some(237), - _ => None, - } -} - -fn http2_huffman_table_len25(n: u32) -> Option { - match n { - 0x1ffffec => Some(199), 0x1ffffed => Some(207), - 0x1ffffee => Some(234), 0x1ffffef => Some(235), - _ => None, - } -} - -fn http2_huffman_table_len26(n: u32) -> Option { - match n { - 0x3ffffe0 => Some(192), 0x3ffffe1 => Some(193), 0x3ffffe2 => Some(200), - 0x3ffffe3 => Some(201), 0x3ffffe4 => Some(202), 0x3ffffe5 => Some(205), - 0x3ffffe6 => Some(210), 0x3ffffe7 => Some(213), 0x3ffffe8 => Some(218), - 0x3ffffe9 => Some(219), 0x3ffffea => Some(238), 0x3ffffeb => Some(240), - 0x3ffffec => Some(242), 0x3ffffed => Some(243), 0x3ffffee => Some(255), - _ => None, - } -} - -fn http2_huffman_table_len27(n: u32) -> Option { - match n { - 0x7ffffde => Some(203), 0x7ffffdf => Some(204), 0x7ffffe0 => Some(211), - 0x7ffffe1 => Some(212), 0x7ffffe2 => Some(214), 0x7ffffe3 => Some(221), - 0x7ffffe4 => Some(222), 0x7ffffe5 => Some(223), 0x7ffffe6 => Some(241), - 0x7ffffe7 => Some(244), 0x7ffffe8 => Some(245), 0x7ffffe9 => Some(246), - 0x7ffffea => Some(247), 0x7ffffeb => Some(248), 0x7ffffec => Some(250), - 0x7ffffed => Some(251), 0x7ffffee => Some(252), 0x7ffffef => Some(253), - 0x7fffff0 => Some(254), - _ => None, - } -} - -fn http2_huffman_table_len28(n: u32) -> Option { - match n { - 0xfffffe2 => Some(2), 0xfffffe3 => Some(3), 0xfffffe4 => Some(4), - 0xfffffe5 => Some(5), 0xfffffe6 => Some(6), 0xfffffe7 => Some(7), - 0xfffffe8 => Some(8), 0xfffffe9 => Some(11), 0xfffffea => Some(12), - 0xfffffeb => Some(14), 0xfffffec => Some(15), 0xfffffed => Some(16), - 0xfffffee => Some(17), 0xfffffef => Some(18), 0xffffff0 => Some(19), - 0xffffff1 => Some(20), 0xffffff2 => Some(21), 0xffffff3 => Some(23), - 0xffffff4 => Some(24), 0xffffff5 => Some(25), 0xffffff6 => Some(26), - 0xffffff7 => Some(27), 0xffffff8 => Some(28), 0xffffff9 => Some(29), - 0xffffffa => Some(30), 0xffffffb => Some(31), 0xffffffc => Some(127), - 0xffffffd => Some(220), 0xffffffe => Some(249), - _ => None, - } -} - -// EOS (0x3fffffff) excluded; only non-EOS 30-bit codes listed. -fn http2_huffman_table_len30(n: u32) -> Option { - match n { - 0x3ffffffc => Some(10), - 0x3ffffffd => Some(13), - 0x3ffffffe => Some(22), - _ => None, - } -} diff --git a/net-guardia/src/detection/rule/app_layer/mod.rs b/net-guardia/src/detection/rule/app_layer/mod.rs deleted file mode 100644 index 28807f9..0000000 --- a/net-guardia/src/detection/rule/app_layer/mod.rs +++ /dev/null @@ -1,228 +0,0 @@ -pub mod dns; -pub mod ftp; -pub mod http; -pub mod http2; -pub mod mqtt; -pub mod quic; -pub mod smtp; -pub mod ssh; -pub mod tls; - -use http::{HttpRequest, HttpResponse}; - -pub use crate::model::rule_detection::{AppProto, ParsedPacket, buf}; - -/// Well-known TLS server ports. When either sport or dport matches, the flow -/// is treated as TLS unconditionally — even for mid-stream captures where the -/// TCP payload no longer starts at a TLS record boundary. -fn is_tls_port(port: u16) -> bool { - matches!(port, - 443 // HTTPS - | 8443 // HTTPS alt - | 465 // SMTPS - | 993 // IMAPS - | 995 // POP3S - | 636 // LDAPS - | 853 // DNS-over-TLS - | 4433 // HTTPS alt - | 8883 // MQTT over TLS - ) -} - -fn is_ssh_port(port: u16) -> bool { - matches!(port, 22 | 2222) -} - -fn is_smtp_port(port: u16) -> bool { - matches!(port, 25 | 587 | 2525) -} - -fn is_ftp_port(port: u16) -> bool { - matches!(port, 21 | 2121) -} - -fn is_mqtt_port(port: u16) -> bool { - matches!(port, 1883) -} - -fn is_http2_port(port: u16) -> bool { - matches!(port, 80 | 8080 | 8000) -} - -fn is_quic_port(port: u16) -> bool { - matches!(port, 443 | 8443 | 4433) -} - -/// Parse `data` into a `ParsedPacket`. -/// -/// Both `sport` and `dport` are needed so that we can identify TLS flows by -/// server port even when the callback only has the ephemeral client port stored -/// in `current_dport` (e.g. the flow key was created from an inbound Google -/// packet: sip=142.251.x.x:443 → dip=local:52873, so sport=443). -/// -/// Detection order: -/// 1. TLS — content-based header check OR port-based hint (catches mid-stream) -/// 2. HTTP/2 — connection preface detection (cleartext h2c) -/// 3. HTTP/1.x request/response -/// 4. DNS (port 53 / 5353) -/// 5. SSH — banner or Kexinit -/// 6. SMTP -/// 7. FTP -/// 8. MQTT -/// 9. Port-based TLS fallback -pub fn detect_and_parse(data: &[u8], sport: u16, dport: u16) -> ParsedPacket { - let mut pkt = ParsedPacket::new(data.to_vec()); - - if data.is_empty() { - return pkt; - } - - // 1. TLS — content-based detection first. - if let Some(tls) = tls::detect_tls(data) { - pkt.raw.clear(); - pkt.is_tls = true; - pkt.proto = AppProto::Tls; - if tls.is_app_data { - pkt.tls_app_data = true; - return pkt; - } - pkt.set(buf::TLS_SNI, tls.sni); - pkt.set(buf::TLS_VERSION, tls.version); - pkt.set(buf::TLS_JA3, tls.ja3); - pkt.set(buf::TLS_JA3S, tls.ja3s); - pkt.set(buf::TLS_JA4, tls.ja4); - pkt.set(buf::TLS_ALPN, tls.alpn); - pkt.set(buf::TLS_CERT_SUBJECT, tls.cert_subject); - pkt.set(buf::TLS_CERT_ISSUER, tls.cert_issuer); - pkt.set(buf::TLS_CERT_FINGERPRINT, tls.cert_fingerprint); - pkt.set(buf::TLS_CERT_NOT_BEFORE, tls.cert_not_before); - pkt.set(buf::TLS_CERT_NOT_AFTER, tls.cert_not_after); - return pkt; - } - - // 2. HTTP/2 cleartext (h2c) — detected by connection preface. - if data.starts_with(b"PRI * HTTP/2.0\r\n") || (is_http2_port(sport) || is_http2_port(dport)) { - if let Some(h2) = http2::parse_http2(data) { - pkt.proto = AppProto::Http2; - pkt.set(buf::HTTP2_METHOD, h2.method); - pkt.set(buf::HTTP2_PATH, h2.path); - pkt.set(buf::HTTP2_AUTHORITY, h2.authority); - pkt.set(buf::HTTP2_STATUS, h2.status); - pkt.set(buf::HTTP2_UA, h2.user_agent); - return pkt; - } - } - - // 3. HTTP/1.x request / response — must come BEFORE port-based TLS fallback - // so that plaintext HTTP on port 443 is identified correctly. - if let Some(req) = http::parse_http_request(data) { - pkt.proto = AppProto::Http; - apply_request(&mut pkt, req); - return pkt; - } - if let Some(resp) = http::parse_http_response(data) { - pkt.proto = AppProto::Http; - apply_response(&mut pkt, resp); - return pkt; - } - - // 4. DNS - if dport == 53 || dport == 5353 || sport == 53 || sport == 5353 { - pkt.proto = AppProto::Dns; - if let Some(dns) = dns::parse_dns(data) { - pkt.set(buf::DNS_QUERY, dns.query_name); - pkt.set(buf::DNS_RCODE, dns.rcode); - pkt.set(buf::DNS_QTYPE, dns.qtype); - pkt.set(buf::DNS_ANSWER, dns.answer); - } - return pkt; - } - - // 5. SSH — banner (starts with "SSH-") or binary Kexinit. - if data.starts_with(b"SSH-") || is_ssh_port(sport) || is_ssh_port(dport) { - if let Some(ssh) = ssh::parse_ssh(data) { - pkt.proto = AppProto::Ssh; - pkt.set(buf::SSH_PROTO, ssh.proto); - pkt.set(buf::SSH_SOFTWARE, ssh.software); - pkt.set(buf::SSH_HASSH, ssh.hassh); - return pkt; - } - } - - // 6. SMTP - if is_smtp_port(sport) || is_smtp_port(dport) { - if let Some(smtp) = smtp::parse_smtp(data) { - pkt.proto = AppProto::Smtp; - pkt.set(buf::SMTP_HELO, smtp.helo); - pkt.set(buf::SMTP_FROM, smtp.from); - pkt.set(buf::SMTP_RCPT, smtp.rcpt); - pkt.set(buf::SMTP_REPLY, smtp.reply); - return pkt; - } - } - - // 7. FTP - if is_ftp_port(sport) || is_ftp_port(dport) { - if let Some(ftp) = ftp::parse_ftp(data) { - pkt.proto = AppProto::Ftp; - pkt.set(buf::FTP_COMMAND, ftp.command); - pkt.set(buf::FTP_FILENAME, ftp.filename); - pkt.set(buf::FTP_REPLY, ftp.reply); - return pkt; - } - } - - // 8. MQTT - if is_mqtt_port(sport) || is_mqtt_port(dport) { - if let Some(mqtt) = mqtt::parse_mqtt(data) { - pkt.proto = AppProto::Mqtt; - pkt.set(buf::MQTT_CLIENTID, mqtt.client_id); - pkt.set(buf::MQTT_TOPIC, mqtt.topic); - pkt.set(buf::MQTT_USERNAME, mqtt.username); - return pkt; - } - } - - // 9. QUIC (UDP port 443/8443 — QUIC is always UDP, but we see payload here - // from the eBPF capture which forwards UDP payloads the same way). - if is_quic_port(sport) || is_quic_port(dport) { - if let Some(q) = quic::detect_quic(data) { - pkt.proto = AppProto::Quic; - pkt.set(buf::QUIC_SNI, q.sni); - pkt.set(buf::QUIC_VERSION, q.version); - pkt.set(buf::QUIC_UA, q.ua); - return pkt; - } - } - - // 10. Port-based TLS fallback — reached only when content detection found - // nothing. Handles mid-stream captures of TLS flows where the first - // byte is not a TLS record header (connection established before we - // started capturing). Suricata equivalent: AppLayerProtoDetectGetProto - // with port hints as last resort. - if is_tls_port(sport) || is_tls_port(dport) { - pkt.raw.clear(); - pkt.is_tls = true; - pkt.proto = AppProto::Tls; - return pkt; - } - - pkt -} - -fn apply_request(pkt: &mut ParsedPacket, req: HttpRequest) { - pkt.set(buf::HTTP_URI, req.uri); - pkt.set(buf::HTTP_RAW_URI, req.raw_uri); - pkt.set(buf::HTTP_METHOD, req.method); - pkt.set(buf::HTTP_HOST, req.host); - pkt.set(buf::HTTP_USER_AGENT, req.user_agent); - pkt.set(buf::HTTP_COOKIE, req.cookie); - pkt.set(buf::HTTP_HEADER, req.headers); - pkt.set(buf::HTTP_CLIENT_BODY, req.client_body); -} - -fn apply_response(pkt: &mut ParsedPacket, resp: HttpResponse) { - pkt.set(buf::HTTP_STAT_CODE, resp.stat_code); - pkt.set(buf::HTTP_HEADER, resp.headers); - pkt.set(buf::HTTP_SERVER_BODY, resp.server_body); -} diff --git a/net-guardia/src/detection/rule/app_layer/mqtt.rs b/net-guardia/src/detection/rule/app_layer/mqtt.rs deleted file mode 100644 index ca8c82e..0000000 --- a/net-guardia/src/detection/rule/app_layer/mqtt.rs +++ /dev/null @@ -1,208 +0,0 @@ -/* Ported from Suricata rust/src/mqtt/parser.rs - * Copyright (C) 2020-2022 Open Information Security Foundation - * Licensed under GPLv2. - * - * Rewritten to use nom8 (nom = "8.0" aliased as nom8). - * - * Fixed header byte layout (Suricata parse_fixed_header_flags via bits/take_bits): - * bits 7-4: message_type (4 bits) - * bit 3: dup_flag (1 bit) - * bits 2-1: qos_level (2 bits) - * bit 0: retain (1 bit) - * - * Variable-length integer (Suricata parse_mqtt_variable_integer): - * take_while_m_n(0, 3, is_continuation_bit_set) + verify last byte has no MSB - * - * CONNECT payload (Suricata parse_connect): - * protocol_string + protocol_version + rawflags + keepalive + - * [MQTT5: properties] + client_id + [will: [MQTT5: props] + topic + message] + - * [username] + [password] - * - * PUBLISH variable header (Suricata parse_publish): - * topic + [has_id: message_id] + [MQTT5: properties] - * has_id = qos_level > 0 - */ - -use nom8::bytes::streaming::take_while_m_n; -use nom8::combinator::verify; -use nom8::error::{Error as NomError}; -use nom8::multi::length_data; -use nom8::number::streaming::{be_u16, be_u8}; -use nom8::{IResult, Parser}; - -const MQTT_CONNECT: u8 = 1; -const MQTT_PUBLISH: u8 = 3; - -pub struct MqttInfo { - pub client_id: Vec, - pub topic: Vec, - pub username: Vec, -} - -pub fn parse_mqtt(data: &[u8]) -> Option { - if data.len() < 2 { - return None; - } - - // Fixed header first byte: type(4) | dup(1) | qos(2) | retain(1) - let first_byte = data[0]; - let packet_type = first_byte >> 4; - let qos_level = (first_byte >> 1) & 0x03; - - // Parse remaining length using nom8 variable integer parser - let (payload, rem_len) = parse_mqtt_variable_integer(&data[1..]).ok()?; - let rem_len = rem_len as usize; - - if rem_len > payload.len() { - return None; - } - let payload = &payload[..rem_len]; - - match packet_type { - MQTT_CONNECT => parse_connect(payload), - MQTT_PUBLISH => parse_publish(payload, qos_level), - _ => None, - } -} - -// ─── Variable-length integer (Suricata parse_mqtt_variable_integer) ─────────── - -/// Decode MQTT variable-length integer using nom8. -/// -/// Suricata: take_while_m_n(0, 3, is_continuation_bit_set) collects continuation -/// bytes (MSB set), then verify(be_u8, |v| !is_continuation_bit_set(v)) reads -/// the final byte. convert_varint computes the value. -fn parse_mqtt_variable_integer(input: &[u8]) -> IResult<&[u8], u32, NomError<&[u8]>> { - #[inline] - fn is_continuation_bit_set(b: u8) -> bool { - (b & 0x80) != 0 - } - - let (input, continued) = take_while_m_n(0, 3, is_continuation_bit_set)(input)?; - let (input, last) = - verify(be_u8::<_, NomError<_>>, |&val| !is_continuation_bit_set(val)).parse(input)?; - - // convert_varint: sum up continued bytes + last byte with 128-multiplier - let mut multiplier = 1u32; - let mut value = 0u32; - for &b in continued { - value += (b as u32 & 0x7F) * multiplier; - multiplier *= 128; - } - value += (last as u32 & 0x7F) * multiplier; - - Ok((input, value)) -} - -// ─── Wire helpers ───────────────────────────────────────────────────────────── - -/// Read a length-prefixed UTF-8 string (Suricata parse_mqtt_string: length_data(be_u16)). -#[inline] -fn parse_mqtt_string(input: &[u8]) -> IResult<&[u8], Vec, NomError<&[u8]>> { - let (input, content) = length_data(be_u16::<_, NomError<_>>).parse(input)?; - Ok((input, content.to_vec())) -} - -/// Read a length-prefixed binary field (Suricata parse_mqtt_binary_data: length_data(be_u16)). -#[inline] -fn parse_mqtt_binary_data(input: &[u8]) -> IResult<&[u8], Vec, NomError<&[u8]>> { - let (input, content) = length_data(be_u16::<_, NomError<_>>).parse(input)?; - Ok((input, content.to_vec())) -} - -/// Parse MQTT 5.0 properties block: variable-length size + that many bytes. -/// -/// Suricata: parse_properties with precond; if precond is false, skip. -/// Returns the input unchanged when skip=true. -fn parse_properties_skip<'a>( - input: &'a [u8], - precond: bool, -) -> IResult<&'a [u8], (), NomError<&'a [u8]>> { - if !precond { - return Ok((input, ())); - } - let (input, prop_len) = parse_mqtt_variable_integer(input)?; - // Skip prop_len bytes - if prop_len as usize > input.len() { - return Err(nom8::Err::Error(NomError::new(input, nom8::error::ErrorKind::Eof))); - } - Ok((&input[prop_len as usize..], ())) -} - -// ─── CONNECT (Suricata parse_connect) ──────────────────────────────────────── - -fn parse_connect(input: &[u8]) -> Option { - // Protocol name: be_u16-length-prefixed string ("MQTT" or "MQIsdp") - let (input, _protocol_string) = parse_mqtt_string(input).ok()?; - - // Protocol version byte - let (input, protocol_version) = be_u8::<_, NomError<_>>(input).ok()?; - - // Connect flags - let (input, rawflags) = be_u8::<_, NomError<_>>(input).ok()?; - let username_flag = rawflags & 0x80 != 0; - let password_flag = rawflags & 0x40 != 0; - let will_flag = rawflags & 0x04 != 0; - - // Keep Alive: be_u16 (unused but must be consumed) - let (input, _keepalive) = be_u16::<_, NomError<_>>(input).ok()?; - - // MQTT 5.0: connect properties (Suricata: parse_properties with precond=version==5) - let (input, _) = parse_properties_skip(input, protocol_version == 5).ok()?; - - // Client ID (Suricata: parse_mqtt_string) - let (input, client_id) = parse_mqtt_string(input).ok()?; - - // Will fields (Suricata: cond(will_flag, ...)) - let input = if will_flag { - // MQTT 5.0: will properties - let (input, _) = parse_properties_skip(input, protocol_version == 5).ok()?; - let (input, _will_topic) = parse_mqtt_string(input).ok()?; - let (input, _will_message) = parse_mqtt_binary_data(input).ok()?; - input - } else { - input - }; - - // Username (Suricata: cond(username_flag, parse_mqtt_string)) - let (input, username) = if username_flag { - let (i, u) = parse_mqtt_string(input).ok()?; - (i, u) - } else { - (input, Vec::new()) - }; - - // Password (Suricata: cond(password_flag, parse_mqtt_binary_data)) - consumed but not stored - let _input = if password_flag { - match parse_mqtt_binary_data(input) { - Ok((i, _)) => i, - Err(_) => input, - } - } else { - input - }; - - Some(MqttInfo { client_id, topic: Vec::new(), username }) -} - -// ─── PUBLISH (Suricata parse_publish) ──────────────────────────────────────── - -/// Parse PUBLISH variable header. -/// -/// Ports Suricata's parse_publish: parse_mqtt_string(topic) + cond(has_id, be_u16). -/// has_id (Packet Identifier present) = qos_level > 0. -fn parse_publish(input: &[u8], qos_level: u8) -> Option { - let (input, topic) = parse_mqtt_string(input).ok()?; - - // Packet Identifier present when QoS > 0 (Suricata: cond(has_id, be_u16)) - let _input = if qos_level > 0 { - match be_u16::<_, NomError<_>>(input) { - Ok((i, _)) => i, - Err(_) => input, - } - } else { - input - }; - - Some(MqttInfo { client_id: Vec::new(), topic, username: Vec::new() }) -} diff --git a/net-guardia/src/detection/rule/app_layer/quic.rs b/net-guardia/src/detection/rule/app_layer/quic.rs deleted file mode 100644 index 2665e94..0000000 --- a/net-guardia/src/detection/rule/app_layer/quic.rs +++ /dev/null @@ -1,773 +0,0 @@ -/* Ported from Suricata rust/src/quic/parser.rs and rust/src/quic/frames.rs - * Copyright (C) 2021-2022 Open Information Security Foundation - * Licensed under GPLv2. - * - * QUIC long-header detection and Initial-packet parsing per RFC 9000. - * SNI is extracted from the TLS ClientHello embedded in CRYPTO frames - * of Initial packets. Initial packets use well-known keys (RFC 9001 §5.2) - * derived from the Destination Connection ID, so no session state is needed. - * - * gQUIC (Google QUIC) support: versions Q043-Q046. Q043-Q045 Initial packets - * are plaintext after skipping the 4-byte packet number and 12-byte FNV hash. - * The SNI is carried in a STREAM frame (type 0x80+) as a gQUIC CHLO tag-value - * structure (StreamTag::Sni = 0x534e4900), not in a TLS ClientHello. - * Q046 uses IETF long-header format and is treated like RFC QUIC for Initial. - * - * Varint decoding (RFC 9000 §16): ported from Suricata quic_var_uint using nom8. - * CRYPTO frame parsing: ported from Suricata parse_crypto_frame using nom8. - * gQUIC frame parsing: ported from Suricata frames.rs decode_frame / parse_stream_frame. - * - * Key derivation (QUIC v1, RFC 9001 §A.1): - * initial_secret = HKDF-Extract(initial_salt, dcid) - * client_in = HKDF-Expand-Label(initial_secret, "client in", "", 32) - * quic_key = HKDF-Expand-Label(client_in, "quic key", "", 16) - * quic_iv = HKDF-Expand-Label(client_in, "quic iv", "", 12) - * quic_hp = HKDF-Expand-Label(client_in, "quic hp", "", 16) - * - * Header-protection mask (RFC 9001 §5.4.1): - * sample = ciphertext[pn_offset + 4 .. pn_offset + 20] - * mask = AES-128-ECB(quic_hp, sample) - * - * Payload decryption: AEAD-AES-128-GCM with nonce = quic_iv XOR pn_bytes. - * - * CYU fingerprint (Suricata cyu.rs): - * hash of "version:ciphers:extensions:..." analogous to JA3 for QUIC. - * Not yet implemented; placeholder field kept for future use. - */ - -use aes_gcm::{Aes128Gcm, Key, Nonce, aead::{Aead, KeyInit, Payload}}; -use nom8::number::complete::{be_u8, be_u24, be_u32}; -use nom8::IResult; -use sha2::{Sha256, Digest}; - -// ─── gQUIC version constants (Suricata quic.rs) ────────────────────────────── - -/// gQUIC version "Q043" = b'Q','0','4','3' packed as big-endian u32. -const GQUIC_VERSION_Q043: u32 = 0x5130_3433; -/// gQUIC version "Q044". -const GQUIC_VERSION_Q044: u32 = 0x5130_3434; -/// gQUIC version "Q045". -const GQUIC_VERSION_Q045: u32 = 0x5130_3435; -/// gQUIC version "Q046" — uses IETF long-header format. -const GQUIC_VERSION_Q046: u32 = 0x5130_3436; - -/// QUIC v1 initial salt (RFC 9001 §A.1). -const INITIAL_SALT_V1: &[u8] = &[ - 0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, - 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, - 0xcc, 0xbb, 0x7f, 0x0a, -]; - -/// QUIC v2 initial salt (RFC 9369 §A.1). -const INITIAL_SALT_V2: &[u8] = &[ - 0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb, - 0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb, - 0xf9, 0xbd, 0x2e, 0xd9, -]; - -pub struct QuicInfo { - /// SNI extracted from embedded TLS ClientHello or gQUIC CHLO (empty if unavailable). - pub sni: Vec, - /// Human-readable QUIC version: "1", "2", "Q043", etc. - pub version: Vec, - /// User-agent from gQUIC CHLO UAID tag (StreamTag::Uaid = 0x55414944). - /// Only populated for gQUIC Q043-Q045 plaintext Initial packets. - /// RFC QUIC v1/v2 UA is in encrypted 1-RTT HEADERS and is not available passively. - pub ua: Vec, -} - -/// Attempt to parse `data` as a QUIC long-header packet. -/// -/// Returns `None` when the data does not look like QUIC. Returns -/// `Some(QuicInfo)` with at least `version` populated when QUIC is detected. -/// -/// Handles both RFC 9000 QUIC (v1/v2/drafts) and gQUIC (Q043-Q046). -/// Ported from Suricata rust/src/quic/parser.rs. -pub fn detect_quic(data: &[u8]) -> Option { - if data.len() < 6 { - return None; - } - let first = data[0]; - - // All QUIC long-header packets have bit7 = 1 (public long-header marker). - if first & 0x80 == 0 { - return None; - } - - let version = u32::from_be_bytes([data[1], data[2], data[3], data[4]]); - if !is_valid_quic_version(version) { - return None; - } - - // gQUIC Q043-Q045 do not require the IETF fixed bit (bit6 = 0x40). - // RFC QUIC and Q046 require bit7 + bit6 = 0xC0 (Suricata quic.rs:is_long_header). - let is_gquic_pre46 = is_gquic_version(version) && version != GQUIC_VERSION_Q046; - if !is_gquic_pre46 && (first & 0x40 == 0) { - return None; - } - - let version_str: Vec = match version { - 0x0000_0001 => b"1".to_vec(), - 0x6b33_43cf => b"2".to_vec(), - 0x0000_0000 => b"negotiation".to_vec(), - GQUIC_VERSION_Q043 => b"Q043".to_vec(), - GQUIC_VERSION_Q044 => b"Q044".to_vec(), - GQUIC_VERSION_Q045 => b"Q045".to_vec(), - GQUIC_VERSION_Q046 => b"Q046".to_vec(), - v => format!("{:08x}", v).into_bytes(), - }; - - let mut sni = Vec::new(); - let mut ua = Vec::new(); - - if is_gquic_pre46 { - // gQUIC Q043-Q045: Initial packet type is (first & 0x7f) == 0x7f. - // Suricata quic.rs: match first & 0x7f { 0x7f => QuicType::Initial, ... } - if (first & 0x7f) == 0x7f { - let (s, u) = try_extract_gquic_info(data); - sni = s; - ua = u; - } - } else { - // RFC QUIC v1/v2 and Q046: DCID length at byte 5, max 20 per RFC 9000. - let pos = 5usize; - if pos >= data.len() { - return Some(QuicInfo { sni, version: version_str, ua }); - } - let dcid_len = data[pos] as usize; - if dcid_len > 20 { - return None; - } - - // Packet type: bits 4-5 of first byte. - // RFC 9000 (v1) and Q046: Initial = 0b00. - // RFC 9369 (v2): Initial = 0b01 (Suricata parser.rs QuicVersion::V2 branch). - let pkt_bits = (first >> 4) & 0x03; - let is_initial = match version { - 0x0000_0001 => pkt_bits == 0x00, - 0x6b33_43cf => pkt_bits == 0x01, - _ => pkt_bits == 0x00, - }; - if is_initial { - if let Some(parsed_sni) = try_extract_initial_sni(data, version, dcid_len) { - sni = parsed_sni; - } - } - } - - Some(QuicInfo { sni, version: version_str, ua }) -} - -// ─── gQUIC SNI extraction ───────────────────────────────────────────────────── - -/// Extract SNI and UA from a gQUIC Q043/Q044/Q045 Initial packet. -/// -/// gQUIC Initial plaintext layout (after first_byte[0] + version[1-4]): -/// CID_lengths(1) | DCID(dcid_len) | SCID(scid_len) | pkt_num(4) | auth_hash(12) | frames -/// -/// CID_lengths nibble encoding (Suricata quic.rs): -/// dcid_nibble = (byte >> 4) & 0x0F → actual_len = if nonzero { nibble + 3 } else { 0 } -/// scid_nibble = byte & 0x0F → actual_len = if nonzero { nibble + 3 } else { 0 } -/// -/// Returns (sni, ua) — either may be empty if the corresponding CHLO tag is absent. -/// Mirrors Suricata quic.rs handle_frames() which reads both StreamTag::Sni and StreamTag::Uaid. -fn try_extract_gquic_info(data: &[u8]) -> (Vec, Vec) { - let none = (Vec::new(), Vec::new()); - let rest = match data.get(5..) { Some(r) if !r.is_empty() => r, _ => return none }; - - let cid_lengths = rest[0]; - let rest = &rest[1..]; - - let mut dcid_len = ((cid_lengths >> 4) & 0x0F) as usize; - let mut scid_len = (cid_lengths & 0x0F) as usize; - if dcid_len != 0 { dcid_len += 3; } - if scid_len != 0 { scid_len += 3; } - - let rest = match rest.get(dcid_len + scid_len..) { Some(r) => r, None => return none }; - // Skip pkt_num (4 bytes) + msg_auth_hash (12 bytes). - let rest = match rest.get(16..) { Some(r) => r, None => return none }; - - parse_gquic_frames_for_info(rest) -} - -/// Walk gQUIC frames and return (sni, ua) from the first CHLO found. -/// -/// Uses be_u8 for frame type (Suricata frames.rs:decode_frame), not quic_var_uint. -/// This is critical because gQUIC STREAM type has bit7 set (0x80+), which a varint -/// decoder would misinterpret as a 2-byte value. -/// Mirrors Suricata handle_frames() which extracts both StreamTag::Sni and StreamTag::Uaid. -fn parse_gquic_frames_for_info(payload: &[u8]) -> (Vec, Vec) { - let mut input = payload; - while !input.is_empty() { - let frame_ty = input[0]; - let rest = &input[1..]; - - if frame_ty & 0x80 == 0x80 { - // STREAM frame — carries gQUIC CHLO tag-value structure. - // Suricata: parse_stream_frame(rest, frame_ty) - return parse_gquic_stream_frame_for_info(rest, frame_ty); - } - - match frame_ty { - 0x00 => { - // PADDING: consume all consecutive zero bytes. - // Suricata: parse_padding_frame - let mut i = 0; - while i < rest.len() && rest[i] == 0 { i += 1; } - input = &rest[i..]; - } - 0x08 => { - // gQUIC crypto stream frame (handshake data). - // Suricata: parse_crypto_stream_frame (frames.rs:504) - return parse_gquic_crypto_stream_frame_for_info(rest); - } - _ => break, - } - } - (Vec::new(), Vec::new()) -} - -/// Parse a gQUIC STREAM frame and return (sni, ua) from the CHLO tag-value payload. -/// -/// Ported from Suricata frames.rs parse_stream_frame (line 459). -/// -/// Frame type byte layout: 0b1_f_d_ooo_ss -/// f (0x40): FIN flag -/// d (0x20): has data_length field -/// ooo (0x1c >> 2): offset nibble (actual offset bytes = nibble+1 if nonzero) -/// ss (0x03): stream_id length - 1 (so +1 gives actual bytes) -fn parse_gquic_stream_frame_for_info(input: &[u8], frame_ty: u8) -> (Vec, Vec) { - let none = (Vec::new(), Vec::new()); - let has_data_length = frame_ty & 0x20 == 0x20; - - let offset_nibble = (frame_ty & 0x1c) >> 2; - let offset_hdr_length = if offset_nibble != 0 { (offset_nibble + 1) as usize } else { 0 }; - let stream_id_hdr_length = ((frame_ty & 0x03) + 1) as usize; - - let rest = match input.get(stream_id_hdr_length..) { Some(r) => r, None => return none }; - let rest = match rest.get(offset_hdr_length..) { Some(r) => r, None => return none }; - - let stream_data = if has_data_length { - if rest.len() < 2 { return none; } - let data_length = u16::from_be_bytes([rest[0], rest[1]]) as usize; - match rest.get(2..2 + data_length) { Some(s) => s, None => return none } - } else { - rest - }; - - // Suricata: all_consuming(parse_crypto_stream).parse(stream_data) - parse_crypto_stream_for_info(stream_data) -} - -/// Parse a gQUIC crypto stream frame (type 0x08) and return (sni, ua). -/// -/// Ported from Suricata frames.rs parse_crypto_stream_frame (line 504). -/// Layout: offset(quic_var_uint) + data_length(quic_var_uint) + stream_data -fn parse_gquic_crypto_stream_frame_for_info(input: &[u8]) -> (Vec, Vec) { - let none = (Vec::new(), Vec::new()); - let (rest, _offset) = match quic_var_uint(input) { Ok(v) => v, Err(_) => return none }; - let (rest, data_length) = match quic_var_uint(rest) { Ok(v) => v, Err(_) => return none }; - if data_length > u32::MAX as u64 { return none; } - let stream_data = match rest.get(..data_length as usize) { Some(s) => s, None => return none }; - parse_crypto_stream_for_info(stream_data) -} - -/// Parse a gQUIC CHLO tag-value structure and return (sni, ua). -/// -/// Ported from Suricata frames.rs parse_crypto_stream (line 430). -/// Mirrors Suricata handle_frames() which reads StreamTag::Sni (0x534e4900) -/// and StreamTag::Uaid (0x55414944) from the same CHLO. -/// -/// Wire format: -/// message_tag(4 BE) | num_entries(2 LE) | padding(2) | -/// [[tag(4 BE) | end_offset(4 LE)] × num_entries] | -/// [value_data cumulative bytes] -/// -/// Tags are sorted; each entry's value occupies bytes [prev_end..end_offset] -/// in the trailing value_data region. -fn parse_crypto_stream_for_info(input: &[u8]) -> (Vec, Vec) { - // Minimum: message_tag(4) + num_entries(2) + padding(2) = 8 bytes. - if input.len() < 8 { return (Vec::new(), Vec::new()); } - - // message_tag(4 BE): should be CHLO (0x43484c4f), but we accept anything. - let num_entries = u16::from_le_bytes([input[4], input[5]]) as usize; - // input[6..8] is padding, skipped. - - let tag_table_size = num_entries * 8; - let value_data_start = 8 + tag_table_size; - if input.len() < value_data_start { return (Vec::new(), Vec::new()); } - - let mut previous_end: u32 = 0; - let mut value_cursor: usize = 0; - let mut sni: Vec = Vec::new(); - let mut ua: Vec = Vec::new(); - - for i in 0..num_entries { - let entry = 8 + i * 8; - let tag = u32::from_be_bytes([ - input[entry], input[entry + 1], input[entry + 2], input[entry + 3], - ]); - let end_offset = u32::from_le_bytes([ - input[entry + 4], input[entry + 5], input[entry + 6], input[entry + 7], - ]); - - let value_len = match end_offset.checked_sub(previous_end) { - Some(v) => v as usize, - None => break, - }; - let value_start = value_data_start + value_cursor; - let value_end = value_start + value_len; - - if let Some(slice) = input.get(value_start..value_end) { - match tag { - 0x534e_4900 => sni = slice.to_vec(), // StreamTag::Sni - 0x5541_4944 => ua = slice.to_vec(), // StreamTag::Uaid - _ => {} - } - } - - value_cursor += value_len; - previous_end = end_offset; - - // Early exit once both are found. - if !sni.is_empty() && !ua.is_empty() { break; } - } - - (sni, ua) -} - -// ─── RFC QUIC Initial-packet SNI extraction ─────────────────────────────────── - -/// Attempt to strip header protection and extract SNI from a QUIC Initial packet. -/// -/// Suricata reference: quic_parse_initial → quic_parse_crypto_frame → -/// quic_extract_sni (rust/src/quic/parser.rs). -/// -/// Header protection removal (RFC 9001 §5.4.1): -/// sample = payload_ciphertext[4..20] (relative to protected pn position) -/// mask = AES-128-ECB(hp_key, sample) -/// first_byte ^= mask[0] & 0x0F -/// pn_bytes[i] ^= mask[1 + i] -fn try_extract_initial_sni(data: &[u8], version: u32, dcid_len: usize) -> Option> { - // RFC 9000 §17.2: byte[5] = DCID_Length, bytes[6..6+dcid_len] = DCID data. - let mut pos = 6usize + dcid_len; // skip first_byte(1)+version(4)+DCID_Length(1)+DCID - - // SCID - if pos >= data.len() { return None; } - let scid_len = data[pos] as usize; - pos += 1 + scid_len; - - // Token (Initial only): varint length + bytes. - let token_len = read_varint(data, &mut pos)? as usize; - pos += token_len; - - // Remaining payload length (varint). - let _payload_len = read_varint(data, &mut pos)?; - - let pn_offset = pos; - if pn_offset + 20 > data.len() { - return None; - } - - // Derive keys from DCID. - let dcid = &data[6..6 + dcid_len]; - let salt = if version == 0x0000_0001 { INITIAL_SALT_V1 } else { INITIAL_SALT_V2 }; - let initial_secret = hkdf_extract(salt, dcid); - let client_in = hkdf_expand_label(&initial_secret, "client in", &[], 32); - let hp_key = hkdf_expand_label(&client_in, "quic hp", &[], 16); - let quic_key = hkdf_expand_label(&client_in, "quic key", &[], 16); - let quic_iv = hkdf_expand_label(&client_in, "quic iv", &[], 12); - - // Header protection mask: AES-128-ECB(hp_key, ciphertext[4..20]). - let sample: [u8; 16] = data[pn_offset + 4..pn_offset + 20].try_into().ok()?; - let mask = aes128_ecb_block(&hp_key.try_into().ok()?, &sample); - - // Unmask first byte and determine packet number length. - let first_unmasked = data[0] ^ (mask[0] & 0x0F); - let pn_len = (first_unmasked & 0x03) as usize + 1; - - // Unmask packet number. - let mut pn_bytes = [0u8; 4]; - for i in 0..pn_len { - if pn_offset + i >= data.len() { return None; } - pn_bytes[i] = data[pn_offset + i] ^ mask[1 + i]; - } - let packet_number = { - let mut pn = 0u64; - for i in 0..pn_len { pn = (pn << 8) | pn_bytes[i] as u64; } - pn - }; - - // Build AAD (decrypted header bytes). - let ciphertext_start = pn_offset + pn_len; - if ciphertext_start >= data.len() { return None; } - let mut header_aad = data[..ciphertext_start].to_vec(); - header_aad[0] = first_unmasked; - for i in 0..pn_len { - header_aad[pn_offset + i] = pn_bytes[i]; - } - - // Nonce: quic_iv XOR packet_number (right-aligned to 12 bytes). - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&quic_iv); - let pn_be = packet_number.to_be_bytes(); - for i in 0..8 { - nonce[4 + i] ^= pn_be[i]; - } - - // Decrypt payload and extract SNI from CRYPTO frames. - let ciphertext = &data[ciphertext_start..]; - let plaintext = decrypt_aes128_gcm(&quic_key.try_into().ok()?, &nonce, &header_aad, ciphertext)?; - parse_crypto_frames_for_sni(&plaintext) -} - -// ─── RFC QUIC CRYPTO frame parsing ─────────────────────────────────────────── - -/// Walk RFC QUIC frames and collect CRYPTO (0x06) data, then extract SNI. -/// -/// Ported from Suricata rust/src/quic/frames.rs `parse_crypto_frame` using nom8. -/// Frame dispatch mirrors Suricata's parse_quic_frames loop (frames.rs ~line 530). -fn parse_crypto_frames_for_sni(payload: &[u8]) -> Option> { - let mut tls_data: Vec = Vec::new(); - let mut input = payload; - - while !input.is_empty() { - // Decode frame type varint (Suricata: quic_var_uint). - let (rest, frame_type) = quic_var_uint(input).ok()?; - match frame_type { - 0x00 => { - // PADDING: consume all consecutive zero bytes (Suricata: parse_padding_frame). - let mut i = 0; - while i < rest.len() && rest[i] == 0 { i += 1; } - input = &rest[i..]; - } - 0x01 => { - // PING: no payload. - input = rest; - } - 0x02 | 0x03 => { - // ACK / ACK with ECN: skip fields. - // Suricata: parse_ack_frame / parse_ack3_frame - let (r, _la) = quic_var_uint(rest).ok()?; - let (r, _ad) = quic_var_uint(r).ok()?; - let (r, arc) = quic_var_uint(r).ok()?; - let (mut r, _far) = quic_var_uint(r).ok()?; - for _ in 0..arc { - let (r2, _gap) = quic_var_uint(r).ok()?; - let (r2, _arl) = quic_var_uint(r2).ok()?; - r = r2; - } - if frame_type == 0x03 { - let (r2, _) = quic_var_uint(r).ok()?; - let (r2, _) = quic_var_uint(r2).ok()?; - let (r2, _) = quic_var_uint(r2).ok()?; - r = r2; - } - input = r; - } - 0x06 => { - // CRYPTO frame: offset(varint) + length(varint) + data. - // Suricata: parse_crypto_frame - let (r, _offset) = quic_var_uint(rest).ok()?; - let (r, length) = quic_var_uint(r).ok()?; - let len = length as usize; - if len > r.len() { break; } - tls_data.extend_from_slice(&r[..len]); - input = &r[len..]; - } - _ => break, - } - } - - if tls_data.is_empty() { return None; } - extract_sni_from_tls_client_hello(&tls_data) -} - -/// Extract SNI from a raw TLS ClientHello handshake message. -/// -/// Layout (no TLS record header — raw handshake payload from CRYPTO frames): -/// handshake_type(1) + length(3) + version(2) + random(32) + ... -fn extract_sni_from_tls_client_hello(data: &[u8]) -> Option> { - if data.len() < 38 { return None; } - if data[0] != 0x01 { return None; } // ClientHello - let hs_len = u24_be(&data[1..]) as usize; - if 4 + hs_len > data.len() { return None; } - let ch = &data[4..4 + hs_len]; - - // version(2) + random(32) = 34 bytes. - if ch.len() < 34 { return None; } - let mut pos = 34usize; - - // Session ID. - let sid_len = *ch.get(pos)? as usize; - pos = pos.checked_add(1 + sid_len)?; - - // Cipher suites. - if pos + 2 > ch.len() { return None; } - let cs_len = u16_be(&ch[pos..]) as usize; - pos = pos.checked_add(2 + cs_len)?; - - // Compression methods. - let cm_len = *ch.get(pos)? as usize; - pos = pos.checked_add(1 + cm_len)?; - - // Extensions. - if pos + 2 > ch.len() { return None; } - let ext_total = u16_be(&ch[pos..]) as usize; - pos += 2; - let ext_end = pos + ext_total; - if ext_end > ch.len() { return None; } - - while pos + 4 <= ext_end { - let ext_type = u16_be(&ch[pos..]); - let ext_len = u16_be(&ch[pos + 2..]) as usize; - pos += 4; - if pos + ext_len > ext_end { break; } - let ext_data = &ch[pos..pos + ext_len]; - - if ext_type == 0x0000 && ext_len >= 5 { - // SNI extension: list_len(2) + name_type(1) + name_len(2) + name. - let name_type = ext_data[2]; - let name_len = u16_be(&ext_data[3..]) as usize; - if name_type == 0 && 5 + name_len <= ext_len { - return Some(ext_data[5..5 + name_len].to_vec()); - } - } - - pos += ext_len; - } - - None -} - -// ─── Crypto primitives ──────────────────────────────────────────────────────── - -/// HMAC-SHA256 using the existing sha2 crate (no hmac crate needed). -fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] { - let mut k = [0u8; 64]; - if key.len() <= 64 { - k[..key.len()].copy_from_slice(key); - } else { - let h: [u8; 32] = Sha256::digest(key).into(); - k[..32].copy_from_slice(&h); - } - let mut ipad = k; - let mut opad = k; - for b in &mut ipad { *b ^= 0x36; } - for b in &mut opad { *b ^= 0x5C; } - let inner: [u8; 32] = { - let mut h = Sha256::new(); - h.update(ipad); - h.update(msg); - h.finalize().into() - }; - let outer: [u8; 32] = { - let mut h = Sha256::new(); - h.update(opad); - h.update(inner); - h.finalize().into() - }; - outer -} - -/// HKDF-Extract = HMAC-SHA256(salt, ikm). -fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] { - hmac_sha256(salt, ikm) -} - -/// HKDF-Expand-Label as specified in RFC 8446 §7.1 (used by QUIC per RFC 9001). -fn hkdf_expand_label(prk: &[u8], label: &str, context: &[u8], length: usize) -> Vec { - let full_label = format!("tls13 {}", label); - let mut info = Vec::new(); - info.push((length >> 8) as u8); - info.push(length as u8); - info.push(full_label.len() as u8); - info.extend_from_slice(full_label.as_bytes()); - info.push(context.len() as u8); - info.extend_from_slice(context); - hkdf_expand(prk, &info, length) -} - -/// HKDF-Expand (RFC 5869 §2.3). -fn hkdf_expand(prk: &[u8], info: &[u8], length: usize) -> Vec { - let mut out = Vec::with_capacity(length); - let mut t: Vec = Vec::new(); - let mut n = 1u8; - while out.len() < length { - let mut msg = t.clone(); - msg.extend_from_slice(info); - msg.push(n); - t = hmac_sha256(prk, &msg).to_vec(); - out.extend_from_slice(&t); - n += 1; - } - out.truncate(length); - out -} - -/// AES-128-ECB single-block encrypt for QUIC header protection mask. -fn aes128_ecb_block(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { - aes128_encrypt(key, block) -} - -/// AES-128-GCM decryption using the aes-gcm crate. -fn decrypt_aes128_gcm(key: &[u8; 16], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Option> { - let cipher = Aes128Gcm::new(Key::::from_slice(key)); - cipher.decrypt(Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok() -} - -// ─── AES-128 software implementation (FIPS 197) ────────────────────────────── -// Used for header protection mask (ECB mode, single block). -// Reference: Suricata rust/src/quic/aes128.rs (hand-rolled for no-dep builds). - -const SBOX: [u8; 256] = [ - 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, - 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, - 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, - 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, - 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, - 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, - 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, - 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, - 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, - 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, - 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, - 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, - 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, - 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, - 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, - 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16, -]; - -#[allow(dead_code)] -fn xtime(a: u8) -> u8 { - if a & 0x80 != 0 { (a << 1) ^ 0x1b } else { a << 1 } -} - -fn gmul(mut a: u8, mut b: u8) -> u8 { - let mut p = 0u8; - for _ in 0..8 { - if b & 1 != 0 { p ^= a; } - let hi = a & 0x80 != 0; - a <<= 1; - if hi { a ^= 0x1b; } - b >>= 1; - } - p -} - -fn sub_bytes(state: &mut [u8; 16]) { - for b in state.iter_mut() { *b = SBOX[*b as usize]; } -} - -fn shift_rows(s: &mut [u8; 16]) { - let t = s[1]; s[1] = s[5]; s[5] = s[9]; s[9] = s[13]; s[13] = t; - s.swap(2, 10); s.swap(6, 14); - let t = s[15]; s[15] = s[11]; s[11] = s[7]; s[7] = s[3]; s[3] = t; -} - -fn mix_columns(s: &mut [u8; 16]) { - for c in 0..4 { - let i = c * 4; - let (s0, s1, s2, s3) = (s[i], s[i+1], s[i+2], s[i+3]); - s[i] = gmul(0x02,s0)^gmul(0x03,s1)^s2^s3; - s[i+1] = s0^gmul(0x02,s1)^gmul(0x03,s2)^s3; - s[i+2] = s0^s1^gmul(0x02,s2)^gmul(0x03,s3); - s[i+3] = gmul(0x03,s0)^s1^s2^gmul(0x02,s3); - } -} - -fn add_round_key(state: &mut [u8; 16], round_key: &[u8]) { - for (s, k) in state.iter_mut().zip(round_key) { *s ^= k; } -} - -/// AES-128 key schedule + encryption of a single 16-byte block. -fn aes128_encrypt(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { - let mut w = [0u8; 176]; - w[..16].copy_from_slice(key); - let rcon: [u8; 10] = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36]; - for i in 4..44usize { - let mut temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]]; - if i % 4 == 0 { - let t = temp[0]; - temp[0] = SBOX[temp[1] as usize] ^ rcon[i/4 - 1]; - temp[1] = SBOX[temp[2] as usize]; - temp[2] = SBOX[temp[3] as usize]; - temp[3] = SBOX[t as usize]; - } - for j in 0..4 { w[i*4+j] = w[(i-4)*4+j] ^ temp[j]; } - } - - let mut state: [u8; 16] = *block; - add_round_key(&mut state, &w[0..16]); - for round in 1..=9 { - sub_bytes(&mut state); - shift_rows(&mut state); - mix_columns(&mut state); - add_round_key(&mut state, &w[round*16..(round+1)*16]); - } - sub_bytes(&mut state); - shift_rows(&mut state); - add_round_key(&mut state, &w[160..176]); - state -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/// QUIC variable-length integer (RFC 9000 §16) using nom8. -/// -/// Ported verbatim from Suricata rust/src/quic/parser.rs `quic_var_uint`. -pub(crate) fn quic_var_uint(input: &[u8]) -> IResult<&[u8], u64> { - let (rest, first) = be_u8(input)?; - let msb = first >> 6; - let lsb = (first & 0x3F) as u64; - match msb { - 3 => { - let (rest, second) = be_u24(rest)?; - let (rest, third) = be_u32(rest)?; - Ok((rest, (lsb << 56) | ((second as u64) << 32) | (third as u64))) - } - 2 => { - let (rest, second) = be_u24(rest)?; - Ok((rest, (lsb << 24) | (second as u64))) - } - 1 => { - let (rest, second) = be_u8(rest)?; - Ok((rest, (lsb << 8) | (second as u64))) - } - _ => Ok((rest, lsb)), - } -} - -/// Adapter: call quic_var_uint from position-based code and advance pos. -fn read_varint(data: &[u8], pos: &mut usize) -> Option { - let (rest, value) = quic_var_uint(&data[*pos..]).ok()?; - *pos = data.len() - rest.len(); - Some(value) -} - -/// True for RFC QUIC, draft, and gQUIC version values. -fn is_valid_quic_version(v: u32) -> bool { - is_gquic_version(v) || matches!(v, - 0x0000_0001 // QUIC v1 (RFC 9000) - | 0x6b33_43cf // QUIC v2 (RFC 9369) - | 0x0000_0000 // Version Negotiation - | 0xff00_001d // draft-29 - | 0xff00_0020 // draft-32 - | 0xff00_0021 // draft-33 - | 0xff00_0022 // draft-34 - ) -} - -/// True for Google QUIC versions Q043-Q046. -fn is_gquic_version(v: u32) -> bool { - matches!(v, - GQUIC_VERSION_Q043 | GQUIC_VERSION_Q044 | GQUIC_VERSION_Q045 | GQUIC_VERSION_Q046 - ) -} - -#[inline] -fn u16_be(b: &[u8]) -> u16 { u16::from_be_bytes([b[0], b[1]]) } - -#[inline] -fn u24_be(b: &[u8]) -> u32 { (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32 } diff --git a/net-guardia/src/detection/rule/app_layer/smtp.rs b/net-guardia/src/detection/rule/app_layer/smtp.rs deleted file mode 100644 index 5b6ff42..0000000 --- a/net-guardia/src/detection/rule/app_layer/smtp.rs +++ /dev/null @@ -1,86 +0,0 @@ -/// SMTP protocol parser. -/// -/// Extracts EHLO/HELO hostname, MAIL FROM address, RCPT TO address, and -/// the first reply code from a single TCP payload chunk. -/// -/// Commands are matched case-insensitively per RFC 5321 §2.4. - -pub struct SmtpInfo { - /// Argument from EHLO or HELO command. - pub helo: Vec, - /// Sender address from MAIL FROM (angle brackets stripped). - pub from: Vec, - /// Recipient address from RCPT TO (angle brackets stripped). - pub rcpt: Vec, - /// First 3-digit reply code seen in this chunk. - pub reply: Vec, -} - -pub fn parse_smtp(data: &[u8]) -> Option { - let mut info = SmtpInfo { - helo: Vec::new(), - from: Vec::new(), - rcpt: Vec::new(), - reply: Vec::new(), - }; - let mut found = false; - - for raw_line in data.split(|&b| b == b'\n') { - let line = strip_cr(raw_line); - if line.is_empty() { - continue; - } - - if info.helo.is_empty() && ci_starts_with(line, b"EHLO ") { - info.helo = line[5..].to_vec(); - found = true; - } else if info.helo.is_empty() && ci_starts_with(line, b"HELO ") { - info.helo = line[5..].to_vec(); - found = true; - } else if info.from.is_empty() && ci_starts_with(line, b"MAIL FROM:") { - info.from = angle_addr(&line[10..]).to_vec(); - found = true; - } else if info.rcpt.is_empty() && ci_starts_with(line, b"RCPT TO:") { - info.rcpt = angle_addr(&line[8..]).to_vec(); - found = true; - } else if info.reply.is_empty() - && line.len() >= 3 - && line[..3].iter().all(|b| b.is_ascii_digit()) - { - info.reply = line[..3].to_vec(); - found = true; - } - } - - if found { Some(info) } else { None } -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -fn strip_cr(line: &[u8]) -> &[u8] { - line.strip_suffix(b"\r").unwrap_or(line) -} - -fn ci_starts_with(haystack: &[u8], needle: &[u8]) -> bool { - haystack.len() >= needle.len() - && haystack[..needle.len()] - .iter() - .zip(needle) - .all(|(a, b)| a.to_ascii_uppercase() == b.to_ascii_uppercase()) -} - -/// Strip surrounding `<` `>` and leading whitespace from an address field. -fn angle_addr(s: &[u8]) -> &[u8] { - let s = ltrim(s); - if s.first() == Some(&b'<') { - let end = s.iter().position(|&b| b == b'>').unwrap_or(s.len()); - &s[1..end] - } else { - s - } -} - -fn ltrim(s: &[u8]) -> &[u8] { - let start = s.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(s.len()); - &s[start..] -} diff --git a/net-guardia/src/detection/rule/app_layer/ssh.rs b/net-guardia/src/detection/rule/app_layer/ssh.rs deleted file mode 100644 index 4fd475a..0000000 --- a/net-guardia/src/detection/rule/app_layer/ssh.rs +++ /dev/null @@ -1,127 +0,0 @@ -/* Ported from Suricata rust/src/ssh/parser.rs - * Copyright (C) 2020 Open Information Security Foundation - * Licensed under GPLv2. - * - * Rewritten to use nom8 (nom = "8.0" aliased as nom8). - * - * HASSH (FoxIO) fingerprints: - * client: MD5(kex_algs ; enc_c2s ; mac_c2s ; comp_c2s) - * server: MD5(kex_algs ; enc_s2c ; mac_s2c ; comp_s2c) - * - * Reference: https://github.com/salesforce/hassh - */ - -use nom8::branch::alt; -use nom8::bytes::streaming::{is_not, tag, take}; -use nom8::combinator::{complete, rest}; -use nom8::error::{Error as NomError}; -use nom8::multi::length_data; -use nom8::number::streaming::{be_u32, be_u8}; -use nom8::combinator::verify; -use nom8::Parser; - -const SSH_MSG_KEXINIT: u8 = 20; - -pub struct SshInfo { - /// Protocol version string, e.g. "2.0". - pub proto: Vec, - /// Software version string, e.g. "OpenSSH_8.9". - pub software: Vec, - /// HASSH MD5 hex fingerprint (32 chars), empty for banner packets. - pub hassh: Vec, -} - -/// Try to parse `data` as an SSH banner or Kexinit packet. -pub fn parse_ssh(data: &[u8]) -> Option { - if data.starts_with(b"SSH-") { - parse_banner(data) - } else { - parse_kexinit(data) - } -} - -// ─── Banner (Suricata ssh_parse_banner) ────────────────────────────────────── - -/// Parse SSH version banner: SSH--[ ][CR][LF] -/// -/// Ports Suricata's ssh_parse_banner using nom8: -/// tag("SSH-") + is_not("-") + char('-') + alt((complete(is_not(" \r\n")), rest)) -fn parse_banner(data: &[u8]) -> Option { - let (i, _) = tag::<_, _, NomError<_>>("SSH-")(data).ok()?; - let (i, protover) = is_not::<_, _, NomError<_>>("-")(i).ok()?; - // consume the '-' separator between protover and swver - let (i, _) = tag::<_, _, NomError<_>>("-")(i).ok()?; - // swver: up to first space/CR/LF, or the remainder of data - let (_i, swver) = alt(( - complete(is_not::<_, _, NomError<_>>(" \r\n")), - rest::<_, NomError<_>>, - )) - .parse(i) - .ok()?; - - Some(SshInfo { - proto: protover.to_vec(), - software: swver.to_vec(), - hassh: Vec::new(), - }) -} - -// ─── Kexinit (Suricata ssh_parse_record_header + ssh_parse_key_exchange + generate_hassh) ── - -/// Parse SSH_MSG_KEXINIT binary packet and compute client HASSH. -/// -/// Ports Suricata's ssh_parse_record_header (verify(be_u32, val>1) + be_u8 + be_u8) -/// and ssh_parse_key_exchange (take(16) cookie + 10x length_data(be_u32) name-lists). -/// generate_hassh(to_server=false): MD5(kex ; enc_c2s ; mac_c2s ; comp_c2s) -fn parse_kexinit(data: &[u8]) -> Option { - // ssh_parse_record_header: verify pkt_len > 1 - let (i, pkt_len) = verify(be_u32::<_, NomError<_>>, |&val| val > 1) - .parse(data) - .ok()?; - let (i, padding_len) = be_u8::<_, NomError<_>>(i).ok()?; - let (i, msg_code) = be_u8::<_, NomError<_>>(i).ok()?; - - if msg_code != SSH_MSG_KEXINIT { - return None; - } - - // Payload length = pkt_len - 1 (padding_len field) - padding_len - 1 (msg_code) - let padding_len = padding_len as usize; - let pkt_len = pkt_len as usize; - if padding_len + 2 > pkt_len { - return None; - } - let payload_len = pkt_len - 2 - padding_len; - if payload_len == 0 || payload_len > i.len() { - return None; - } - let (_, payload) = take::<_, _, NomError<_>>(payload_len)(i).ok()?; - - // ssh_parse_key_exchange: cookie(16) + 10 name-lists - let (payload, _cookie) = take::<_, _, NomError<_>>(16usize)(payload).ok()?; - - // Each name-list is a be_u32-length-prefixed byte string. - // Parse all 10 in SSH order; only kex_algs, enc_c2s, mac_c2s, comp_c2s are kept. - let (p, kex_algs) = length_data(be_u32::<_, NomError<_>>).parse(payload).ok()?; // 0 - let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 1 server_host_key_algs - let (p, enc_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 2 enc_c2s - let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 3 enc_s2c - let (p, mac_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 4 mac_c2s - let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 5 mac_s2c - let (_, comp_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 6 comp_c2s - - // generate_hassh(to_server=false): MD5(kex ; enc_c2s ; mac_c2s ; comp_c2s) - let mut hassh_string: Vec = Vec::new(); - hassh_string.extend_from_slice(kex_algs); - hassh_string.push(b';'); - hassh_string.extend_from_slice(enc_c2s); - hassh_string.push(b';'); - hassh_string.extend_from_slice(mac_c2s); - hassh_string.push(b';'); - hassh_string.extend_from_slice(comp_c2s); - - let digest = md5::compute(&hassh_string); - let hassh = format!("{:x}", digest).into_bytes(); - - Some(SshInfo { proto: Vec::new(), software: Vec::new(), hassh }) -} diff --git a/net-guardia/src/detection/rule/app_layer/tls.rs b/net-guardia/src/detection/rule/app_layer/tls.rs deleted file mode 100644 index d7da4b0..0000000 --- a/net-guardia/src/detection/rule/app_layer/tls.rs +++ /dev/null @@ -1,613 +0,0 @@ -/* Ported from Suricata rust/src/tls/ - * Copyright (C) 2017-2022 Open Information Security Foundation - * Licensed under GPLv2. - * - * Algorithm ported verbatim; nom8 replaced with hand-written byte-slice parsing. - * - * JA3 (Salesforce): MD5(version,ciphers,extensions,curves,point_fmts) - * Reference: https://github.com/salesforce/ja3 - * - * JA3S (Salesforce): MD5(version,cipher,extensions) - * Reference: https://github.com/salesforce/ja3#ja3s - * - * JA4 (FoxIO): t{ver}{sni}{cc:02}{ec:02}{alpn}_{cipher_sha256[:12]}_{ext_sha256[:12]} - * Reference: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md - * - * Certificate parsing via x509-parser; SHA-1 fingerprint over raw DER bytes. - * Suricata reference: rust/src/x509/mod.rs - */ - -use sha1::{Digest, Sha1}; -use sha2::Sha256; -use x509_parser::prelude::*; - -const RT_CHANGE_CIPHER: u8 = 0x14; -const RT_ALERT: u8 = 0x15; -const RT_HANDSHAKE: u8 = 0x16; -const RT_APP_DATA: u8 = 0x17; - -const HS_CLIENT_HELLO: u8 = 0x01; -const HS_SERVER_HELLO: u8 = 0x02; -const HS_CERTIFICATE: u8 = 0x0B; - -const EXT_SNI: u16 = 0x0000; -const EXT_SIG_ALGS: u16 = 0x000d; -const EXT_SUPPORTED_GROUPS: u16 = 0x000a; -const EXT_EC_POINT_FORMATS: u16 = 0x000b; -const EXT_ALPN: u16 = 0x0010; -const EXT_SUPPORTED_VERSIONS: u16 = 0x002b; - -#[derive(Debug, Default)] -pub struct TlsInfo { - pub is_app_data: bool, - pub sni: Vec, - /// Human-readable version: "TLS 1.3", "TLS 1.2", etc. - pub version: Vec, - /// JA3 MD5 hex fingerprint (32 chars), from ClientHello. - pub ja3: Vec, - /// JA3S MD5 hex fingerprint (32 chars), from ServerHello. - pub ja3s: Vec, - /// JA4 fingerprint string, from ClientHello. - pub ja4: Vec, - /// First ALPN protocol, from ClientHello or ServerHello. - pub alpn: Vec, - /// Certificate subject distinguished name. - pub cert_subject: Vec, - /// Certificate issuer distinguished name. - pub cert_issuer: Vec, - /// Certificate SHA-1 fingerprint, lowercase hex (40 chars). - pub cert_fingerprint: Vec, - /// Certificate notBefore in ISO 8601 format: "YYYYMMDDTHHMMSSZ". - pub cert_not_before: Vec, - /// Certificate notAfter in ISO 8601 format: "YYYYMMDDTHHMMSSZ". - pub cert_not_after: Vec, -} - -/// Parse a TLS record layer buffer. -/// -/// Scans all TLS records in `data`. A single TCP segment may contain multiple -/// TLS records (e.g. ServerHello + Certificate in one segment). ClientHello -/// populates ja3/ja4/sni/version; ServerHello populates ja3s/alpn/version; -/// Certificate populates cert_* fields. -pub fn detect_tls(data: &[u8]) -> Option { - if data.len() < 5 { - return None; - } - - // Quick sanity check: first byte must be a known TLS record type and - // version field must be 3.x (SSLv3 / TLS). - let record_type = data[0]; - if !matches!(record_type, RT_CHANGE_CIPHER | RT_ALERT | RT_HANDSHAKE | RT_APP_DATA) { - return None; - } - if data[1] != 3 || data[2] > 4 { - return None; - } - - if record_type == RT_APP_DATA { - return Some(TlsInfo { is_app_data: true, ..Default::default() }); - } - - let mut info = TlsInfo::default(); - - // Walk all TLS records in the segment. - let mut pos = 0usize; - while pos + 5 <= data.len() { - let rtype = data[pos]; - let rlen = u16_be(&data[pos + 3..]) as usize; - pos += 5; - if pos + rlen > data.len() { - break; - } - let record = &data[pos..pos + rlen]; - pos += rlen; - - if rtype != RT_HANDSHAKE { - continue; - } - - // Walk all handshake messages within the record. - let mut hp = 0usize; - while hp + 4 <= record.len() { - let hs_type = record[hp]; - let hs_len = u24_be(&record[hp + 1..]) as usize; - hp += 4; - if hp + hs_len > record.len() { - break; - } - let hs_body = &record[hp..hp + hs_len]; - hp += hs_len; - - match hs_type { - HS_CLIENT_HELLO => parse_client_hello_into(&mut info, hs_body), - HS_SERVER_HELLO => parse_server_hello_into(&mut info, hs_body), - HS_CERTIFICATE => parse_certificate_into(&mut info, hs_body), - _ => {} - } - } - } - - Some(info) -} - -// ─── ClientHello ───────────────────────────────────────────────────────────── - -fn parse_client_hello_into(info: &mut TlsInfo, ch: &[u8]) { - // version(2) + random(32) + ... - if ch.len() < 34 { - return; - } - - let client_version = u16_be(ch); - let mut pos = 34usize; - - // Session ID - let sid_len = match ch.get(pos) { Some(&v) => v as usize, None => return }; - pos = match pos.checked_add(1 + sid_len) { Some(v) => v, None => return }; - - // Cipher suites - if pos + 2 > ch.len() { return; } - let cs_len = u16_be(&ch[pos..]) as usize; - pos += 2; - if pos + cs_len > ch.len() { return; } - let mut ciphers: Vec = Vec::new(); - let mut i = 0; - while i + 1 < cs_len { - let c = u16_be(&ch[pos + i..]); - if !is_grease(c) { ciphers.push(c); } - i += 2; - } - pos += cs_len; - - // Compression methods - let cm_len = match ch.get(pos) { Some(&v) => v as usize, None => return }; - pos = match pos.checked_add(1 + cm_len) { Some(v) => v, None => return }; - - // Extensions - let mut sni = Vec::new(); - let mut ext_types = Vec::new(); - let mut curves = Vec::new(); - let mut point_fmts = Vec::new(); - let mut supported_ver: Option = None; - let mut alpn = Vec::new(); - let mut sig_algs = Vec::new(); - - if pos + 2 <= ch.len() { - let ext_total = u16_be(&ch[pos..]) as usize; - pos += 2; - let ext_end = pos + ext_total; - if ext_end <= ch.len() { - while pos + 4 <= ext_end { - let ext_type = u16_be(&ch[pos..]); - let ext_len = u16_be(&ch[pos + 2..]) as usize; - pos += 4; - if pos + ext_len > ext_end { break; } - let ext_data = &ch[pos..pos + ext_len]; - - if !is_grease(ext_type) { - ext_types.push(ext_type); - } - - match ext_type { - EXT_SNI => { - if ext_len >= 5 { - let name_type = ext_data[2]; - let name_len = u16_be(&ext_data[3..]) as usize; - if name_type == 0 && 5 + name_len <= ext_len { - sni = ext_data[5..5 + name_len].to_vec(); - } - } - } - EXT_SUPPORTED_GROUPS => { - if ext_len >= 2 { - let list_len = u16_be(ext_data) as usize; - let mut ci = 2; - while ci + 1 < list_len.saturating_add(2).min(ext_len) { - let v = u16_be(&ext_data[ci..]); - if !is_grease(v) { curves.push(v); } - ci += 2; - } - } - } - EXT_EC_POINT_FORMATS => { - if ext_len >= 1 { - let list_len = ext_data[0] as usize; - for j in 0..list_len.min(ext_len.saturating_sub(1)) { - point_fmts.push(ext_data[1 + j]); - } - } - } - EXT_SUPPORTED_VERSIONS => { - // ClientHello: list_len(1) + 2-byte versions - if ext_len >= 1 { - let list_len = ext_data[0] as usize; - let mut vi = 1; - while vi + 1 <= list_len.saturating_add(1).min(ext_len) { - let v = u16_be(&ext_data[vi..]); - if !is_grease(v) && supported_ver.is_none() { - supported_ver = Some(v); - } - vi += 2; - } - } - } - EXT_ALPN => { - // protocol_list_len(2) + [ proto_len(1) + proto_bytes... ] - if ext_len >= 4 { - let list_len = u16_be(ext_data) as usize; - if list_len >= 1 && 2 + list_len <= ext_len { - let proto_len = ext_data[2] as usize; - if proto_len > 0 && 3 + proto_len <= ext_len { - alpn = ext_data[3..3 + proto_len].to_vec(); - } - } - } - } - EXT_SIG_ALGS => { - // list_len(2) + 2-byte scheme codes - if ext_len >= 2 { - let list_len = u16_be(ext_data) as usize; - let mut si = 2; - while si + 1 < list_len.saturating_add(2).min(ext_len) { - let v = u16_be(&ext_data[si..]); - sig_algs.push(v); - si += 2; - } - } - } - _ => {} - } - - pos += ext_len; - } - } - } - - // JA3 uses client_version (legacy field), per the JA3 specification. - info.ja3 = ja3_compute(client_version, &ciphers, &ext_types, &curves, &point_fmts); - - // JA4 - let display_v = supported_ver.unwrap_or(client_version); - info.ja4 = ja4_compute( - display_v, - &sni, - &ciphers, - &ext_types, - &alpn, - &sig_algs, - ); - - // Version string from supported_versions extension (TLS 1.3) or client_version. - if info.version.is_empty() { - info.version = tls_version_str(display_v).as_bytes().to_vec(); - } - - if info.sni.is_empty() { - info.sni = sni; - } - if info.alpn.is_empty() { - info.alpn = alpn; - } -} - -// ─── ServerHello ───────────────────────────────────────────────────────────── - -fn parse_server_hello_into(info: &mut TlsInfo, sh: &[u8]) { - // version(2) + random(32) + ... - if sh.len() < 34 { - return; - } - - let server_version = u16_be(sh); - let mut pos = 34usize; - - // Session ID - let sid_len = match sh.get(pos) { Some(&v) => v as usize, None => return }; - pos = match pos.checked_add(1 + sid_len) { Some(v) => v, None => return }; - - // Selected cipher suite (2 bytes) - if pos + 2 > sh.len() { return; } - let cipher = u16_be(&sh[pos..]); - pos += 2; - - // Compression method (1 byte) - pos += 1; - - // Extensions - let mut ext_types = Vec::new(); - let mut supported_ver: Option = None; - let mut alpn = Vec::new(); - - if pos + 2 <= sh.len() { - let ext_total = u16_be(&sh[pos..]) as usize; - pos += 2; - let ext_end = pos + ext_total; - if ext_end <= sh.len() { - while pos + 4 <= ext_end { - let ext_type = u16_be(&sh[pos..]); - let ext_len = u16_be(&sh[pos + 2..]) as usize; - pos += 4; - if pos + ext_len > ext_end { break; } - let ext_data = &sh[pos..pos + ext_len]; - - if !is_grease(ext_type) { - ext_types.push(ext_type); - } - - match ext_type { - EXT_SUPPORTED_VERSIONS => { - // ServerHello: single 2-byte version (no length prefix) - if ext_len >= 2 { - let v = u16_be(ext_data); - if !is_grease(v) { - supported_ver = Some(v); - } - } - } - EXT_ALPN => { - // Same layout as ClientHello ALPN - if ext_len >= 4 { - let list_len = u16_be(ext_data) as usize; - if list_len >= 1 && 2 + list_len <= ext_len { - let proto_len = ext_data[2] as usize; - if proto_len > 0 && 3 + proto_len <= ext_len { - alpn = ext_data[3..3 + proto_len].to_vec(); - } - } - } - } - _ => {} - } - - pos += ext_len; - } - } - } - - // JA3S: MD5(version,cipher,extensions) - let display_v = supported_ver.unwrap_or(server_version); - info.ja3s = ja3s_compute(display_v, cipher, &ext_types); - - if info.version.is_empty() { - info.version = tls_version_str(display_v).as_bytes().to_vec(); - } - if info.alpn.is_empty() { - info.alpn = alpn; - } -} - -// ─── Certificate ───────────────────────────────────────────────────────────── - -/// Parse TLS Certificate handshake body. -/// -/// Layout: cert_list_len(3) + [ cert_len(3) + DER_bytes ... ] -/// Only the first (leaf) certificate is examined. -/// Suricata reference: TlsCertificateNew in rust/src/tls/tls.rs, which calls -/// x509_parser::parse_x509_certificate on the first cert DER. -fn parse_certificate_into(info: &mut TlsInfo, body: &[u8]) { - if body.len() < 3 { - return; - } - let list_len = u24_be(body) as usize; - if list_len + 3 > body.len() || list_len < 3 { - return; - } - let first_cert_len = u24_be(&body[3..]) as usize; - if 6 + first_cert_len > body.len() { - return; - } - let der = &body[6..6 + first_cert_len]; - - // SHA-1 fingerprint of raw DER bytes (Suricata: sha1::compute). - let sha1_hash = Sha1::digest(der); - info.cert_fingerprint = sha1_hash - .iter() - .map(|b| format!("{:02x}", b)) - .collect::() - .into_bytes(); - - // Parse DER with x509-parser (Suricata: parse_x509_certificate). - if let Ok((_, cert)) = X509Certificate::from_der(der) { - info.cert_subject = cert.subject().to_string().into_bytes(); - info.cert_issuer = cert.issuer().to_string().into_bytes(); - - let validity = cert.validity(); - info.cert_not_before = format_asn1_time(validity.not_before.timestamp()); - info.cert_not_after = format_asn1_time(validity.not_after.timestamp()); - } -} - -// ─── JA3 ───────────────────────────────────────────────────────────────────── - -fn ja3_compute( - version: u16, - ciphers: &[u16], - ext_types: &[u16], - curves: &[u16], - point_fmts: &[u8], -) -> Vec { - let join_u16 = |v: &[u16]| v.iter().map(|x| x.to_string()).collect::>().join("-"); - let join_u8 = |v: &[u8]| v.iter().map(|x| x.to_string()).collect::>().join("-"); - let s = format!( - "{},{},{},{},{}", - version, - join_u16(ciphers), - join_u16(ext_types), - join_u16(curves), - join_u8(point_fmts), - ); - let digest = md5::compute(s.as_bytes()); - format!("{:x}", digest).into_bytes() -} - -// ─── JA3S ──────────────────────────────────────────────────────────────────── - -/// JA3S: MD5(version,cipher,extensions). -/// Extensions are dash-joined decimals; no GREASE (already filtered). -fn ja3s_compute(version: u16, cipher: u16, ext_types: &[u16]) -> Vec { - let exts_str = ext_types.iter().map(|x| x.to_string()).collect::>().join("-"); - let s = format!("{},{},{}", version, cipher, exts_str); - let digest = md5::compute(s.as_bytes()); - format!("{:x}", digest).into_bytes() -} - -// ─── JA4 ───────────────────────────────────────────────────────────────────── - -/// JA4 fingerprint. -/// -/// Format: t{ver}{sni_type}{cc:02}{ec:02}{alpn}_{cipher_hash}_{ext_hash} -/// -/// ver: 13=TLS1.3 12=TLS1.2 11=TLS1.1 10=TLS1.0 s3=SSL3.0 00=unknown -/// sni_type: 'd' if domain SNI present, 'i' if IP literal, 'n' if no SNI -/// cc: cipher count (excluding GREASE + 0x00FF, capped at 99) -/// ec: extension count (excluding GREASE, capped at 99) -/// alpn: first and last byte of first ALPN (or "00") -/// cipher_hash: first 12 chars of SHA-256 hex of sorted cipher decimals -/// ext_hash: first 12 chars of SHA-256 hex of sorted ext types -/// (excl. GREASE, SNI=0x0000, ALPN=0x0010) -/// + "_" + sig_algs as dash-joined decimals (in order, not sorted) -fn ja4_compute( - version: u16, - sni: &[u8], - ciphers: &[u16], - ext_types: &[u16], - alpn: &[u8], - sig_algs: &[u16], -) -> Vec { - // Protocol prefix: always 't' (TCP) for standard TLS captured by NetGuardia. - let proto = 't'; - - // Version code - let ver_code = match version { - 0x0304 => "13", - 0x0303 => "12", - 0x0302 => "11", - 0x0301 => "10", - 0x0300 => "s3", - _ => "00", - }; - - // SNI type - let sni_type = if sni.is_empty() { - 'n' - } else if sni.iter().all(|&b| b.is_ascii_digit() || b == b'.' || b == b':') { - 'i' - } else { - 'd' - }; - - // Cipher count: exclude GREASE and 0x00FF (EMPTY_RENEGOTIATION_INFO_SCSV). - let filtered_ciphers: Vec = ciphers.iter() - .copied() - .filter(|&c| !is_grease(c) && c != 0x00FF) - .collect(); - let cc = filtered_ciphers.len().min(99); - - // Extension count: exclude GREASE. - let filtered_exts: Vec = ext_types.iter() - .copied() - .filter(|&e| !is_grease(e)) - .collect(); - let ec = filtered_exts.len().min(99); - - // ALPN: first and last byte of the first protocol string. - let alpn_tag = if alpn.len() >= 2 { - format!("{}{}", alpn[0] as char, alpn[alpn.len() - 1] as char) - } else if alpn.len() == 1 { - format!("{}{}", alpn[0] as char, alpn[0] as char) - } else { - "00".to_string() - }; - - // Cipher hash: sorted decimal, comma-separated. - let mut sorted_ciphers = filtered_ciphers.clone(); - sorted_ciphers.sort_unstable(); - let cipher_str = sorted_ciphers.iter().map(|c| c.to_string()).collect::>().join(","); - let cipher_hash_full = hex_sha256(cipher_str.as_bytes()); - let cipher_hash = &cipher_hash_full[..12.min(cipher_hash_full.len())]; - - // Extension hash: sorted extensions (excl. SNI=0, ALPN=0x0010), then sig_algs. - let mut sorted_exts: Vec = filtered_exts.iter() - .copied() - .filter(|&e| e != EXT_SNI && e != EXT_ALPN) - .collect(); - sorted_exts.sort_unstable(); - let ext_str = sorted_exts.iter().map(|e| e.to_string()).collect::>().join(","); - let sig_str = sig_algs.iter().map(|s| s.to_string()).collect::>().join("-"); - let ext_input = if sig_str.is_empty() { - ext_str - } else { - format!("{}_{}", ext_str, sig_str) - }; - let ext_hash_full = hex_sha256(ext_input.as_bytes()); - let ext_hash = &ext_hash_full[..12.min(ext_hash_full.len())]; - - format!( - "{}{}{}{:02}{:02}{}_{}_{}", - proto, ver_code, sni_type, cc, ec, alpn_tag, cipher_hash, ext_hash - ).into_bytes() -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/// GREASE values per RFC 8701: both bytes equal, lower nibble == 0xA. -fn is_grease(v: u16) -> bool { - let lo = v as u8; - let hi = (v >> 8) as u8; - hi == lo && lo & 0x0F == 0x0A -} - -fn hex_sha256(data: &[u8]) -> String { - let hash = Sha256::digest(data); - hash.iter().map(|b| format!("{:02x}", b)).collect() -} - -/// Format a Unix timestamp as "YYYYMMDDTHHMMSSZ". -fn format_asn1_time(ts: i64) -> Vec { - // Manual UTC decomposition — avoids a chrono dependency in this file. - // Uses the proleptic Gregorian calendar algorithm. - let secs = if ts >= 0 { ts as u64 } else { return b"19700101T000000Z".to_vec() }; - let s = secs % 60; - let m = (secs / 60) % 60; - let h = (secs / 3600) % 24; - let days = secs / 86400; - let (y, mo, d) = days_to_ymd(days); - format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", y, mo, d, h, m, s).into_bytes() -} - -/// Convert days since Unix epoch (1970-01-01) to (year, month, day). -fn days_to_ymd(z: u64) -> (u64, u64, u64) { - // Algorithm from http://howardhinnant.github.io/date_algorithms.html - let z = z + 719468; - let era = z / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if mo <= 2 { y + 1 } else { y }; - (y, mo, d) -} - -fn tls_version_str(v: u16) -> &'static str { - match v { - 0x0304 => "TLS 1.3", - 0x0303 => "TLS 1.2", - 0x0302 => "TLS 1.1", - 0x0301 => "TLS 1.0", - 0x0300 => "SSL 3.0", - _ => "", - } -} - -#[inline] -fn u16_be(b: &[u8]) -> u16 { - u16::from_be_bytes([b[0], b[1]]) -} - -#[inline] -fn u24_be(b: &[u8]) -> u32 { - (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32 -} diff --git a/net-guardia/src/detection/rule/bounded_pcre.rs b/net-guardia/src/detection/rule/bounded_pcre.rs deleted file mode 100644 index b5a7ffa..0000000 --- a/net-guardia/src/detection/rule/bounded_pcre.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::ptr; - -use pcre2_sys::{ - PCRE2_DOTALL, PCRE2_ERROR_NOMATCH, PCRE2_MULTILINE, - pcre2_code_8, pcre2_code_free_8, pcre2_compile_8, - pcre2_match_8, pcre2_match_context_8, pcre2_match_context_create_8, - pcre2_match_context_free_8, pcre2_match_data_create_from_pattern_8, - pcre2_match_data_free_8, pcre2_set_depth_limit_8, pcre2_set_match_limit_8, -}; - -// Suricata production defaults (detect-pcre.h: SC_MATCH_LIMIT_DEFAULT / SC_MATCH_LIMIT_RECURSION_DEFAULT). -const MATCH_LIMIT: u32 = 3500; -const DEPTH_LIMIT: u32 = 1500; - -/// A compiled PCRE2 pattern with a pre-configured match context that enforces -/// hard backtracking limits (match_limit + depth_limit) on every call to is_match. -/// -/// This works around the pcre2 v0.2 high-level crate not exposing match context -/// configuration. Code and context are both read-only after construction, so the -/// type is Send + Sync. -pub struct BoundedPcre { - code: *mut pcre2_code_8, - match_ctx: *mut pcre2_match_context_8, - pub negated: bool, - pub buffer: u8, -} - -// Safety: pcre2_code_8 is read-only after compile; pcre2_match_context_8 is -// read-only after the limits are set. pcre2_match_8 only reads both. The -// per-call pcre2_match_data_8 is allocated and freed within each is_match call. -unsafe impl Send for BoundedPcre {} -unsafe impl Sync for BoundedPcre {} - -impl BoundedPcre { - /// Compile `pattern` with DOTALL + MULTILINE and attach a match context - /// capped at MATCH_LIMIT backtrack steps and DEPTH_LIMIT nesting depth. - /// Returns None if compilation fails (the caller should drop the signature). - pub fn new(pattern: &str, negated: bool, buffer: u8) -> Option { - let mut error_code: i32 = 0; - let mut error_offset: usize = 0; - - let code = unsafe { - pcre2_compile_8( - pattern.as_ptr(), - pattern.len(), - PCRE2_DOTALL | PCRE2_MULTILINE, - &mut error_code, - &mut error_offset, - ptr::null_mut(), - ) - }; - if code.is_null() { - return None; - } - - let match_ctx = unsafe { pcre2_match_context_create_8(ptr::null_mut()) }; - if match_ctx.is_null() { - unsafe { pcre2_code_free_8(code) }; - return None; - } - - unsafe { - pcre2_set_match_limit_8(match_ctx, MATCH_LIMIT); - pcre2_set_depth_limit_8(match_ctx, DEPTH_LIMIT); - } - - Some(Self { code, match_ctx, negated, buffer }) - } - - /// Run the pattern against `haystack`. - /// - /// - `Ok(true)` — pattern matched - /// - `Ok(false)` — no match (PCRE2_ERROR_NOMATCH) - /// - `Err(())` — PCRE2 error: limit exceeded, internal failure, etc. - /// - /// The caller is responsible for translating Err into the correct - /// fail-open / fail-closed behaviour for positive vs. negated patterns. - pub fn is_match(&self, haystack: &[u8]) -> Result { - let match_data = unsafe { - pcre2_match_data_create_from_pattern_8(self.code, ptr::null_mut()) - }; - if match_data.is_null() { - return Err(()); - } - - let rc = unsafe { - pcre2_match_8( - self.code, - haystack.as_ptr(), - haystack.len(), - 0, - 0, - match_data, - self.match_ctx, - ) - }; - - unsafe { pcre2_match_data_free_8(match_data) }; - - match rc { - r if r >= 0 => Ok(true), - PCRE2_ERROR_NOMATCH => Ok(false), - _ => Err(()), - } - } -} - -impl Drop for BoundedPcre { - fn drop(&mut self) { - unsafe { - pcre2_match_context_free_8(self.match_ctx); - pcre2_code_free_8(self.code); - } - } -} diff --git a/net-guardia/src/detection/rule/mod.rs b/net-guardia/src/detection/rule/mod.rs deleted file mode 100644 index a983b30..0000000 --- a/net-guardia/src/detection/rule/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod app_layer; -pub mod bounded_pcre; -pub mod rule_engine; -pub mod stream_reassembler; -pub mod suppress; \ No newline at end of file diff --git a/net-guardia/src/detection/rule/rule_engine.rs b/net-guardia/src/detection/rule/rule_engine.rs deleted file mode 100644 index 63277d7..0000000 --- a/net-guardia/src/detection/rule/rule_engine.rs +++ /dev/null @@ -1,816 +0,0 @@ -use std::collections::{HashSet, HashMap}; -use std::net::IpAddr; -use std::path::PathBuf; -use std::time::{Duration, Instant}; -use rusqlite::{Connection, params}; -use ipnet::IpNet; -use parking_lot::Mutex; -use vectorscan_rs::{BlockDatabase, Flag, Pattern, Scan}; - -use macros::log; -use crate::model::error::rule::RuleError; -use crate::model::rule_detection::{RuleMatch, RuleSig, ContentEntry, ParsedPacket, ByteOp, ThresholdInfo}; -use super::bounded_pcre::BoundedPcre; -use super::suppress::SuppressList; - -// ─── Threshold state ───────────────────────────────────────────────────────── - -#[derive(Hash, Eq, PartialEq)] -enum ThresholdKey { - BySrc(IpAddr), - ByDst(IpAddr), - ByBoth(IpAddr, IpAddr), - ByRule, -} - -struct ThresholdState { - hit_count: u32, - window_start: Instant, -} - -// ─── Extended RuleSig ──────────────────────────────────────────────────────── - -struct Sig { - base: RuleSig, - /// True when rule requires flow:established. - flow_established: bool, - /// 0=any 1=$HOME_NET 2=$EXTERNAL_NET - src_ip_flag: u8, - dst_ip_flag: u8, - /// Compiled PCRE patterns — verified after content chain passes. - pcres: Vec, - /// Flowbit operations: (op, name) - /// Mirrors Suricata DETECT_FLOWBITS_CMD_*: - /// op: 0=set 1=toggle 2=unset 3=isnotset 4=isset 5=noalert(internal) - flowbits: Vec<(u8, String)>, - /// Application-layer protocol filter (0 = any). - alproto: u8, - alproto_neg: bool, - /// Byte-level inspection ops evaluated after the content chain succeeds. - byte_ops: Vec, - /// Optional inline threshold (threshold: keyword). - threshold: Option, -} - -pub struct RuleEngine { - db: BlockDatabase, - sigs: Vec, - home_nets: Vec, - suppress: SuppressList, - threshold_states: Mutex>, -} - -impl RuleEngine { - pub fn new(home_net_strs: &[String]) -> Option { - let home_nets: Vec = home_net_strs - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - - let rules_db_path = PathBuf::from(env!("RULES_DB_PATH")); - let conn = Connection::open(rules_db_path.join("rules.db")).ok()?; - - let patterns = load_patterns(&conn)?; - let sigs = load_sigs(&conn)?; - - if patterns.is_empty() { - return None; - } - - let db = match BlockDatabase::new(patterns) { - Ok(db) => db, - Err(e) => { - log!(RuleError::BlockDatabaseFailed(e.to_string())); - return None; - } - }; - - let suppress = SuppressList::load_from_db(&conn); - - Some(Self { db, sigs, home_nets, suppress, threshold_states: Mutex::new(HashMap::new()) }) - } - - pub fn pattern_count(&self) -> usize { - self.sigs.len() - } - - fn is_home(&self, ip: &IpAddr) -> bool { - self.home_nets.iter().any(|n| n.contains(ip)) - } - - /// Scan a parsed application-layer packet. - /// - /// Returns an empty list immediately for TLS Application Data (encrypted). - /// Filters apply in order: established, direction, port, IP zone, flowbit - /// preconditions, content chain, PCRE. - pub fn scan( - &self, - pkt: &ParsedPacket, - is_ingress: bool, - dst_port: u16, - is_established: bool, - flowbits: &HashSet, - src_ip: IpAddr, - dst_ip: IpAddr, - ) -> Vec { - if pkt.tls_app_data { - return Vec::new(); - } - if pkt.raw.is_empty() { - return Vec::new(); - } - - let mut scanner = match self.db.create_scanner() { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - - let mut candidates: Vec = Vec::new(); - let _ = scanner.scan(&pkt.raw, |id, _from, _to, _flags| { - candidates.push(id); - Scan::Continue - }); - candidates.sort_unstable(); - candidates.dedup(); - - let src_home = self.is_home(&src_ip); - let dst_home = self.is_home(&dst_ip); - - let mut results = Vec::new(); - - for sig_idx in candidates { - let Some(sig) = self.sigs.get(sig_idx as usize) else { - continue; - }; - - // flow:established filter - if sig.flow_established && !is_established { - continue; - } - - // Direction filter - let dir_ok = match sig.base.flow_dir { - 1 => is_ingress, - 2 => !is_ingress, - _ => true, - }; - if !dir_ok { - continue; - } - - // Port filter - if !sig.base.dports.is_empty() && !sig.base.dports.contains(&dst_port) { - continue; - } - - // IP zone filter - if !check_ip_flag(sig.src_ip_flag, src_home) { - continue; - } - if !check_ip_flag(sig.dst_ip_flag, dst_home) { - continue; - } - - // app-layer-protocol: filter - if sig.alproto != 0 { - let matches = pkt.proto as u8 == sig.alproto; - if sig.alproto_neg == matches { continue; } - } - - // Flowbit preconditions (isset / isnotset) - if !check_flowbit_conditions(&sig.flowbits, flowbits) { - continue; - } - - // Content chain verification - let last_match_end = match verify_chain(&sig.base.chain, pkt) { - Some(end) => end, - None => continue, - }; - - // Byte-level ops (byte_test / byte_jump / byte_extract / isdataat) - if !eval_byte_ops(&sig.byte_ops, pkt, last_match_end) { - continue; - } - - // PCRE verification - if !verify_pcres(&sig.pcres, pkt) { - continue; - } - - // Collect mutation ops (set / toggle / unset) - let flowbit_ops: Vec<(u8, String)> = sig.flowbits.iter() - .filter(|(op, _)| matches!(op, 0 | 1 | 2)) - .cloned() - .collect(); - - let is_noalert = sig.flowbits.iter().any(|(op, _)| *op == 5); - - if !is_noalert && !self.should_alert(sig_idx, &sig.threshold, src_ip, dst_ip) { - continue; - } - - results.push(RuleMatch { - sid: sig.base.sid, - msg: sig.base.msg.clone(), - src: String::new(), - dst: String::new(), - flowbit_ops, - is_noalert, - }); - } - - results.retain(|m| !self.suppress.is_suppressed(m.sid, src_ip, dst_ip)); - - results - } - - /// Apply Suricata-compatible threshold logic (detect-threshold.c). - /// - /// Returns true when the alert should fire: - /// threshold — fire on every Nth match within the window - /// limit — fire at most N times per window - /// both — fire on every Nth match, capped at N per window - /// - /// noalert rules bypass this check — their match is always kept for - /// flowbit mutation regardless of threshold state. - fn should_alert( - &self, - sig_idx: u32, - threshold: &Option, - src: IpAddr, - dst: IpAddr, - ) -> bool { - let thresh = match threshold { - Some(t) => t, - None => return true, - }; - - let key = match thresh.track { - 1 => ThresholdKey::ByDst(dst), - 2 => ThresholdKey::BySrc(src), - 5 => ThresholdKey::ByBoth(src, dst), - 6 => ThresholdKey::ByBoth(src, dst), // by_flow: approximate as (src, dst) pair - _ => ThresholdKey::ByRule, - }; - - let mut states = self.threshold_states.lock(); - let state = states.entry((sig_idx, key)).or_insert_with(|| ThresholdState { - hit_count: 0, - window_start: Instant::now(), - }); - - let now = Instant::now(); - // Mirrors Suricata SCTIME_CMP_LTE(p->ts, te->tv1 + td->seconds): inclusive upper bound. - let within_window = - now.duration_since(state.window_start) <= Duration::from_secs(thresh.seconds as u64); - - if !within_window { - // Window expired — mirrors Suricata ThresholdCheckUpdate expiry path. - // Reset tv1 and current_count, then return type-specific value. - state.window_start = now; - state.hit_count = 1; - return match thresh.kind { - 3 => false, // threshold: no alert on first hit of new window - 1 => true, // limit: always alert after reset - 2 => thresh.count == 1, // both: alert only if count == 1 - _ => true, - }; - } - - // Within window — mirrors Suricata's within-window branch. - state.hit_count += 1; - match thresh.kind { - 3 => { - // TYPE_THRESHOLD: alert on every Nth hit; reset counter after firing. - if state.hit_count >= thresh.count { - state.hit_count = 0; - true - } else { - false - } - } - 1 => { - // TYPE_LIMIT: alert on the first N hits; silence thereafter. - // Mirrors: current_count <= td->count -> ret=1. - state.hit_count <= thresh.count - } - 2 => { - // TYPE_BOTH: alert exactly on the Nth hit; silence for hits N+1, N+2, ... - // Mirrors: current_count == td->count -> ret=1, > td->count -> ret=2 (silent). - state.hit_count == thresh.count - } - _ => true, - } - } -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/// 0=any 1=must be home 2=must be external -fn check_ip_flag(flag: u8, is_home: bool) -> bool { - match flag { - 1 => is_home, - 2 => !is_home, - _ => true, - } -} - -/// Return false if any isset/isnotset condition is not satisfied. -fn check_flowbit_conditions(sig_flowbits: &[(u8, String)], flow: &HashSet) -> bool { - for (op, name) in sig_flowbits { - match op { - 4 => if !flow.contains(name.as_str()) { return false; } // isset - 3 => if flow.contains(name.as_str()) { return false; } // isnotset - _ => {} - } - } - true -} - -/// Return false if any PCRE pattern fails (or negated pattern matches). -/// -/// Any PCRE2 error (match_limit / depth_limit exceeded, internal failure) causes -/// the entire signature to be suppressed regardless of whether the pattern is -/// negated. This is fail-closed: a limit breach on a negated pattern would -/// otherwise silently pass, producing false positives. -fn verify_pcres(pcres: &[BoundedPcre], pkt: &ParsedPacket) -> bool { - for p in pcres { - let buf = pkt.buffer(p.buffer); - let hit = match p.is_match(buf) { - Ok(h) => h, - Err(()) => return false, - }; - if p.negated && hit { return false; } - if !p.negated && !hit { return false; } - } - true -} - -// ─── Byte-level inspection ──────────────────────────────────────────────────── - -/// Evaluate all byte ops in sequence. Returns false if any check fails. -/// -/// `cursor` starts at `last_match_end` (end of final content match in the chain). -/// byte_jump / byte_extract advance the cursor; byte_test / isdataat do not. -fn eval_byte_ops(byte_ops: &[ByteOp], pkt: &ParsedPacket, last_match_end: usize) -> bool { - let mut vars: [u64; 8] = [0; 8]; - let mut cursor = last_match_end; - - for op in byte_ops { - let buf = pkt.buffer(op.buffer); - let base: usize = if op.relative { cursor } else { 0 }; - let abs_pos = (base as i64).saturating_add(op.offset as i64) as usize; - - match op.kind { - 0 => { // byte_test - if !eval_byte_test(op, buf, abs_pos, &vars) { - return false; - } - } - 1 => { // byte_jump - match eval_byte_jump(op, buf, abs_pos) { - Some(new_pos) => cursor = new_pos, - None => return false, - } - } - 2 => { // byte_extract - match read_bytes(op, buf, abs_pos) { - Some(val) => { - let idx = op.be_var_idx as usize; - if idx < 8 { - let mult = if op.be_multiplier > 0 { op.be_multiplier as u64 } else { 1 }; - vars[idx] = val.wrapping_mul(mult); - } - cursor = abs_pos + op.count as usize; - } - None => return false, - } - } - 3 => { // isdataat: assert data exists (or doesn't) at abs_pos - let exists = abs_pos < buf.len(); - // bt_negated=true means "!isdataat" → assert no data - if op.bt_negated == exists { - return false; - } - } - _ => {} - } - } - true -} - -fn eval_byte_test(op: &ByteOp, buf: &[u8], pos: usize, vars: &[u64; 8]) -> bool { - let raw = match read_bytes(op, buf, pos) { - Some(v) => v, - None => return false, - }; - - let val = if op.bt_bitmask != 0 { - raw & op.bt_bitmask as u64 - } else { - raw - }; - - let rhs = if op.bt_uses_var { - let idx = op.bt_value as usize; - if idx < 8 { vars[idx] } else { return false; } - } else { - op.bt_value - }; - - let result = match op.bt_op { - 0 => val < rhs, - 1 => val > rhs, - 2 => val == rhs, - 3 => val != rhs, - 4 => val <= rhs, - 5 => val >= rhs, - 6 => (val & rhs) != 0, - 7 => (val ^ rhs) != 0, - _ => false, - }; - - if op.bt_negated { !result } else { result } -} - -fn eval_byte_jump(op: &ByteOp, buf: &[u8], pos: usize) -> Option { - let raw = read_bytes(op, buf, pos)?; - let jump = (raw as usize).saturating_mul(op.bj_multiplier as usize); - - let base = if op.bj_from_beginning { - 0usize - } else { - pos.saturating_add(op.count as usize) - }; - - let mut new_pos = base.saturating_add(jump); - - if op.bj_align { - new_pos = (new_pos + 3) & !3; - } - - let post = op.bj_post_offset; - new_pos = if post >= 0 { - new_pos.saturating_add(post as usize) - } else { - new_pos.saturating_sub((-post) as usize) - }; - - Some(new_pos) -} - -/// Read `op.count` bytes from `buf` at `pos` and return as u64. -/// Handles big-endian, little-endian, and ASCII string modes. -fn read_bytes(op: &ByteOp, buf: &[u8], pos: usize) -> Option { - let n = op.count as usize; - if n == 0 || n > 8 || pos + n > buf.len() { - return None; - } - let slice = &buf[pos..pos + n]; - - if op.string_mode { - let s = std::str::from_utf8(slice).ok()?.trim(); - let base = match op.string_base { 1 => 16, 2 => 8, _ => 10 }; - u64::from_str_radix(s, base).ok() - } else if op.endian_little { - let mut val: u64 = 0; - for (i, &b) in slice.iter().enumerate() { - val |= (b as u64) << (i * 8); - } - Some(val) - } else { - let mut val: u64 = 0; - for &b in slice { - val = (val << 8) | (b as u64); - } - Some(val) - } -} - -// ─── SQLite loaders ─────────────────────────────────────────────────────────── - -fn load_patterns(conn: &Connection) -> Option> { - let mut stmt = conn - .prepare("SELECT id, expression, nocase FROM patterns ORDER BY id") - .ok()?; - let patterns: Vec = stmt - .query_map([], |row| { - let id: i64 = row.get(0)?; - let expression: Vec = row.get(1)?; - let nocase: i64 = row.get(2)?; - let flags = if nocase != 0 { Flag::CASELESS } else { Flag::default() }; - Ok(Pattern::new(expression, flags, Some(id as u32))) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - Some(patterns) -} - -fn load_sigs(conn: &Connection) -> Option> { - let mut stmt = conn - .prepare( - "SELECT id, sid, flow_dir, flow_established, src_ip_flag, dst_ip_flag, msg, \ - alproto, alproto_neg \ - FROM signatures ORDER BY id" - ) - .ok()?; - - let sig_rows: Vec<(i64, u32, u8, bool, u8, u8, String, u8, bool)> = stmt - .query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, u32>(1)?, - row.get::<_, u8>(2)?, - row.get::<_, i64>(3)? != 0, - row.get::<_, u8>(4)?, - row.get::<_, u8>(5)?, - row.get::<_, String>(6)?, - row.get::<_, u8>(7)?, - row.get::<_, i64>(8)? != 0, - )) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - let mut sigs = Vec::with_capacity(sig_rows.len()); - - for (sig_id, sid, flow_dir, flow_established, src_ip_flag, dst_ip_flag, msg, alproto, alproto_neg) in sig_rows { - // Destination ports - let mut port_stmt = conn - .prepare("SELECT dport FROM signature_ports WHERE sig_id = ?1") - .ok()?; - let dports: Vec = port_stmt - .query_map(params![sig_id], |row| row.get::<_, u16>(0)) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - // Content chain - let mut entry_stmt = conn - .prepare( - "SELECT pattern, nocase, negated, \ - has_distance, has_within, has_offset, has_depth, \ - distance, within, off, depth, buffer \ - FROM content_entries WHERE sig_id = ?1 ORDER BY entry_order", - ) - .ok()?; - let chain: Vec = entry_stmt - .query_map(params![sig_id], |row| { - Ok(ContentEntry { - pattern: row.get(0)?, - nocase: row.get::<_, i64>(1)? != 0, - negated: row.get::<_, i64>(2)? != 0, - has_distance: row.get::<_, i64>(3)? != 0, - has_within: row.get::<_, i64>(4)? != 0, - has_offset: row.get::<_, i64>(5)? != 0, - has_depth: row.get::<_, i64>(6)? != 0, - distance: row.get::<_, i32>(7)?, - within: row.get::<_, i32>(8)?, - offset: row.get::<_, u16>(9)?, - depth: row.get::<_, u16>(10)?, - buffer: row.get::<_, u8>(11)?, - }) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - // PCRE entries — compile at load time. - // If any pattern fails to compile the whole signature is dropped (fail-closed): - // an uncompilable PCRE silently removed would produce false positives. - let mut pcre_stmt = conn - .prepare( - "SELECT pattern, negated, buffer FROM pcre_entries \ - WHERE sig_id = ?1 ORDER BY entry_order", - ) - .ok()?; - let raw_pcres: Vec<(String, bool, u8)> = pcre_stmt - .query_map(params![sig_id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i64>(1)? != 0, - row.get::<_, u8>(2)?, - )) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - let mut pcres: Vec = Vec::with_capacity(raw_pcres.len()); - let mut pcre_ok = true; - for (pattern, negated, buffer) in raw_pcres { - match BoundedPcre::new(&pattern, negated, buffer) { - Some(bp) => pcres.push(bp), - None => { pcre_ok = false; break; } - } - } - if !pcre_ok { - // Drop this sig entirely — better to miss a detection than fire falsely. - continue; - } - - // Flowbit entries - let mut fb_stmt = conn - .prepare("SELECT op, name FROM flowbit_entries WHERE sig_id = ?1") - .ok()?; - let flowbits: Vec<(u8, String)> = fb_stmt - .query_map(params![sig_id], |row| { - Ok((row.get::<_, u8>(0)?, row.get::<_, String>(1)?)) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - // Byte ops - let mut bo_stmt = conn - .prepare( - "SELECT kind, count, offset, relative, endian_little, \ - string_mode, string_base, buffer, \ - bt_op, bt_value, bt_uses_var, bt_bitmask, bt_negated, \ - bj_align, bj_multiplier, bj_from_beg, bj_post_offset, \ - be_var_idx, be_multiplier \ - FROM byte_ops WHERE sig_id = ?1 ORDER BY op_order", - ) - .ok()?; - let byte_ops: Vec = bo_stmt - .query_map(params![sig_id], |row| { - Ok(ByteOp { - kind: row.get::<_, u8>(0)?, - count: row.get::<_, u8>(1)?, - offset: row.get::<_, i32>(2)?, - relative: row.get::<_, i64>(3)? != 0, - endian_little: row.get::<_, i64>(4)? != 0, - string_mode: row.get::<_, i64>(5)? != 0, - string_base: row.get::<_, u8>(6)?, - buffer: row.get::<_, u8>(7)?, - bt_op: row.get::<_, u8>(8)?, - bt_value: row.get::<_, i64>(9)? as u64, - bt_uses_var: row.get::<_, i64>(10)? != 0, - bt_bitmask: row.get::<_, i64>(11)? as u64, - bt_negated: row.get::<_, i64>(12)? != 0, - bj_align: row.get::<_, i64>(13)? != 0, - bj_multiplier: row.get::<_, i64>(14)? as u32, - bj_from_beginning: row.get::<_, i64>(15)? != 0, - bj_post_offset: row.get::<_, i32>(16)?, - be_var_idx: row.get::<_, u8>(17)?, - be_multiplier: row.get::<_, i64>(18)? as u32, - }) - }) - .ok()? - .filter_map(|r| r.ok()) - .collect(); - - let mut th_stmt = conn - .prepare( - "SELECT kind, track, count, seconds FROM threshold_entries WHERE sig_id = ?1" - ) - .ok()?; - let threshold = th_stmt.query_row(params![sig_id], |row| { - Ok(ThresholdInfo { - kind: row.get::<_, u8>(0)?, - track: row.get::<_, u8>(1)?, - count: row.get::<_, u32>(2)?, - seconds: row.get::<_, u32>(3)?, - }) - }).ok(); - - sigs.push(Sig { - base: RuleSig { sid, flow_dir, dports, msg, chain }, - flow_established, - src_ip_flag, - dst_ip_flag, - pcres, - flowbits, - alproto, - alproto_neg, - byte_ops, - threshold, - }); - } - - Some(sigs) -} - -// ─── Chain verification ─────────────────────────────────────────────────────── - -/// Returns Some(last_match_end) on success, None on failure. -/// last_match_end is the byte offset past the final content match — used as -/// the base position for subsequent byte_test / byte_jump / isdataat ops. -fn verify_chain(chain: &[ContentEntry], pkt: &ParsedPacket) -> Option { - verify_from(chain, pkt, 0, 0) -} - -fn verify_from(chain: &[ContentEntry], pkt: &ParsedPacket, idx: usize, prev_end: usize) -> Option { - if idx >= chain.len() { - return Some(prev_end); - } - - let entry = &chain[idx]; - let payload = pkt.buffer(entry.buffer); - - if payload.is_empty() && !entry.negated { - return None; - } - - let buf_len = payload.len(); - let (ws, we) = compute_window(entry, prev_end, buf_len); - - if ws >= we || ws >= buf_len { - return if entry.negated { - verify_from(chain, pkt, idx + 1, prev_end) - } else { - None - }; - } - - let window = &payload[ws..we]; - - if entry.negated { - if find_in(window, &entry.pattern, entry.nocase).is_some() { - return None; - } - return verify_from(chain, pkt, idx + 1, prev_end); - } - - let next_is_relative = idx + 1 < chain.len() && { - let next = &chain[idx + 1]; - next.has_distance || next.has_within - }; - - let mut search_from = 0usize; - loop { - let sub = &window[search_from..]; - let Some(rel_pos) = find_in(sub, &entry.pattern, entry.nocase) else { - break; - }; - - let match_end = ws + search_from + rel_pos + entry.pattern.len(); - - if let Some(end) = verify_from(chain, pkt, idx + 1, match_end) { - return Some(end); - } - - if !next_is_relative { - break; - } - - search_from += rel_pos + 1; - if search_from >= window.len() { - break; - } - } - - None -} - -fn compute_window(entry: &ContentEntry, prev_end: usize, buf_len: usize) -> (usize, usize) { - let (mut start, mut end) = if entry.has_distance || entry.has_within { - let dist = entry.distance; - let s = if dist >= 0 { - prev_end.saturating_add(dist as usize) - } else { - prev_end.saturating_sub((-dist) as usize) - }; - let e = if entry.has_within { - let dist_non_neg = dist.max(0) as usize; - prev_end.saturating_add(dist_non_neg).saturating_add(entry.within as usize) - } else { - buf_len - }; - (s, e) - } else { - let s = if entry.has_offset { entry.offset as usize } else { 0 }; - let e = if entry.has_depth { entry.depth as usize } else { buf_len }; - (s, e) - }; - - if entry.has_offset { - start = start.max(entry.offset as usize); - } - if entry.has_depth { - end = end.min(entry.depth as usize); - } - - (start, end.min(buf_len)) -} - -fn find_in(haystack: &[u8], needle: &[u8], nocase: bool) -> Option { - if needle.is_empty() { - return Some(0); - } - if haystack.len() < needle.len() { - return None; - } - - if nocase { - let needle_lc: Vec = needle.iter().map(|b| b.to_ascii_lowercase()).collect(); - haystack - .windows(needle.len()) - .position(|w| w.iter().zip(&needle_lc).all(|(a, b)| a.to_ascii_lowercase() == *b)) - } else { - haystack.windows(needle.len()).position(|w| w == needle) - } -} \ No newline at end of file diff --git a/net-guardia/src/detection/rule/stream_reassembler.rs b/net-guardia/src/detection/rule/stream_reassembler.rs deleted file mode 100644 index 6ae1b72..0000000 --- a/net-guardia/src/detection/rule/stream_reassembler.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; -use std::ffi::c_void; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::rc::Rc; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use protolens::{Prolens, TransProto}; - -use super::app_layer; -use super::rule_engine::RuleEngine; -use crate::model::error::rule::RuleError; -use crate::model::rule_detection::{FlowKey, FlowProto, FlowState, RawPkt, RuleMatch}; - -/// Shared per-flow context surfaced to protolens callbacks via Rc>. -/// Populated in process_tcp() before each run_task() call. -struct ScanCtx { - sport: u16, - dport: u16, - proto: u8, // FlowProto as u8 - established: bool, - flowbits: HashSet, - sip: IpAddr, - dip: IpAddr, -} - -impl Default for ScanCtx { - fn default() -> Self { - Self { - sport: 0, - dport: 0, - proto: 0, - established: false, - flowbits: HashSet::new(), - sip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), - dip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), - } - } -} - -/// Per-thread TCP stream reassembler backed by protolens. -/// Not Send (Prolens uses Rc/RefCell) — must be created inside the XSK thread. -pub struct StreamReassembler { - engine: Prolens, - flows: HashMap, - rule_engine: Arc, - pending: Rc>>, - ctx: Rc>, - - /// (flow_key, sid) -> cumulative match count for min_matches threshold - flow_sid_counts: HashMap<(FlowKey, u32), u32>, - min_matches: u32, - is_ingress: bool, -} - -impl StreamReassembler { - pub fn new(rule_engine: Arc, min_matches: u32, is_ingress: bool) -> Self { - let pending = Rc::new(RefCell::new(Vec::::new())); - let ctx = Rc::new(RefCell::new(ScanCtx::default())); - - let p_c2s = Rc::clone(&pending); - let re_c2s = Arc::clone(&rule_engine); - let cx_c2s = Rc::clone(&ctx); - - let p_s2c = Rc::clone(&pending); - let re_s2c = Arc::clone(&rule_engine); - let cx_s2c = Rc::clone(&ctx); - - let mut engine = Prolens::::default(); - - engine.set_cb_task_c2s(move |data: &[u8], _seq: u32, _ctx: *const c_void| { - let c = cx_c2s.borrow(); - if c.proto == FlowProto::Tls as u8 { - return; - } - let pkt = app_layer::detect_and_parse(data, c.sport, c.dport); - if pkt.is_tls { - drop(c); - cx_c2s.borrow_mut().proto = FlowProto::Tls as u8; - return; - } - let matches = re_c2s.scan( - &pkt, true, c.dport, - c.established, &c.flowbits, - c.sip, c.dip, - ); - drop(c); - p_c2s.borrow_mut().extend(matches); - }); - - engine.set_cb_task_s2c(move |data: &[u8], _seq: u32, _ctx: *const c_void| { - let c = cx_s2c.borrow(); - if c.proto == FlowProto::Tls as u8 { - return; - } - let pkt = app_layer::detect_and_parse(data, c.sport, c.dport); - if pkt.is_tls { - drop(c); - cx_s2c.borrow_mut().proto = FlowProto::Tls as u8; - return; - } - let matches = re_s2c.scan( - &pkt, false, c.dport, - c.established, &c.flowbits, - c.sip, c.dip, - ); - drop(c); - p_s2c.borrow_mut().extend(matches); - }); - - Self { - engine, - flows: HashMap::new(), - rule_engine, - pending, - ctx, - flow_sid_counts: HashMap::new(), - min_matches, - is_ingress, - } - } - - /// Feed a raw Ethernet frame. Returns alerts that have crossed min_matches threshold. - pub fn process(&mut self, packet_data: &[u8]) -> Result, RuleError> { - let Some(pkt) = parse_raw(packet_data) else { - return Ok(Vec::new()); - }; - - if is_multicast_or_broadcast(&pkt.dip) { - return Ok(Vec::new()); - } - - let src = format!("{}:{}", pkt.sip, pkt.sport); - let dst = format!("{}:{}", pkt.dip, pkt.dport); - let flow_key = FlowKey { - sip: pkt.sip, - sport: pkt.sport, - dip: pkt.dip, - dport: pkt.dport, - }; - - let raw_matches = if matches!(pkt.trans_proto, TransProto::Udp) { - let parsed = app_layer::detect_and_parse(&pkt.payload, pkt.sport, pkt.dport); - self.rule_engine.scan( - &parsed, self.is_ingress, pkt.dport, - false, - &HashSet::new(), - pkt.sip, pkt.dip, - ) - } else { - self.process_tcp(pkt, &flow_key)? - }; - - Ok(self.apply_threshold(raw_matches, &flow_key, src, dst)) - } - - fn process_tcp(&mut self, pkt: RawPkt, key: &FlowKey) -> Result, RuleError> { - let rev = key.reversed(); - let is_syn = pkt.syn; - let is_rst = pkt.rst; - let is_fin = pkt.fin; - - let actual_key = if self.flows.contains_key(key) { - key.clone() - } else if self.flows.contains_key(&rev) { - rev - } else { - let task = self.engine.new_task(TransProto::Tcp); - self.flows.insert( - key.clone(), - FlowState { - task, - last_seen: Instant::now(), - fin_count: 0, - rst_seen: false, - detected_proto: FlowProto::Unknown, - established: false, - flowbits: HashSet::new(), - }, - ); - key.clone() - }; - - let Some(state) = self.flows.get_mut(&actual_key) else { - return Err(RuleError::FlowStateMissing); - }; - state.last_seen = Instant::now(); - if is_rst { state.rst_seen = true; } - if is_fin { state.fin_count += 1; } - - // Mark established on first non-SYN packet — handshake complete. - if !is_syn && !state.established { - state.established = true; - } - - // Populate the shared scan context for callbacks. - { - let mut c = self.ctx.borrow_mut(); - c.sport = actual_key.sport; - c.dport = actual_key.dport; - c.proto = state.detected_proto as u8; - c.established = state.established; - c.flowbits = state.flowbits.clone(); - c.sip = actual_key.sip; - c.dip = actual_key.dip; - } - - self.engine.run_task(&mut state.task, pkt); - - // Persist protocol detection back into FlowState. - let proto_byte = self.ctx.borrow().proto; - if let Some(s) = self.flows.get_mut(&actual_key) { - if s.detected_proto == FlowProto::Unknown && proto_byte != 0 { - s.detected_proto = if proto_byte == FlowProto::Tls as u8 { - FlowProto::Tls - } else { - FlowProto::Unknown - }; - } - } - - let all_matches: Vec = self.pending.borrow_mut().drain(..).collect(); - - // Apply flowbit mutations (set/unset/toggle) to FlowState. - if let Some(s) = self.flows.get_mut(&actual_key) { - for m in &all_matches { - apply_flowbit_mutations(&m.flowbit_ops, &mut s.flowbits); - } - } - - // Strip noalert matches before threshold counting. - let alert_matches: Vec = all_matches - .into_iter() - .filter(|m| !m.is_noalert) - .collect(); - - if self.flows.get(&actual_key).map(|s| s.rst_seen || s.fin_count >= 2).unwrap_or(false) { - self.flows.remove(&actual_key); - } - - Ok(alert_matches) - } - - fn apply_threshold( - &mut self, - raw: Vec, - flow_key: &FlowKey, - src: String, - dst: String, - ) -> Vec { - let threshold = self.min_matches.max(1); - let mut alerts = Vec::new(); - for mut m in raw { - let count = self.flow_sid_counts.entry((flow_key.clone(), m.sid)).or_insert(0); - *count += 1; - if *count == threshold { - m.src = src.clone(); - m.dst = dst.clone(); - alerts.push(m); - } - } - alerts - } - - /// Drop flows idle longer than `max_idle`. Call periodically from the XSK thread. - pub fn cleanup_stale(&mut self, max_idle: Duration) { - let now = Instant::now(); - self.flows.retain(|_, s| now.duration_since(s.last_seen) < max_idle); - self.flow_sid_counts.retain(|(k, _), _| self.flows.contains_key(k)); - } - - pub fn flow_count(&self) -> usize { - self.flows.len() - } - - pub fn match_counts(&self) -> Vec { - self.flow_sid_counts.values().cloned().collect() - } - - pub fn min_matches(&self) -> u32 { - self.min_matches - } -} - -// ─── Flowbit mutation helper ────────────────────────────────────────────────── - -fn apply_flowbit_mutations(ops: &[(u8, String)], bits: &mut HashSet) { - for (op, name) in ops { - match op { - 0 => { bits.insert(name.clone()); } // set - 1 => { // toggle - if bits.contains(name.as_str()) { - bits.remove(name.as_str()); - } else { - bits.insert(name.clone()); - } - } - 2 => { bits.remove(name.as_str()); } // unset - _ => {} - } - } -} - -// ─── Raw frame parser ───────────────────────────────────────────────────────── - -fn parse_raw(data: &[u8]) -> Option { - if data.len() < 14 { - return None; - } - match u16::from_be_bytes([data[12], data[13]]) { - 0x0800 => parse_ipv4(data), - 0x86DD => parse_ipv6(data), - _ => None, - } -} - -fn parse_ipv4(data: &[u8]) -> Option { - if data.len() < 34 { - return None; - } - let ip = &data[14..]; - let proto = ip[9]; - if proto != 6 && proto != 17 { - return None; - } - - let sip = IpAddr::V4(Ipv4Addr::from(u32::from_be_bytes([ip[12], ip[13], ip[14], ip[15]]))); - let dip = IpAddr::V4(Ipv4Addr::from(u32::from_be_bytes([ip[16], ip[17], ip[18], ip[19]]))); - let ihl = (ip[0] & 0x0F) as usize * 4; - let total_len = u16::from_be_bytes([ip[2], ip[3]]) as usize; - - if data.len() < 14 + ihl + 4 { - return None; - } - let tp = &ip[ihl..]; - let sport = u16::from_be_bytes([tp[0], tp[1]]); - let dport = u16::from_be_bytes([tp[2], tp[3]]); - - if proto == 6 { - if data.len() < 14 + ihl + 20 { - return None; - } - let seq = u32::from_be_bytes([tp[4], tp[5], tp[6], tp[7]]); - let hlen = (tp[12] >> 4) as usize * 4; - let flags = tp[13]; - let payload_start = 14 + ihl + hlen; - let payload_end = (14 + total_len).min(data.len()); - Some(RawPkt { - trans_proto: TransProto::Tcp, - sip, dip, sport, dport, seq, - syn: flags & 0x02 != 0, - fin: flags & 0x01 != 0, - rst: flags & 0x04 != 0, - payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(), - }) - } else { - let payload_start = 14 + ihl + 8; - let payload_end = (14 + total_len).min(data.len()); - Some(RawPkt { - trans_proto: TransProto::Udp, - sip, dip, sport, dport, seq: 0, - syn: false, fin: false, rst: false, - payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(), - }) - } -} - -fn parse_ipv6(data: &[u8]) -> Option { - if data.len() < 54 { - return None; - } - let ip = &data[14..]; - let proto = ip[6]; - if proto != 6 && proto != 17 { - return None; - } - - let mut s = [0u8; 16]; - s.copy_from_slice(&ip[8..24]); - let mut d = [0u8; 16]; - d.copy_from_slice(&ip[24..40]); - let sip = IpAddr::V6(Ipv6Addr::from(s)); - let dip = IpAddr::V6(Ipv6Addr::from(d)); - - let payload_len = u16::from_be_bytes([ip[4], ip[5]]) as usize; - let tp = &ip[40..]; - let sport = u16::from_be_bytes([tp[0], tp[1]]); - let dport = u16::from_be_bytes([tp[2], tp[3]]); - - if proto == 6 { - if data.len() < 54 + 20 { - return None; - } - let seq = u32::from_be_bytes([tp[4], tp[5], tp[6], tp[7]]); - let hlen = (tp[12] >> 4) as usize * 4; - let flags = tp[13]; - let payload_start = 14 + 40 + hlen; - let payload_end = (14 + 40 + payload_len).min(data.len()); - Some(RawPkt { - trans_proto: TransProto::Tcp, - sip, dip, sport, dport, seq, - syn: flags & 0x02 != 0, - fin: flags & 0x01 != 0, - rst: flags & 0x04 != 0, - payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(), - }) - } else { - let payload_start = 14 + 40 + 8; - let payload_end = (14 + 40 + payload_len).min(data.len()); - Some(RawPkt { - trans_proto: TransProto::Udp, - sip, dip, sport, dport, seq: 0, - syn: false, fin: false, rst: false, - payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(), - }) - } -} - -/// True for IPv4 multicast (224.0.0.0/4), limited broadcast (255.255.255.255), -/// and IPv6 multicast (ff00::/8). -fn is_multicast_or_broadcast(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_multicast() || v4.is_broadcast(), - IpAddr::V6(v6) => v6.is_multicast(), - } -} diff --git a/net-guardia/src/detection/rule/suppress.rs b/net-guardia/src/detection/rule/suppress.rs deleted file mode 100644 index fe6b670..0000000 --- a/net-guardia/src/detection/rule/suppress.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::net::IpAddr; -use ipnet::IpNet; -use rusqlite::Connection; - -pub struct SuppressEntry { - pub sid: u32, - /// 0=any(TRACK_RULE) 1=TRACK_SRC 2=TRACK_DST 4=TRACK_EITHER - pub track: u8, - pub nets: Vec, -} - -pub struct SuppressList { - entries: Vec, -} - -impl SuppressList { - pub fn load_from_db(conn: &Connection) -> Self { - let mut stmt = match conn.prepare("SELECT sid, track, ip_net FROM suppress") { - Ok(s) => s, - Err(_) => return Self { entries: Vec::new() }, - }; - let mut entries = Vec::new(); - let rows = stmt.query_map([], |row| { - let sid: u32 = row.get(0)?; - let track: u8 = row.get(1)?; - let ip_net: String = row.get(2)?; - Ok((sid, track, ip_net)) - }); - if let Ok(iter) = rows { - for result in iter.flatten() { - let (sid, track, ip_net_str) = result; - let mut nets = Vec::new(); - if !ip_net_str.is_empty() { - if let Ok(net) = ip_net_str.parse::() { - nets.push(net); - } else if let Ok(ip) = ip_net_str.parse::() { - let prefix = if ip.is_ipv4() { 32 } else { 128 }; - if let Ok(net) = format!("{}/{}", ip, prefix).parse::() { - nets.push(net); - } - } - } - entries.push(SuppressEntry { sid, track, nets }); - } - } - Self { entries } - } - - /// Mirrors Suricata threshold.conf suppress matching logic. - /// - /// track 0 (no track/ip): suppress all instances (TRACK_RULE). - /// track 1 (TRACK_SRC): suppress when src_ip is in nets. - /// track 2 (TRACK_DST): suppress when dst_ip is in nets. - /// track 4 (TRACK_EITHER): suppress when either src or dst is in nets. - pub fn is_suppressed(&self, sid: u32, src: IpAddr, dst: IpAddr) -> bool { - for e in &self.entries { - if e.sid != sid { continue; } - let hit = match e.track { - 0 => true, - 1 => e.nets.is_empty() || e.nets.iter().any(|n| n.contains(&src)), - 2 => e.nets.is_empty() || e.nets.iter().any(|n| n.contains(&dst)), - 4 => e.nets.is_empty() - || e.nets.iter().any(|n| n.contains(&src)) - || e.nets.iter().any(|n| n.contains(&dst)), - _ => false, - }; - if hit { return true; } - } - false - } -} diff --git a/net-guardia/src/detection/suricata/engine.rs b/net-guardia/src/detection/suricata/engine.rs new file mode 100644 index 0000000..8708905 --- /dev/null +++ b/net-guardia/src/detection/suricata/engine.rs @@ -0,0 +1,159 @@ +use std::mem; +use std::path::PathBuf; +use std::process::{Child, Command}; +use std::sync::Arc; +use std::thread; + +use crossbeam::channel::{bounded, Sender}; +use macros::log; + +use crate::detection::fusion::FusionEngine; +use crate::model::error::suricata::SuricataError; +use crate::model::log::suricata::SuricataLog; + +use super::output; + +const MIRROR_IFACE: &str = "ng-mirror"; +const MIRROR_PEER: &str = "ng-mirror-peer"; + +/* Channel capacity matches the old libsuricata engine. */ +const CHANNEL_CAP: usize = 4096; + +pub struct SuricataEngine { + tx: Sender>, + child: std::sync::Mutex, +} + +impl SuricataEngine { + pub fn start( + yaml_path: PathBuf, + eve_socket: PathBuf, + fusion: Arc, + ) -> Result, SuricataError> { + Self::setup_veth()?; + + let ifindex = Self::get_ifindex(MIRROR_IFACE)?; + + if let Some(path) = eve_socket.to_str() { + output::start_eve_reader(path, fusion); + } + + let yaml = yaml_path.to_str() + .ok_or_else(|| SuricataError::InvalidPath { path: yaml_path.display().to_string() })?; + + let child = Command::new("suricata") + .args(["-c", yaml, "-i", MIRROR_PEER, "--runmode=workers"]) + .spawn() + .map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?; + + let (tx, rx) = bounded::>(CHANNEL_CAP); + + /* Mirror worker: reads packet data from channel, sends via raw socket. */ + thread::Builder::new() + .name("suricata-mirror".into()) + .spawn(move || { + let fd = match Self::open_raw_socket() { + Ok(fd) => fd, + Err(e) => { + log!(e); + return; + } + }; + + let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() }; + sll.sll_family = libc::AF_PACKET as u16; + sll.sll_protocol = (libc::ETH_P_ALL as u16).to_be(); + sll.sll_ifindex = ifindex as i32; + + log!(SuricataLog::MirrorReady { iface: MIRROR_IFACE.into() }); + + while let Ok(data) = rx.recv() { + unsafe { + libc::sendto( + fd, + data.as_ptr() as *const libc::c_void, + data.len(), + 0, + &sll as *const libc::sockaddr_ll as *const libc::sockaddr, + mem::size_of::() as libc::socklen_t, + ); + } + } + + unsafe { libc::close(fd) }; + log!(SuricataLog::MirrorStopped); + }) + .map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?; + + log!(SuricataLog::Initialized); + + Ok(Arc::new(Self { tx, child: std::sync::Mutex::new(child) })) + } + + /* Non-blocking: drops silently when the channel is full under load. */ + pub fn inject(&self, data: Vec) { + match self.tx.try_send(data) { + Ok(()) => {} + Err(crossbeam::channel::TrySendError::Full(_)) => { + log!(SuricataLog::ChannelFull); + } + Err(crossbeam::channel::TrySendError::Disconnected(_)) => {} + } + } + + fn setup_veth() -> Result<(), SuricataError> { + /* Remove stale pair if it exists. */ + let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output(); + + Command::new("ip") + .args(["link", "add", MIRROR_IFACE, "type", "veth", "peer", "name", MIRROR_PEER]) + .output() + .map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })?; + + for iface in [MIRROR_IFACE, MIRROR_PEER] { + Command::new("ip") + .args(["link", "set", iface, "up"]) + .output() + .map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })?; + } + + log!(SuricataLog::VethCreated { iface: MIRROR_IFACE.into(), peer: MIRROR_PEER.into() }); + Ok(()) + } + + fn get_ifindex(name: &str) -> Result { + std::fs::read_to_string(format!("/sys/class/net/{}/ifindex", name)) + .map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })? + .trim() + .parse() + .map_err(|e: std::num::ParseIntError| SuricataError::MirrorSetupFailed { + reason: format!("ifindex parse: {e}"), + }) + } + + fn open_raw_socket() -> Result { + let fd = unsafe { + libc::socket( + libc::AF_PACKET, + libc::SOCK_RAW, + (libc::ETH_P_ALL as u16).to_be() as i32, + ) + }; + if fd < 0 { + let errno = unsafe { *libc::__errno_location() }; + return Err(SuricataError::MirrorSetupFailed { + reason: format!("socket(AF_PACKET): errno {errno}"), + }); + } + Ok(fd) + } +} + +impl Drop for SuricataEngine { + fn drop(&mut self) { + if let Ok(mut child) = self.child.lock() { + let _ = child.kill(); + } + let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output(); + } +} diff --git a/net-guardia/src/detection/suricata/mod.rs b/net-guardia/src/detection/suricata/mod.rs new file mode 100644 index 0000000..e1095f0 --- /dev/null +++ b/net-guardia/src/detection/suricata/mod.rs @@ -0,0 +1,3 @@ +mod output; +pub mod engine; +pub use engine::SuricataEngine; diff --git a/net-guardia/src/detection/suricata/output.rs b/net-guardia/src/detection/suricata/output.rs new file mode 100644 index 0000000..c5953f3 --- /dev/null +++ b/net-guardia/src/detection/suricata/output.rs @@ -0,0 +1,110 @@ +use std::io::{BufRead, BufReader}; +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::sync::Arc; +use std::thread; + +use macros::log; +use serde::Deserialize; + +use crate::detection::fusion::FusionEngine; +use crate::model::log::suricata::SuricataLog; +use crate::model::rule_detection::RuleMatch; + +/* ── EVE JSON structs (alert subset) ─────────────────────────────────────── */ + +#[derive(Deserialize)] +struct EveEvent { + event_type: String, + src_ip: Option, + src_port: Option, + dest_ip: Option, + dest_port: Option, + alert: Option, +} + +#[derive(Deserialize)] +struct EveAlert { + #[serde(rename = "signature_id")] + signature_id: u32, + signature: String, +} + +/* ── Unix socket EVE reader ──────────────────────────────────────────────── */ + +pub fn start_eve_reader(socket_path: &str, fusion: Arc) { + let path = socket_path.to_owned(); + + if Path::new(&path).exists() { + let _ = std::fs::remove_file(&path); + } + + let listener = UnixListener::bind(&path).unwrap_or_else(|e| { + panic!("suricata EVE socket bind failed at {path}: {e}"); + }); + + log!(SuricataLog::EveReaderReady { path: path.clone() }); + + thread::Builder::new() + .name("suricata-eve-reader".into()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(s) => { + let fusion_clone = fusion.clone(); + thread::Builder::new() + .name("suricata-eve-conn".into()) + .spawn(move || handle_eve_stream(s, fusion_clone)) + .ok(); + } + Err(e) => { + log!(crate::model::error::suricata::SuricataError::EveSocketAcceptFailed { + reason: e.to_string(), + }); + break; + } + } + } + }) + .expect("suricata-eve-reader thread spawn failed"); +} + +fn handle_eve_stream(stream: std::os::unix::net::UnixStream, fusion: Arc) { + let reader = BufReader::new(stream); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.is_empty() { + continue; + } + let Ok(event) = serde_json::from_str::(&line) else { + continue; + }; + if event.event_type != "alert" { + continue; + } + let (Some(alert), Some(src_ip), Some(dst_ip)) = + (event.alert, event.src_ip, event.dest_ip) + else { + continue; + }; + + let src_port = event.src_port.unwrap_or(0); + let dst_port = event.dest_port.unwrap_or(0); + + let m = RuleMatch { + sid: alert.signature_id, + msg: alert.signature, + src: format!("{}:{}", src_ip, src_port), + dst: format!("{}:{}", dst_ip, dst_port), + }; + + log!(SuricataLog::RuleAlert { + src: m.src.clone(), + dst: m.dst.clone(), + sid: m.sid, + msg: m.msg.clone(), + }); + + fusion.record_rule(&m); + } +} \ No newline at end of file diff --git a/net-guardia/src/model/config.rs b/net-guardia/src/model/config.rs index a53fe35..dbe0f09 100644 --- a/net-guardia/src/model/config.rs +++ b/net-guardia/src/model/config.rs @@ -30,8 +30,6 @@ pub struct Config { pub flow_timeout_us: u64, pub traffic_logging_mode: bool, pub traffic_log_csv_path: String, - pub min_signature_matches: u32, - pub home_net: Vec, /// Optional path to an NSS key log file (SSLKEYLOGFILE) for TLS decryption. /// Only useful in [external]->NetGuardia->[internal] deployments where the /// internal server can be configured to write TLS session keys. @@ -57,6 +55,10 @@ pub struct Config { /// "mean+1std", "Q3+1.5IQR", "Q3+3.0IQR". Defaults to "95" when absent. #[serde(default = "default_ae_threshold_method")] pub ae_threshold_method: String, + + /// Path to suricata.yaml. If absent, Suricata rule engine is disabled. + pub suricata_name: String, + } fn default_fusion_mode() -> String { @@ -69,4 +71,4 @@ fn default_fusion_window_secs() -> u64 { fn default_ae_threshold_method() -> String { "95".to_string() -} \ No newline at end of file +} diff --git a/net-guardia/src/model/error/mod.rs b/net-guardia/src/model/error/mod.rs index 3099af4..2100338 100644 --- a/net-guardia/src/model/error/mod.rs +++ b/net-guardia/src/model/error/mod.rs @@ -4,7 +4,7 @@ pub mod io; pub mod misc; pub mod ml; pub mod system; -pub mod rule; +pub mod suricata; use serde::{Deserialize, Serialize}; @@ -13,7 +13,7 @@ use crate::model::error::http::HttpError; use crate::model::error::io::IOError; use crate::model::error::misc::MiscError; use crate::model::error::ml::MLError; -use crate::model::error::rule::RuleError; +use crate::model::error::suricata::SuricataError; use crate::model::error::system::SystemError; #[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)] @@ -29,7 +29,7 @@ pub enum Error { #[error("{0}")] Misc(MiscError), #[error("{0}")] - Rule(RuleError), + Suricata(SuricataError), #[error("{0}")] System(SystemError), } @@ -70,8 +70,8 @@ impl From for Error { } } -impl From for Error { - fn from(error: RuleError) -> Self { - Self::Rule(error) +impl From for Error { + fn from(error: SuricataError) -> Self { + Self::Suricata(error) } } \ No newline at end of file diff --git a/net-guardia/src/model/error/rule.rs b/net-guardia/src/model/error/rule.rs deleted file mode 100644 index 6261c1b..0000000 --- a/net-guardia/src/model/error/rule.rs +++ /dev/null @@ -1,13 +0,0 @@ -use macros::traceable; - -traceable! { - RuleError { - #[no_source] - #[error("BlockDatabase::new failed: {error}")] - BlockDatabaseFailed { error: String } => tracing::Level::ERROR, - - #[no_source] - #[error("Flow state missing for TCP key; skipping packet processing")] - FlowStateMissing => tracing::Level::ERROR, - } -} \ No newline at end of file diff --git a/net-guardia/src/model/error/suricata.rs b/net-guardia/src/model/error/suricata.rs new file mode 100644 index 0000000..8bbe3bc --- /dev/null +++ b/net-guardia/src/model/error/suricata.rs @@ -0,0 +1,25 @@ +use macros::traceable; + +traceable! { + SuricataError { + #[no_source] + #[error("Suricata process spawn failed: {reason}")] + ProcessSpawnFailed { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Mirror veth setup failed: {reason}")] + MirrorSetupFailed { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("EVE socket bind failed at {path}: {reason}")] + EveSocketBindFailed { path: String, reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("EVE socket accept failed: {reason}")] + EveSocketAcceptFailed { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Invalid path (non-UTF-8): {path}")] + InvalidPath { path: String } => tracing::Level::ERROR, + } +} diff --git a/net-guardia/src/model/log/mod.rs b/net-guardia/src/model/log/mod.rs index 2e20b92..4ae0a9e 100644 --- a/net-guardia/src/model/log/mod.rs +++ b/net-guardia/src/model/log/mod.rs @@ -4,4 +4,4 @@ pub mod ml; pub mod system; pub mod misc; pub mod health; -pub mod rule; +pub mod suricata; diff --git a/net-guardia/src/model/log/rule.rs b/net-guardia/src/model/log/rule.rs deleted file mode 100644 index 047df7c..0000000 --- a/net-guardia/src/model/log/rule.rs +++ /dev/null @@ -1,18 +0,0 @@ -use macros::loggable; -use tracing; - -loggable! { - RuleLog { - #[error("Signature rules loaded: {count} patterns")] - RulesLoaded { count: usize } => tracing::Level::INFO, - - #[error("Signature rules disabled (no rules found in rules/)")] - RulesDisabled => tracing::Level::INFO, - - #[error("Signature match [{direction}]: {src}->{dst} sid={sid} \"{msg}\"")] - SignatureMatch { direction: String, src: String, dst: String, sid: u32, msg: String } => tracing::Level::WARN, - - #[error("Signature stats [{direction}]: active_flows={active_flows}, min_matches={min_matches}, match_counts: {counts}")] - SignatureStats { direction: String, active_flows: usize, min_matches: u32, counts: String } => tracing::Level::INFO, - } -} \ No newline at end of file diff --git a/net-guardia/src/model/log/suricata.rs b/net-guardia/src/model/log/suricata.rs new file mode 100644 index 0000000..1f468b6 --- /dev/null +++ b/net-guardia/src/model/log/suricata.rs @@ -0,0 +1,27 @@ +use macros::loggable; +use tracing; + +loggable! { + SuricataLog { + #[error("Suricata engine initialized (daemon mode)")] + Initialized => tracing::Level::INFO, + + #[error("Suricata veth pair created: {iface} <-> {peer}")] + VethCreated { iface: String, peer: String } => tracing::Level::INFO, + + #[error("Suricata mirror socket ready on {iface}")] + MirrorReady { iface: String } => tracing::Level::INFO, + + #[error("Suricata mirror socket stopped")] + MirrorStopped => tracing::Level::INFO, + + #[error("Suricata EVE reader listening at {path}")] + EveReaderReady { path: String } => tracing::Level::INFO, + + #[error("Suricata rule alert: {src}->{dst} sid={sid} \"{msg}\"")] + RuleAlert { src: String, dst: String, sid: u32, msg: String } => tracing::Level::WARN, + + #[error("Suricata mirror channel full — packet dropped")] + ChannelFull => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/rule_detection.rs b/net-guardia/src/model/rule_detection.rs index 50ba592..ac56b48 100644 --- a/net-guardia/src/model/rule_detection.rs +++ b/net-guardia/src/model/rule_detection.rs @@ -1,300 +1,7 @@ -use std::collections::HashSet; -use std::net::IpAddr; -use std::time::Instant; -use protolens::{Packet as ProtoPacket, Task, TransProto}; - #[derive(Debug, Clone)] pub struct RuleMatch { pub sid: u32, pub msg: String, pub src: String, pub dst: String, - /// Flowbit mutations to apply after this match: (op, name). - /// op: 0=set, 1=toggle, 2=unset (Suricata DETECT_FLOWBITS_CMD_* order) - pub flowbit_ops: Vec<(u8, String)>, - /// When true, do not count this match toward alert threshold. - pub is_noalert: bool, -} - -pub struct ContentEntry { - pub pattern: Vec, - pub nocase: bool, - pub negated: bool, - pub has_distance: bool, - pub has_within: bool, - pub has_offset: bool, - pub has_depth: bool, - pub distance: i32, - pub within: i32, - pub offset: u16, - pub depth: u16, - - /// Target buffer ID (0 = raw; see buf module for named constants). - pub buffer: u8, -} - -/// Buffer IDs — must match the constants used in `build.rs` and `rule_engine.rs`. -/// 0 (Raw) has no named constant; it is the default when no sticky buffer is set. -pub mod buf { - // HTTP/1.x - pub const HTTP_URI: u8 = 1; - pub const HTTP_HEADER: u8 = 2; - pub const HTTP_CLIENT_BODY: u8 = 3; - pub const HTTP_SERVER_BODY: u8 = 4; - pub const HTTP_METHOD: u8 = 5; - pub const HTTP_USER_AGENT: u8 = 6; - pub const HTTP_HOST: u8 = 7; - pub const HTTP_STAT_CODE: u8 = 8; - pub const HTTP_COOKIE: u8 = 9; - pub const HTTP_RAW_URI: u8 = 10; - // TLS - pub const TLS_SNI: u8 = 11; - pub const DNS_QUERY: u8 = 12; - pub const TLS_VERSION: u8 = 13; - pub const TLS_JA3: u8 = 14; - // DNS - pub const DNS_RCODE: u8 = 15; - pub const DNS_QTYPE: u8 = 16; - pub const DNS_ANSWER: u8 = 17; - // SSH - pub const SSH_PROTO: u8 = 18; - pub const SSH_SOFTWARE: u8 = 19; - pub const SSH_HASSH: u8 = 20; - // SMTP - pub const SMTP_HELO: u8 = 21; - pub const SMTP_FROM: u8 = 22; - pub const SMTP_RCPT: u8 = 23; - pub const SMTP_REPLY: u8 = 24; - // FTP - pub const FTP_COMMAND: u8 = 25; - pub const FTP_FILENAME: u8 = 26; - pub const FTP_REPLY: u8 = 27; - // MQTT - pub const MQTT_CLIENTID: u8 = 28; - pub const MQTT_TOPIC: u8 = 29; - pub const MQTT_USERNAME: u8 = 30; - // HTTP/2 - pub const HTTP2_METHOD: u8 = 31; - pub const HTTP2_PATH: u8 = 32; - pub const HTTP2_AUTHORITY: u8 = 33; - pub const HTTP2_STATUS: u8 = 34; - pub const HTTP2_UA: u8 = 35; - // TLS extended - pub const TLS_JA3S: u8 = 36; - pub const TLS_JA4: u8 = 37; - pub const TLS_CERT_SUBJECT: u8 = 38; - pub const TLS_CERT_ISSUER: u8 = 39; - pub const TLS_CERT_FINGERPRINT: u8 = 40; - pub const TLS_CERT_NOT_BEFORE: u8 = 41; - pub const TLS_CERT_NOT_AFTER: u8 = 42; - pub const TLS_ALPN: u8 = 43; - // QUIC - pub const QUIC_SNI: u8 = 44; - pub const QUIC_VERSION: u8 = 45; - pub const QUIC_UA: u8 = 46; -} - -/// All application-layer fields extracted from a single payload chunk. -/// Uses a sparse buffer map so that adding new protocols requires no struct changes. -pub struct ParsedPacket { - pub raw: Vec, - /// True when the payload is TLS Application Data (encrypted). - pub tls_app_data: bool, - /// True when this payload was identified as TLS (any record type, or port-based fallback). - pub is_tls: bool, - /// Application-layer protocol detected for this packet. - pub proto: AppProto, - bufs: Vec<(u8, Vec)>, -} - -impl ParsedPacket { - pub fn new(raw: Vec) -> Self { - Self { raw, tls_app_data: false, is_tls: false, proto: AppProto::Unknown, bufs: Vec::new() } - } - - /// Store `data` under buffer `id`. No-op if data is empty. - pub fn set(&mut self, id: u8, data: Vec) { - if data.is_empty() { - return; - } - if let Some((_, v)) = self.bufs.iter_mut().find(|(k, _)| *k == id) { - *v = data; - } else { - self.bufs.push((id, data)); - } - } - - /// Return the byte slice for the given buffer ID. - pub fn buffer(&self, id: u8) -> &[u8] { - if id == 0 { - return &self.raw; - } - self.bufs.iter().find(|(k, _)| *k == id).map(|(_, v)| v.as_slice()).unwrap_or(&[]) - } -} - -/// A byte-level inspection operation stored alongside the content chain. -/// -/// Suricata keywords: byte_test, byte_jump, byte_extract, isdataat. -/// Operations are evaluated in order after the content chain succeeds. -/// For `relative` ops, the base position is the end of the last content match. -/// -/// kind encoding: -/// 0 = byte_test — read N bytes, compare against a value or extracted var -/// 1 = byte_jump — advance the cursor by the value read at offset -/// 2 = byte_extract — read N bytes into a named variable slot -/// 3 = isdataat — assert that data exists (or doesn't) at a position -#[derive(Clone, Debug)] -pub struct ByteOp { - /// 0=byte_test 1=byte_jump 2=byte_extract 3=isdataat - pub kind: u8, - /// Number of bytes to read from the buffer (byte_test/jump/extract: 1–8). - pub count: u8, - /// Signed byte offset from base position. - pub offset: i32, - /// If true, offset is from the previous match end; otherwise from buffer start. - pub relative: bool, - /// Read value as little-endian (default: big-endian). - pub endian_little: bool, - /// Parse numeric value from ASCII text rather than raw binary. - pub string_mode: bool, - /// 0=decimal 1=hex 2=octal (only when string_mode is true). - pub string_base: u8, - /// Buffer ID the operation applies to (same IDs as ContentEntry::buffer). - pub buffer: u8, - - // ── byte_test fields ────────────────────────────────────────────────── - /// Comparison operator: 0=< 1=> 2=== 3=!= 4=<= 5=>= 6=& 7=^ - pub bt_op: u8, - /// Value to test against (or variable slot index when bt_uses_var is true). - pub bt_value: u64, - /// If true, bt_value is a byte_extract variable slot index, not a literal. - pub bt_uses_var: bool, - /// Bitmask ANDed with the extracted value before comparison; 0 = disabled. - pub bt_bitmask: u64, - /// Negate the operator result. - pub bt_negated: bool, - - // ── byte_jump fields ────────────────────────────────────────────────── - /// Align the new cursor to the next multiple of 4 bytes. - pub bj_align: bool, - /// Multiply the extracted value by this before jumping. - pub bj_multiplier: u32, - /// Jump from the start of the buffer rather than the current cursor. - pub bj_from_beginning: bool, - /// Additional signed offset applied after the jump. - pub bj_post_offset: i32, - - // ── byte_extract fields ─────────────────────────────────────────────── - /// Variable slot index (0–7) where the extracted value is stored. - pub be_var_idx: u8, - /// Multiply the extracted value before storing. - pub be_multiplier: u32, -} - -pub struct RuleSig { - pub sid: u32, - /// 0=any, 1=to_server (ingress), 2=to_client (egress) - pub flow_dir: u8, - /// empty = any port - pub dports: Vec, - pub msg: String, - pub chain: Vec, -} - -#[derive(Clone)] -pub struct RawPkt { - pub trans_proto: TransProto, - pub sip: IpAddr, - pub dip: IpAddr, - pub sport: u16, - pub dport: u16, - pub seq: u32, - pub syn: bool, - pub fin: bool, - pub rst: bool, - pub payload: Vec, -} - -impl ProtoPacket for RawPkt { - fn trans_proto(&self) -> TransProto { self.trans_proto } - fn sip(&self) -> IpAddr { self.sip } - fn dip(&self) -> IpAddr { self.dip } - fn tu_sport(&self) -> u16 { self.sport } - fn tu_dport(&self) -> u16 { self.dport } - fn seq(&self) -> u32 { self.seq } - fn syn(&self) -> bool { self.syn } - fn fin(&self) -> bool { self.fin } - fn payload_len(&self) -> usize { self.payload.len() } - fn payload(&self) -> &[u8] { &self.payload } -} - -#[derive(Hash, Eq, PartialEq, Clone)] -pub struct FlowKey { - pub sip: IpAddr, - pub sport: u16, - pub dip: IpAddr, - pub dport: u16, -} - -impl FlowKey { - pub fn reversed(&self) -> Self { - Self { sip: self.dip, sport: self.dport, dip: self.sip, dport: self.sport } - } -} - -/// Application-layer protocol identified during packet parsing. -/// Used by app-layer-protocol: rule keyword (Suricata-compatible). -#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] -#[repr(u8)] -pub enum AppProto { - #[default] - Unknown = 0, - Http = 1, - Http2 = 2, - Tls = 3, - Dns = 4, - Ssh = 5, - Smtp = 6, - Ftp = 7, - Mqtt = 8, - Quic = 9, -} - -/// Application-layer protocol detected for a flow. -/// Mirrors Suricata's `flow->alproto` — once set, never cleared. -/// Only TLS is tracked for now; HTTP and DNS don't benefit from per-flow -/// skip logic because they're cheap to detect and should still be scanned. -#[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum FlowProto { - #[default] - Unknown = 0, - Tls = 1, -} - -/// Inline threshold from Suricata threshold: keyword. -/// Mirrors Suricata detect-threshold.h DetectThresholdData. -pub struct ThresholdInfo { - /// 1=limit 2=both 3=threshold - pub kind: u8, - /// 1=TRACK_DST 2=TRACK_SRC 3=TRACK_RULE 5=TRACK_BOTH 6=TRACK_FLOW - pub track: u8, - pub count: u32, - pub seconds: u32, -} - -pub struct FlowState { - pub task: Task, - pub last_seen: Instant, - pub fin_count: u8, - pub rst_seen: bool, - - /// Protocol identified on the first parseable chunk of this flow. - pub detected_proto: FlowProto, - - /// True once the first non-SYN packet is seen, indicating TCP handshake complete. - pub established: bool, - - /// Per-flow flowbits set by matching rules (Suricata flowbits:set/unset/toggle). - pub flowbits: HashSet, } diff --git a/net-guardia/static/rules/suricata.yaml b/net-guardia/static/rules/suricata.yaml new file mode 100644 index 0000000..6306e8a --- /dev/null +++ b/net-guardia/static/rules/suricata.yaml @@ -0,0 +1,81 @@ +%YAML 1.1 +--- + +vars: + address-groups: + HOME_NET: "[140.130.34.0/24]" + EXTERNAL_NET: "!$HOME_NET" + HTTP_SERVERS: "$HOME_NET" + SMTP_SERVERS: "$HOME_NET" + SQL_SERVERS: "$HOME_NET" + DNS_SERVERS: "$HOME_NET" + TELNET_SERVERS: "$HOME_NET" + AIM_SERVERS: "$EXTERNAL_NET" + DC_SERVERS: "$HOME_NET" + DNP3_SERVER: "$HOME_NET" + DNP3_CLIENT: "$HOME_NET" + MODBUS_CLIENT: "$HOME_NET" + MODBUS_SERVER: "$HOME_NET" + ENIP_CLIENT: "$HOME_NET" + ENIP_SERVER: "$HOME_NET" + port-groups: + HTTP_PORTS: "80" + SHELLCODE_PORTS: "!80" + ORACLE_PORTS: 1521 + SSH_PORTS: 22 + DNP3_PORTS: 20000 + MODBUS_PORTS: 502 + FILE_DATA_PORTS: "[$HTTP_PORTS,110,143]" + FTP_PORTS: 21 + VXLAN_PORTS: 4789 + TEREDO_PORTS: 3544 + +default-rule-path: ./net-guardia/static/rules + +rule-files: + - "*.rules" + +threshold-file: ./net-guardia/static/rules/suppress.conf + +outputs: + - eve-log: + enabled: yes + filetype: unix_stream + filename: /tmp/suricata-alerts.sock + types: + - alert: + payload: no + packet: no + metadata: no + http-body: no + tagged-packets: no + - fast: + enabled: no + - stats: + enabled: no + +app-layer: + protocols: + tls: + enabled: yes + http: + enabled: yes + dns: + enabled: yes + smtp: + enabled: yes + ssh: + enabled: yes + +af-packet: + - interface: ng-mirror-peer + use-mmap: yes + tpacket-v3: yes + ring-size: 2048 + block-size: 131072 + +threading: + set-cpu-affinity: no + +legacy: + uricontent: enabled \ No newline at end of file