mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
refactor: simplify trainer code
This commit is contained in:
parent
bac8b11516
commit
3012959163
536
Cargo.lock
generated
536
Cargo.lock
generated
@ -336,10 +336,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cipher 0.4.4",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
||||
dependencies = [
|
||||
"cipher 0.5.1",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
@ -347,8 +358,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"aes 0.8.4",
|
||||
"cipher 0.4.4",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
@ -684,12 +695,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@ -768,6 +773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -818,6 +824,15 @@ dependencies = [
|
||||
"bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.2"
|
||||
@ -913,7 +928,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.6",
|
||||
"inout",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.1",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -998,18 +1023,18 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@ -1061,6 +1086,12 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@ -1150,18 +1181,6 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@ -1188,7 +1207,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1200,33 +1219,6 @@ dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@ -1277,15 +1269,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
name = "deflate64"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
@ -1350,7 +1337,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
@ -1362,9 +1348,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
"ctutils",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1418,44 +1405,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egress-ebpf"
|
||||
version = "1.0.0"
|
||||
@ -1472,27 +1421,6 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf 0.12.4",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
@ -1552,22 +1480,6 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@ -1593,6 +1505,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1704,7 +1617,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1741,11 +1653,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1764,17 +1678,6 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
@ -1861,31 +1764,13 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2234,6 +2119,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@ -2365,29 +2259,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac 0.12.1",
|
||||
"js-sys",
|
||||
"p256",
|
||||
"p384",
|
||||
"pem",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
@ -2429,9 +2300,6 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
@ -2478,6 +2346,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@ -2647,6 +2521,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
|
||||
dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macros"
|
||||
version = "1.0.0"
|
||||
@ -2810,10 +2693,9 @@ dependencies = [
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"hkdf 0.13.0",
|
||||
"hmac 0.13.0",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"ipnetwork",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"libc",
|
||||
"libxdp-sys",
|
||||
@ -2837,6 +2719,7 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@ -2845,6 +2728,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"which",
|
||||
"xsk-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2963,32 +2847,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[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-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@ -3024,17 +2882,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@ -3159,30 +3006,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@ -3230,22 +3053,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
name = "pbkdf2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"digest 0.11.3",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3303,27 +3117,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@ -3378,6 +3171,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@ -3406,15 +3205,6 @@ dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@ -3724,16 +3514,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@ -3748,26 +3528,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
@ -4010,20 +3770,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@ -4192,16 +3938,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@ -4224,18 +3960,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@ -4268,22 +3992,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.3"
|
||||
@ -4487,6 +4195,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"js-sys",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
@ -4866,6 +4575,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
@ -5694,12 +5409,57 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"aes 0.9.0",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.4.2",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
"typed-path",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
@ -28,6 +28,7 @@ serde_yaml_ng = "0.10.0"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std", "fs", "signal"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Web framework
|
||||
actix = "0.13.5"
|
||||
@ -37,6 +38,7 @@ actix-ws = "0.4.0"
|
||||
actix-multipart = "0.7"
|
||||
actix-files = "0.6"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
zip = "8.6.0"
|
||||
|
||||
# Logging / tracing
|
||||
tracing = "0.1.44"
|
||||
@ -60,7 +62,6 @@ ipnetwork = "0.21.1"
|
||||
lru = "0.18.0"
|
||||
rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] }
|
||||
async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] }
|
||||
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
||||
argon2 = "0.5"
|
||||
rand = "0.10.1"
|
||||
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
|
||||
|
||||
@ -15,6 +15,9 @@ services:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
environment:
|
||||
NETGUARDIA_DB_KEY: netguardia-dev-db-key
|
||||
NETGUARDIA_SECRETS_KEY: netguardia-dev-secrets-key
|
||||
dns:
|
||||
- 10.10.3.1
|
||||
- 8.8.8.8
|
||||
|
||||
187
macros/src/fallible.rs
Normal file
187
macros/src/fallible.rs
Normal file
@ -0,0 +1,187 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Attribute, Error, Ident, LitStr, Result, Token, Type};
|
||||
|
||||
struct FallibleVariant {
|
||||
attributes: Vec<Attribute>,
|
||||
error_msg: LitStr,
|
||||
name: Ident,
|
||||
fields: Vec<(Ident, Type)>,
|
||||
}
|
||||
|
||||
impl FallibleVariant {
|
||||
fn has_no_source(&self) -> bool {
|
||||
self.attributes.iter().any(|attr| attr.path().is_ident("no_source"))
|
||||
}
|
||||
|
||||
fn should_generate_constructor(&self) -> bool {
|
||||
if self.has_no_source() {
|
||||
!self.fields.is_empty()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FallibleInput {
|
||||
enum_name: Ident,
|
||||
variants: Vec<FallibleVariant>,
|
||||
}
|
||||
|
||||
impl Parse for FallibleInput {
|
||||
fn parse(input: ParseStream) -> Result<Self> {
|
||||
let enum_name = input.parse::<Ident>()?;
|
||||
|
||||
let content;
|
||||
syn::braced!(content in input);
|
||||
|
||||
let mut variants = Vec::new();
|
||||
|
||||
while !content.is_empty() {
|
||||
let mut attributes = Vec::new();
|
||||
|
||||
while content.peek(Token![#]) {
|
||||
attributes.push(content.call(Attribute::parse_outer)?);
|
||||
}
|
||||
|
||||
let attributes: Vec<_> = attributes.into_iter().flatten().collect();
|
||||
|
||||
let error_attr = attributes
|
||||
.iter()
|
||||
.find(|attr| attr.path().is_ident("error"))
|
||||
.ok_or_else(|| Error::new(content.span(), "Missing #[error] attribute"))?;
|
||||
|
||||
let error_msg = match &error_attr.meta {
|
||||
syn::Meta::List(list) => syn::parse2::<LitStr>(list.tokens.clone())?,
|
||||
_ => {
|
||||
return Err(Error::new(error_attr.span(), "Invalid error attribute format"));
|
||||
}
|
||||
};
|
||||
|
||||
let name = content.parse::<Ident>()?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
if content.peek(syn::token::Brace) {
|
||||
let fields_content;
|
||||
syn::braced!(fields_content in content);
|
||||
|
||||
while !fields_content.is_empty() {
|
||||
let field_name = fields_content.parse::<Ident>()?;
|
||||
fields_content.parse::<Token![:]>()?;
|
||||
let field_type = fields_content.parse::<Type>()?;
|
||||
fields.push((field_name, field_type));
|
||||
|
||||
if !fields_content.is_empty() {
|
||||
fields_content.parse::<Token![,]>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !content.is_empty() {
|
||||
content.parse::<Token![,]>()?;
|
||||
}
|
||||
|
||||
variants.push(FallibleVariant {
|
||||
attributes,
|
||||
error_msg,
|
||||
name,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(FallibleInput { enum_name, variants })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fallible_impl(input: TokenStream) -> TokenStream {
|
||||
let input = syn::parse_macro_input!(input as FallibleInput);
|
||||
|
||||
let enum_name = &input.enum_name;
|
||||
let variants = &input.variants;
|
||||
|
||||
let enum_variants = variants.iter().map(|variant| {
|
||||
let name = &variant.name;
|
||||
let error_msg = &variant.error_msg;
|
||||
let fields = &variant.fields;
|
||||
|
||||
let field_definitions = fields.iter().map(|(name, ty)| {
|
||||
quote! { #name: #ty }
|
||||
});
|
||||
|
||||
if variant.has_no_source() {
|
||||
if variant.fields.is_empty() {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name { #(#field_definitions,)* }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[error(#error_msg)]
|
||||
#name {
|
||||
#(#field_definitions,)*
|
||||
err: String
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let constructors = variants.iter().filter_map(|variant| {
|
||||
if !variant.should_generate_constructor() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &variant.name;
|
||||
let fields = &variant.fields;
|
||||
|
||||
let params = fields.iter().map(|(field_name, field_type)| {
|
||||
quote! { #field_name: impl Into<#field_type> }
|
||||
});
|
||||
|
||||
let field_assignments = fields.iter().map(|(field_name, _)| {
|
||||
quote! { #field_name: #field_name.into() }
|
||||
});
|
||||
|
||||
if variant.has_no_source() {
|
||||
Some(quote! {
|
||||
#[allow(non_snake_case)]
|
||||
pub fn #name(#(#params),*) -> Self {
|
||||
Self::#name {
|
||||
#(#field_assignments,)*
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Some(quote! {
|
||||
#[allow(non_snake_case)]
|
||||
pub fn #name(#(#params,)* source: impl std::fmt::Display) -> Self {
|
||||
Self::#name {
|
||||
#(#field_assignments,)*
|
||||
err: source.to_string()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let expanded = quote! {
|
||||
#[allow(dead_code, clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum #enum_name {
|
||||
#(#enum_variants,)*
|
||||
}
|
||||
|
||||
impl #enum_name {
|
||||
#(#constructors)*
|
||||
}
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod config;
|
||||
mod error_enum;
|
||||
mod fallible;
|
||||
mod log;
|
||||
mod loggable;
|
||||
mod traceable;
|
||||
@ -11,6 +12,11 @@ pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
config::config_settings_impl(attr, item)
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn fallible(input: TokenStream) -> TokenStream {
|
||||
fallible::fallible_impl(input)
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn log(input: TokenStream) -> TokenStream {
|
||||
log::log_impl(input)
|
||||
|
||||
@ -275,7 +275,7 @@
|
||||
},
|
||||
"anomaly_threshold": 0.9179317355155945,
|
||||
"c2_threshold": 0.9085615873336792,
|
||||
"model_type": "MultiTaskModel",
|
||||
"model_type": "multi_task",
|
||||
"output_names": [
|
||||
"anomaly",
|
||||
"class_probs",
|
||||
|
||||
@ -1,54 +1,106 @@
|
||||
# NetGuardia model manifest. Structural/semantic fields live here;
|
||||
# preprocessing arrays (scaler mean/std, clip params, feature weights) stay
|
||||
# in the JSON sidecar referenced by `preprocessing.scaler_sidecar`.
|
||||
|
||||
name: netguardia-v10
|
||||
adapter: multi_task
|
||||
name: netguardia-v2
|
||||
version: 2
|
||||
|
||||
models:
|
||||
autoencoder: deep_autoencoder.onnx
|
||||
classifier: classifier.onnx
|
||||
- id: anomaly_detector
|
||||
file: deep_autoencoder.onnx
|
||||
input_features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- bwd_packets
|
||||
- fwd_bytes
|
||||
- bwd_bytes
|
||||
- flow_bytes_per_sec
|
||||
- flow_pkts_per_sec
|
||||
- fwd_win_bytes
|
||||
- bwd_win_bytes
|
||||
- fwd_pkt_len_mean
|
||||
- bwd_pkt_len_mean
|
||||
- fwd_iat_mean
|
||||
- bwd_iat_mean
|
||||
- flow_iat_mean
|
||||
- pkt_len_mean
|
||||
- dst_port
|
||||
- protocol
|
||||
- psh_flag_cnt
|
||||
- ack_flag_cnt
|
||||
- syn_flag_cnt
|
||||
- fin_flag_cnt
|
||||
- rst_flag_cnt
|
||||
- pkt_len_std
|
||||
- fwd_pkt_len_std
|
||||
- bwd_pkt_len_std
|
||||
- fwd_seg_size_min
|
||||
- fwd_act_data_pkts
|
||||
- fwd_iat_std
|
||||
- bwd_iat_std
|
||||
- fwd_bwd_bytes_ratio
|
||||
- iat_cv
|
||||
preprocessing:
|
||||
- type: standard_scaler
|
||||
sidecar: inference_config.json
|
||||
- type: clip
|
||||
min: -5.0
|
||||
max: 5.0
|
||||
outputs:
|
||||
- name: ae_anomaly_score
|
||||
shape: [1]
|
||||
semantic: anomaly_score
|
||||
threshold: 0.23011694848537445
|
||||
|
||||
# 31 AE-input features. Order matters — must match ONNX input column order
|
||||
# and inference_config.json `ae_feature_names`. The classifier takes these
|
||||
# plus `ae_anomaly_score` appended as the 32nd input (handled in code).
|
||||
features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- bwd_packets
|
||||
- fwd_bytes
|
||||
- bwd_bytes
|
||||
- flow_bytes_per_sec
|
||||
- flow_pkts_per_sec
|
||||
- fwd_win_bytes
|
||||
- bwd_win_bytes
|
||||
- fwd_pkt_len_mean
|
||||
- bwd_pkt_len_mean
|
||||
- fwd_iat_mean
|
||||
- bwd_iat_mean
|
||||
- flow_iat_mean
|
||||
- pkt_len_mean
|
||||
- dst_port
|
||||
- protocol
|
||||
- psh_flag_cnt
|
||||
- ack_flag_cnt
|
||||
- syn_flag_cnt
|
||||
- fin_flag_cnt
|
||||
- rst_flag_cnt
|
||||
- pkt_len_std
|
||||
- fwd_pkt_len_std
|
||||
- bwd_pkt_len_std
|
||||
- fwd_seg_size_min
|
||||
- fwd_act_data_pkts
|
||||
- fwd_iat_std
|
||||
- bwd_iat_std
|
||||
- fwd_bwd_bytes_ratio
|
||||
- iat_cv
|
||||
- id: classifier
|
||||
file: classifier.onnx
|
||||
input_features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- bwd_packets
|
||||
- fwd_bytes
|
||||
- bwd_bytes
|
||||
- flow_bytes_per_sec
|
||||
- flow_pkts_per_sec
|
||||
- fwd_win_bytes
|
||||
- bwd_win_bytes
|
||||
- fwd_pkt_len_mean
|
||||
- bwd_pkt_len_mean
|
||||
- fwd_iat_mean
|
||||
- bwd_iat_mean
|
||||
- flow_iat_mean
|
||||
- pkt_len_mean
|
||||
- dst_port
|
||||
- protocol
|
||||
- psh_flag_cnt
|
||||
- ack_flag_cnt
|
||||
- syn_flag_cnt
|
||||
- fin_flag_cnt
|
||||
- rst_flag_cnt
|
||||
- pkt_len_std
|
||||
- fwd_pkt_len_std
|
||||
- bwd_pkt_len_std
|
||||
- fwd_seg_size_min
|
||||
- fwd_act_data_pkts
|
||||
- fwd_iat_std
|
||||
- bwd_iat_std
|
||||
- fwd_bwd_bytes_ratio
|
||||
- iat_cv
|
||||
- ae_anomaly_score
|
||||
outputs:
|
||||
- name: anomaly
|
||||
shape: [1]
|
||||
semantic: binary
|
||||
threshold: 0.9179317355155945
|
||||
- name: class_probs
|
||||
shape: [10]
|
||||
semantic: multiclass
|
||||
min_confidence: 0.4
|
||||
- name: c2_score
|
||||
shape: [1]
|
||||
semantic: binary
|
||||
threshold: 0.9085615873336792
|
||||
|
||||
pipeline:
|
||||
- anomaly_detector
|
||||
- classifier
|
||||
|
||||
# `confirmations` sets the per-class aggregator firing threshold. Classes
|
||||
# with single-shot semantics (C2 / Bot / DNS tunneling / exploit) use 1 so
|
||||
# the aggregator alerts on the first detection; noisier classes can raise
|
||||
# it (DoS/DDoS: 2). Absent entries fall back to the engine default.
|
||||
labels:
|
||||
"0": { name: Bot, confirmations: 1 }
|
||||
"1": { name: Brute Force }
|
||||
@ -61,14 +113,8 @@ labels:
|
||||
"8": { name: Reconnaissance }
|
||||
"9": { name: Web Attack }
|
||||
|
||||
thresholds:
|
||||
anomaly: 0.9179317355155945
|
||||
c2: 0.9085615873336792
|
||||
class_min_confidence: 0.4
|
||||
ae: 0.23011694848537445
|
||||
# Average score must exceed `class_min_confidence * alert_multiplier`
|
||||
# before the aggregator fires. Raising this suppresses borderline hits.
|
||||
alert_multiplier: 1.2
|
||||
|
||||
preprocessing:
|
||||
scaler_sidecar: inference_config.json
|
||||
alert_rules:
|
||||
- condition: "anomaly > threshold"
|
||||
source_label: anomaly
|
||||
- condition: "class_probs.argmax != Normal AND class_probs.max > min_confidence"
|
||||
source_label: class_probs
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 9d32885d4cf3781b861e8f3e582dd46fe3bb187c
|
||||
Subproject commit bb44252915e0a8e0ae56508b2695c8dbf748c404
|
||||
@ -1 +1 @@
|
||||
Subproject commit 283a44ba4357b9a509400f62a59361da9025b8d9
|
||||
Subproject commit 2fc51b61fbbe463d88bc72ee0f792452a0ef2c3f
|
||||
@ -25,6 +25,7 @@ uuid = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
url = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
@ -33,6 +34,7 @@ serde_yaml_ng = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
crossbeam = { workspace = true }
|
||||
|
||||
@ -70,7 +72,6 @@ ipnetwork = { workspace = true }
|
||||
lru = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
async-sqlite = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
|
||||
@ -62,7 +62,7 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) {
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
println!("cargo:rerun-if-changed=../common/src");
|
||||
println!("cargo:rerun-if-changed=../netguardia-abi/src");
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
|
||||
@ -27,21 +27,36 @@ pub struct DropCountersAtomic {
|
||||
protocol_filter: AtomicU64,
|
||||
dns_blacklist: AtomicU64,
|
||||
geo_block: AtomicU64,
|
||||
total: AtomicU64,
|
||||
}
|
||||
|
||||
impl DropCountersAtomic {
|
||||
pub fn snapshot(&self) -> DropCounters {
|
||||
let acl_blacklist = self.acl_blacklist.load(Ordering::Relaxed);
|
||||
let rate_limit_pkt = self.rate_limit_pkt.load(Ordering::Relaxed);
|
||||
let rate_limit_syn = self.rate_limit_syn.load(Ordering::Relaxed);
|
||||
let rate_limit_udp = self.rate_limit_udp.load(Ordering::Relaxed);
|
||||
let rate_limit_dns = self.rate_limit_dns.load(Ordering::Relaxed);
|
||||
let protocol_filter = self.protocol_filter.load(Ordering::Relaxed);
|
||||
let dns_blacklist = self.dns_blacklist.load(Ordering::Relaxed);
|
||||
let geo_block = self.geo_block.load(Ordering::Relaxed);
|
||||
let total = acl_blacklist
|
||||
+ rate_limit_pkt
|
||||
+ rate_limit_syn
|
||||
+ rate_limit_udp
|
||||
+ rate_limit_dns
|
||||
+ protocol_filter
|
||||
+ dns_blacklist
|
||||
+ geo_block;
|
||||
DropCounters {
|
||||
acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed),
|
||||
rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed),
|
||||
rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed),
|
||||
rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed),
|
||||
rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed),
|
||||
protocol_filter: self.protocol_filter.load(Ordering::Relaxed),
|
||||
dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed),
|
||||
geo_block: self.geo_block.load(Ordering::Relaxed),
|
||||
total: self.total.load(Ordering::Relaxed),
|
||||
acl_blacklist,
|
||||
rate_limit_pkt,
|
||||
rate_limit_syn,
|
||||
rate_limit_udp,
|
||||
rate_limit_dns,
|
||||
protocol_filter,
|
||||
dns_blacklist,
|
||||
geo_block,
|
||||
total,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,7 +94,6 @@ impl DropMonitor {
|
||||
}
|
||||
|
||||
pub fn record_drop_count(&self, reason: u8) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
if let Some(counter) = self.bucket_for(reason) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@ -171,20 +171,20 @@ impl GeoBlock {
|
||||
}
|
||||
}
|
||||
|
||||
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
|
||||
let mut v6_entries: Vec<(Key<u128>, u8)> = Vec::new();
|
||||
let mut v4_entries: HashSet<(u32, u32)> = HashSet::new();
|
||||
let mut v6_entries: HashSet<(u128, u32)> = HashSet::new();
|
||||
|
||||
if !countries.is_empty() {
|
||||
let index = self.index()?;
|
||||
for code in countries {
|
||||
if let Some(prefixes) = index.v4.get(code) {
|
||||
for &(ip_be, prefix_len) in prefixes {
|
||||
v4_entries.push((Key::new(prefix_len, ip_be), 1u8));
|
||||
for &entry in prefixes {
|
||||
v4_entries.insert(entry);
|
||||
}
|
||||
}
|
||||
if let Some(prefixes) = index.v6.get(code) {
|
||||
for &(ip_be, prefix_len) in prefixes {
|
||||
v6_entries.push((Key::new(prefix_len, ip_be), 1u8));
|
||||
for &entry in prefixes {
|
||||
v6_entries.insert(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -196,44 +196,49 @@ impl GeoBlock {
|
||||
(Some(v4), Some(v6)) => (v4, v6),
|
||||
_ => Err(EbpfError::NotLoaded)?,
|
||||
};
|
||||
Self::clear_trie_v4(v4_trie)?;
|
||||
Self::clear_trie_v6(v6_trie)?;
|
||||
|
||||
let mut count = 0u64;
|
||||
for (key, val) in &v4_entries {
|
||||
if v4_trie.insert(key, *val, 0).is_ok() {
|
||||
for &(ip_be, prefix_len) in &v4_entries {
|
||||
let key = Key::new(prefix_len, ip_be);
|
||||
if v4_trie.insert(&key, 1u8, 0).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
for (key, val) in &v6_entries {
|
||||
if v6_trie.insert(key, *val, 0).is_ok() {
|
||||
for &(ip_be, prefix_len) in &v6_entries {
|
||||
let key = Key::new(prefix_len, ip_be);
|
||||
if v6_trie.insert(&key, 1u8, 0).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Self::remove_stale_v4(v4_trie, &v4_entries);
|
||||
Self::remove_stale_v6(v6_trie, &v6_entries);
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn clear_trie_v4(trie: &mut LpmTrie<MapData, u32, u8>) -> Result<(), Error> {
|
||||
let keys: Vec<Key<u32>> = trie
|
||||
fn remove_stale_v4(trie: &mut LpmTrie<MapData, u32, u8>, desired: &HashSet<(u32, u32)>) {
|
||||
let stale: Vec<Key<u32>> = trie
|
||||
.iter()
|
||||
.map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for key in keys {
|
||||
trie.remove(&key).map_err(EbpfError::MapOperationError)?;
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !desired.contains(&(key.data(), key.prefix_len())))
|
||||
.collect();
|
||||
for key in stale {
|
||||
let _ = trie.remove(&key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_trie_v6(trie: &mut LpmTrie<MapData, u128, u8>) -> Result<(), Error> {
|
||||
let keys: Vec<Key<u128>> = trie
|
||||
fn remove_stale_v6(trie: &mut LpmTrie<MapData, u128, u8>, desired: &HashSet<(u128, u32)>) {
|
||||
let stale: Vec<Key<u128>> = trie
|
||||
.iter()
|
||||
.map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for key in keys {
|
||||
trie.remove(&key).map_err(EbpfError::MapOperationError)?;
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !desired.contains(&(key.data(), key.prefix_len())))
|
||||
.collect();
|
||||
for key in stale {
|
||||
let _ = trie.remove(&key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ use arc_swap::ArcSwap;
|
||||
use aya::Ebpf;
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use crossbeam::queue::SegQueue;
|
||||
use macros::log;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
@ -22,7 +21,6 @@ use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
|
||||
use crate::adapter::ebpf::rate_limit::RateLimitConfig;
|
||||
use crate::adapter::ebpf::xsk_manager::XskManager;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::system::SystemError;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::data_plane::dns_query_filter::DnsQueryFilter;
|
||||
@ -105,9 +103,7 @@ impl EbpfServices {
|
||||
|
||||
pub fn terminate(self: Arc<Self>) {
|
||||
while let Some(shutdown) = self.shutdowns.pop() {
|
||||
if shutdown.send(()).is_err() {
|
||||
log!(SystemError::ShutdownSignalFailed);
|
||||
}
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,8 @@ use crate::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::interface::data_plane::protocol_filter::ProtocolFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::HttpFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::SshFilterPort;
|
||||
use netguardia_abi::model::empty::EmptyMapValue;
|
||||
use netguardia_abi::model::http_method::{HttpMethod, HttpMethodBitmap};
|
||||
use netguardia_abi::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
|
||||
@ -85,7 +86,7 @@ fn require_v6_ip(ip: IpAddr) -> Result<Ipv6Addr, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolFilterPort for ProtocolFilter {
|
||||
impl HttpFilterPort for ProtocolFilter {
|
||||
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
@ -135,7 +136,9 @@ impl ProtocolFilterPort for ProtocolFilter {
|
||||
.remove_http_service(require_v6_socket(address)?, methods),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SshFilterPort for ProtocolFilter {
|
||||
fn is_ssh_white_list_enable(&self) -> bool {
|
||||
self.ssh_white_list_enable.read().is_white_list_enable()
|
||||
}
|
||||
|
||||
@ -14,21 +14,7 @@ pub fn initialize() -> Scope {
|
||||
|
||||
async fn list_audit_logs(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
|
||||
match audit.list_audit_logs().await {
|
||||
Ok(entries) => {
|
||||
let json: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.id,
|
||||
"actor": e.actor,
|
||||
"action": e.action,
|
||||
"detail": e.detail,
|
||||
"created_at": e.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(json)
|
||||
}
|
||||
Ok(entries) => HttpResponse::Ok().json(entries),
|
||||
Err(e) => internal_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ use crate::common::error::Error;
|
||||
use crate::core::data_plane::dns_filter_service::DnsFilterService;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::interface::data_plane::protocol_filter::ProtocolFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::HttpFilterPort;
|
||||
use crate::interface::data_plane::protocol_filter::SshFilterPort;
|
||||
use netguardia_abi::model::http_method::HttpMethod;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
@ -125,7 +126,7 @@ fn ssh_blacklist_scope() -> Scope {
|
||||
.route("/{version}", web::delete().to(remove_ssh_black_list))
|
||||
}
|
||||
|
||||
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn HttpFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
@ -135,7 +136,7 @@ async fn get_http_service(path: web::Path<String>, service: web::Data<dyn Protoc
|
||||
async fn add_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn HttpFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -147,7 +148,7 @@ async fn add_http_service(
|
||||
async fn remove_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn HttpFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -156,7 +157,7 @@ async fn remove_http_service(
|
||||
protocol_filter_result(service.remove_http_service(version, addr, methods))
|
||||
}
|
||||
|
||||
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
@ -166,7 +167,7 @@ async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn Protoco
|
||||
async fn add_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -177,7 +178,7 @@ async fn add_ssh_service(
|
||||
async fn remove_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -185,21 +186,21 @@ async fn remove_ssh_service(
|
||||
protocol_filter_result(service.remove_ssh_service(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"enabled": service.is_ssh_white_list_enable(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn enable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
protocol_filter_result(service.enable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
protocol_filter_result(service.disable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
@ -209,7 +210,7 @@ async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn Prot
|
||||
async fn add_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -220,7 +221,7 @@ async fn add_ssh_white_list(
|
||||
async fn remove_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -228,7 +229,7 @@ async fn remove_ssh_white_list(
|
||||
protocol_filter_result(service.remove_ssh_white_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
};
|
||||
@ -238,7 +239,7 @@ async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn Prot
|
||||
async fn add_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
@ -249,7 +250,7 @@ async fn add_ssh_black_list(
|
||||
async fn remove_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
service: web::Data<dyn SshFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return bad_request("invalid IP version");
|
||||
|
||||
@ -13,11 +13,11 @@ use crate::core::inference::model_adapter::ModelSourceState;
|
||||
use crate::core::inference::model_watcher::{ModelReloadOutcome, reload_model_from_disk};
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
|
||||
use crate::domain::common::config::constants::{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, PERMISSION_USERS_ADMIN};
|
||||
use crate::infrastructure::model_promotion_deps::ModelPromotionDeps;
|
||||
use crate::interface::system::audit::AuditRepo;
|
||||
|
||||
const MODEL_LIFECYCLE_REQUIRED_PERMISSION: &str = "users:admin";
|
||||
const MODEL_LIFECYCLE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN;
|
||||
const AUDIT_ACTION_MODEL_DORMANT: &str = "model_dormant";
|
||||
const AUDIT_ACTION_MODEL_ENABLE: &str = "model_enable";
|
||||
|
||||
@ -183,10 +183,10 @@ async fn enable_current_model(
|
||||
let audit_detail = serde_json::json!({
|
||||
"before": before_json,
|
||||
"after": {
|
||||
"name": info.name,
|
||||
"adapter_kind": info.adapter_kind,
|
||||
"name": info.name(),
|
||||
"adapter_kind": info.adapter_kind(),
|
||||
"loaded_at_secs": info.loaded_at_secs,
|
||||
"features_count": info.features_count,
|
||||
"features_count": info.features_count(),
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
@ -234,7 +234,7 @@ async fn enable_current_model(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@ -254,7 +254,11 @@ mod tests {
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::domain::detection::model_source::ModelSourceStatus;
|
||||
use crate::domain::detection::{
|
||||
error::MLError, manifest::AdapterKind, manifest::ModelManifest, manifest::ModelPaths,
|
||||
error::MLError,
|
||||
manifest::{
|
||||
AdapterKind, AlertRuleSpec, LabelSpec, ModelManifest, ModelSpec, OutputHeadSpec, OutputSemantic,
|
||||
PreprocessingStep,
|
||||
},
|
||||
};
|
||||
use crate::domain::identity::auth::Claims;
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
@ -318,6 +322,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_manifest() -> ModelManifest {
|
||||
ModelManifest {
|
||||
name: "test-model".to_string(),
|
||||
version: 2,
|
||||
models: vec![
|
||||
ModelSpec {
|
||||
id: "anomaly_detector".to_string(),
|
||||
file: "deep_autoencoder.onnx".to_string(),
|
||||
input_features: vec!["Destination Port".to_string()],
|
||||
preprocessing: vec![PreprocessingStep::StandardScaler {
|
||||
sidecar: "sidecar.json".to_string(),
|
||||
}],
|
||||
outputs: vec![OutputHeadSpec {
|
||||
name: "ae_anomaly_score".to_string(),
|
||||
shape: vec!["1".to_string()],
|
||||
semantic: OutputSemantic::AnomalyScore,
|
||||
threshold: Some(0.5),
|
||||
min_confidence: None,
|
||||
}],
|
||||
},
|
||||
ModelSpec {
|
||||
id: "classifier".to_string(),
|
||||
file: "classifier.onnx".to_string(),
|
||||
input_features: vec!["Destination Port".to_string(), "ae_anomaly_score".to_string()],
|
||||
preprocessing: vec![],
|
||||
outputs: vec![OutputHeadSpec {
|
||||
name: "class_probs".to_string(),
|
||||
shape: vec!["2".to_string()],
|
||||
semantic: OutputSemantic::Multiclass,
|
||||
threshold: None,
|
||||
min_confidence: Some(0.4),
|
||||
}],
|
||||
},
|
||||
],
|
||||
pipeline: vec!["anomaly_detector".to_string(), "classifier".to_string()],
|
||||
labels: BTreeMap::from([(
|
||||
"0".to_string(),
|
||||
LabelSpec {
|
||||
name: "Bot".to_string(),
|
||||
confirmations: Some(1),
|
||||
playbook: None,
|
||||
},
|
||||
)]),
|
||||
alert_rules: vec![AlertRuleSpec {
|
||||
condition: "class_probs.max > min_confidence".to_string(),
|
||||
source_label: "class_probs".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn inference_with_error_state() -> web::Data<Inference> {
|
||||
web::Data::new(Inference::new(
|
||||
ModelSourceState::Error {
|
||||
@ -388,39 +442,14 @@ mod tests {
|
||||
|
||||
impl ModelConfigLoader for FakeConfigLoader {
|
||||
fn load_manifest(&self, _manifest_path: &Path) -> Result<ModelManifest, MLError> {
|
||||
Ok(ModelManifest {
|
||||
name: "test-model".to_string(),
|
||||
adapter: AdapterKind::ClassifierOnly,
|
||||
models: ModelPaths {
|
||||
model: Some("classifier.onnx".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
features: vec!["Destination Port".to_string()],
|
||||
labels: Default::default(),
|
||||
thresholds: Default::default(),
|
||||
preprocessing: None,
|
||||
})
|
||||
Ok(test_manifest())
|
||||
}
|
||||
|
||||
fn load_manifest_with_sidecar(
|
||||
&self,
|
||||
_manifest_path: &Path,
|
||||
) -> Result<(MLInferenceConfig, ModelManifest), MLError> {
|
||||
Ok((
|
||||
test_inference_config(),
|
||||
ModelManifest {
|
||||
name: "test-model".to_string(),
|
||||
adapter: AdapterKind::ClassifierOnly,
|
||||
models: ModelPaths {
|
||||
model: Some("classifier.onnx".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
features: vec!["Destination Port".to_string()],
|
||||
labels: Default::default(),
|
||||
thresholds: Default::default(),
|
||||
preprocessing: None,
|
||||
},
|
||||
))
|
||||
Ok((test_inference_config(), test_manifest()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,24 +7,27 @@ use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use futures_util::TryStreamExt;
|
||||
use macros::log;
|
||||
use std::io::{self, Read};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::inference::model_promotion::{PromoteError, PromoteGate, StagedModelPromotion, validate_and_promote};
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::PERMISSION_USERS_ADMIN;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
|
||||
use crate::infrastructure::model_promotion_deps::ModelPromotionDeps;
|
||||
use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
|
||||
use crate::interface::system::audit::AuditRepo;
|
||||
|
||||
const FIELD_MANIFEST: &str = "manifest";
|
||||
const FIELD_ONNX: &str = "onnx";
|
||||
const FIELD_SCALER: &str = "scaler";
|
||||
const FIELD_BUNDLE: &str = "bundle";
|
||||
const BUNDLE_FILENAME: &str = "model_bundle.zip";
|
||||
#[cfg(test)]
|
||||
const ONNX_SNIFF_BYTES: usize = 16;
|
||||
const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin";
|
||||
const PROMOTE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/ml/models").route("/upload", web::post().to(upload))
|
||||
@ -51,6 +54,12 @@ async fn upload(
|
||||
|
||||
let config = app_config.load();
|
||||
let caps = UploadCaps {
|
||||
bundle: config
|
||||
.ml
|
||||
.model_upload
|
||||
.max_manifest_bytes
|
||||
.saturating_add(config.ml.model_upload.max_onnx_bytes)
|
||||
.saturating_add(config.ml.model_upload.max_scaler_bytes),
|
||||
manifest: config.ml.model_upload.max_manifest_bytes,
|
||||
onnx: config.ml.model_upload.max_onnx_bytes,
|
||||
scaler: config.ml.model_upload.max_scaler_bytes,
|
||||
@ -67,8 +76,6 @@ async fn upload(
|
||||
};
|
||||
let outcome = validate_and_promote(&StagedModelPromotion {
|
||||
staging_dir: &staging_dir,
|
||||
uploaded_onnx_filename: &summary.onnx_filename,
|
||||
uploaded_scaler_filename: summary.scaler_filename.as_deref(),
|
||||
inference: inference.get_ref(),
|
||||
audit_repo: audit_repo.get_ref(),
|
||||
promote_gate: promote_lock.get_ref(),
|
||||
@ -87,6 +94,7 @@ async fn upload(
|
||||
Ok(report) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"promoted": true,
|
||||
"staging_id": staging_id,
|
||||
"bundle_bytes": summary.bundle_bytes,
|
||||
"manifest_bytes": summary.manifest_bytes,
|
||||
"onnx_bytes": summary.onnx_bytes,
|
||||
"scaler_bytes": summary.scaler_bytes,
|
||||
@ -113,15 +121,15 @@ async fn cleanup_staging_dir(staging_dir: &Path) {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UploadSummary {
|
||||
bundle_bytes: usize,
|
||||
manifest_bytes: usize,
|
||||
onnx_bytes: usize,
|
||||
onnx_filename: String,
|
||||
scaler_filename: Option<String>,
|
||||
scaler_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct UploadCaps {
|
||||
bundle: usize,
|
||||
manifest: usize,
|
||||
onnx: usize,
|
||||
scaler: usize,
|
||||
@ -133,10 +141,13 @@ enum UploadError {
|
||||
DuplicateField(&'static str),
|
||||
FilenameConflict(String),
|
||||
UnknownField(String),
|
||||
BundleTooLarge(usize),
|
||||
BundleNotZip,
|
||||
BundleEntryInvalid(String),
|
||||
BundleExtractionFailed(String),
|
||||
ManifestTooLarge(usize),
|
||||
OnnxTooLarge(usize),
|
||||
ScalerTooLarge(usize),
|
||||
OnnxNotBinary,
|
||||
StreamFailure(String),
|
||||
StagingSetupFailure(String),
|
||||
}
|
||||
@ -151,13 +162,13 @@ impl UploadError {
|
||||
format!("multipart filename conflicts with another upload file: {name}"),
|
||||
),
|
||||
Self::UnknownField(name) => (400, format!("unexpected multipart field: {name}")),
|
||||
Self::BundleTooLarge(max_bytes) => (413, format!("bundle exceeds {max_bytes} bytes")),
|
||||
Self::BundleNotZip => (400, "bundle field does not look like a zip archive".to_string()),
|
||||
Self::BundleEntryInvalid(err) => (422, format!("invalid bundle entry: {err}")),
|
||||
Self::BundleExtractionFailed(err) => (422, format!("bundle extraction failed: {err}")),
|
||||
Self::ManifestTooLarge(max_bytes) => (413, format!("manifest exceeds {max_bytes} bytes")),
|
||||
Self::OnnxTooLarge(max_bytes) => (413, format!("onnx exceeds {max_bytes} bytes")),
|
||||
Self::ScalerTooLarge(max_bytes) => (413, format!("scaler exceeds {max_bytes} bytes")),
|
||||
Self::OnnxNotBinary => (
|
||||
400,
|
||||
"onnx field does not look like a protobuf-encoded ONNX model".to_string(),
|
||||
),
|
||||
Self::StreamFailure(err) => (400, format!("upload stream error: {err}")),
|
||||
Self::StagingSetupFailure(err) => (500, format!("staging directory error: {err}")),
|
||||
};
|
||||
@ -179,10 +190,7 @@ async fn ingest_multipart(
|
||||
.await
|
||||
.map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?;
|
||||
|
||||
let mut manifest_written: Option<usize> = None;
|
||||
let mut onnx_summary: Option<(String, usize)> = None;
|
||||
let mut scaler_summary: Option<(String, usize)> = None;
|
||||
let mut upload_filenames = vec![MANIFEST_FILENAME.to_string()];
|
||||
let mut bundle_summary: Option<usize> = None;
|
||||
|
||||
while let Some(mut field) = payload
|
||||
.try_next()
|
||||
@ -195,41 +203,19 @@ async fn ingest_multipart(
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match field_name.as_str() {
|
||||
FIELD_MANIFEST => {
|
||||
if manifest_written.is_some() {
|
||||
return Err(UploadError::DuplicateField(FIELD_MANIFEST));
|
||||
FIELD_BUNDLE => {
|
||||
if bundle_summary.is_some() {
|
||||
return Err(UploadError::DuplicateField(FIELD_BUNDLE));
|
||||
}
|
||||
let dest = staging_dir.join(MANIFEST_FILENAME);
|
||||
let written = stream_field_to_file(&mut field, &dest, caps.manifest, FieldKind::Manifest).await?;
|
||||
manifest_written = Some(written);
|
||||
}
|
||||
FIELD_ONNX => {
|
||||
if onnx_summary.is_some() {
|
||||
return Err(UploadError::DuplicateField(FIELD_ONNX));
|
||||
}
|
||||
let onnx_filename = field
|
||||
let _uploaded_name = field
|
||||
.content_disposition()
|
||||
.and_then(|cd| cd.get_filename())
|
||||
.map(sanitize_filename)
|
||||
.unwrap_or_else(|| "model.onnx".to_string());
|
||||
reserve_upload_filename(&mut upload_filenames, &onnx_filename)?;
|
||||
let dest = staging_dir.join(&onnx_filename);
|
||||
let written = stream_field_to_file(&mut field, &dest, caps.onnx, FieldKind::Onnx).await?;
|
||||
onnx_summary = Some((onnx_filename, written));
|
||||
}
|
||||
FIELD_SCALER => {
|
||||
if scaler_summary.is_some() {
|
||||
return Err(UploadError::DuplicateField(FIELD_SCALER));
|
||||
}
|
||||
let scaler_filename = field
|
||||
.content_disposition()
|
||||
.and_then(|cd| cd.get_filename())
|
||||
.map(sanitize_filename)
|
||||
.unwrap_or_else(|| "inference_config.json".to_string());
|
||||
reserve_upload_filename(&mut upload_filenames, &scaler_filename)?;
|
||||
let dest = staging_dir.join(&scaler_filename);
|
||||
let written = stream_field_to_file(&mut field, &dest, caps.scaler, FieldKind::Scaler).await?;
|
||||
scaler_summary = Some((scaler_filename, written));
|
||||
.unwrap_or_else(|| BUNDLE_FILENAME.to_string());
|
||||
let bundle_filename = BUNDLE_FILENAME.to_string();
|
||||
let dest = staging_dir.join(&bundle_filename);
|
||||
let written = stream_field_to_file(&mut field, &dest, caps.bundle).await?;
|
||||
bundle_summary = Some(written);
|
||||
}
|
||||
other => {
|
||||
return Err(UploadError::UnknownField(other.to_string()));
|
||||
@ -237,40 +223,28 @@ async fn ingest_multipart(
|
||||
}
|
||||
}
|
||||
|
||||
let manifest_bytes = manifest_written.ok_or(UploadError::MissingField(FIELD_MANIFEST))?;
|
||||
let (onnx_filename, onnx_bytes) = onnx_summary.ok_or(UploadError::MissingField(FIELD_ONNX))?;
|
||||
let (scaler_filename, scaler_bytes) = match scaler_summary {
|
||||
Some((name, n)) => (Some(name), Some(n)),
|
||||
None => (None, None),
|
||||
};
|
||||
let bundle_bytes = bundle_summary.ok_or(UploadError::MissingField(FIELD_BUNDLE))?;
|
||||
let bundle_path = staging_dir.join(BUNDLE_FILENAME);
|
||||
let extracted = extract_bundle_zip(&bundle_path, staging_dir, caps)?;
|
||||
|
||||
Ok(UploadSummary {
|
||||
manifest_bytes,
|
||||
onnx_bytes,
|
||||
onnx_filename,
|
||||
scaler_filename,
|
||||
scaler_bytes,
|
||||
bundle_bytes,
|
||||
manifest_bytes: extracted.manifest_bytes,
|
||||
onnx_bytes: extracted.onnx_bytes,
|
||||
scaler_bytes: extracted.scaler_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FieldKind {
|
||||
Manifest,
|
||||
Onnx,
|
||||
Scaler,
|
||||
}
|
||||
|
||||
async fn stream_field_to_file(
|
||||
field: &mut actix_multipart::Field,
|
||||
dest: &Path,
|
||||
max_bytes: usize,
|
||||
kind: FieldKind,
|
||||
) -> Result<usize, UploadError> {
|
||||
let mut file = fs::File::create(dest)
|
||||
.await
|
||||
.map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?;
|
||||
let mut total = 0usize;
|
||||
let mut sniffed = kind != FieldKind::Onnx;
|
||||
let mut sniffed = false;
|
||||
|
||||
while let Some(chunk) = field
|
||||
.try_next()
|
||||
@ -278,18 +252,14 @@ async fn stream_field_to_file(
|
||||
.map_err(|e| UploadError::StreamFailure(e.to_string()))?
|
||||
{
|
||||
if !sniffed {
|
||||
if !looks_like_onnx(&chunk) {
|
||||
return Err(UploadError::OnnxNotBinary);
|
||||
if !looks_like_zip(&chunk) {
|
||||
return Err(UploadError::BundleNotZip);
|
||||
}
|
||||
sniffed = true;
|
||||
}
|
||||
total = total.saturating_add(chunk.len());
|
||||
if total > max_bytes {
|
||||
return Err(match kind {
|
||||
FieldKind::Manifest => UploadError::ManifestTooLarge(max_bytes),
|
||||
FieldKind::Onnx => UploadError::OnnxTooLarge(max_bytes),
|
||||
FieldKind::Scaler => UploadError::ScalerTooLarge(max_bytes),
|
||||
});
|
||||
return Err(UploadError::BundleTooLarge(max_bytes));
|
||||
}
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
@ -309,6 +279,105 @@ fn reserve_upload_filename(used: &mut Vec<String>, filename: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BundleExtractionSummary {
|
||||
manifest_bytes: usize,
|
||||
onnx_bytes: usize,
|
||||
scaler_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
fn extract_bundle_zip(
|
||||
bundle_path: &Path,
|
||||
staging_dir: &Path,
|
||||
caps: UploadCaps,
|
||||
) -> Result<BundleExtractionSummary, UploadError> {
|
||||
let file = std::fs::File::open(bundle_path).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?;
|
||||
|
||||
let mut seen_files: Vec<String> = Vec::with_capacity(archive.len());
|
||||
let mut manifest_bytes = None;
|
||||
let mut onnx_bytes = 0usize;
|
||||
let mut scaler_bytes = None;
|
||||
let mut total_uncompressed = 0usize;
|
||||
|
||||
for idx in 0..archive.len() {
|
||||
let entry = archive
|
||||
.by_index(idx)
|
||||
.map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let enclosed = entry
|
||||
.enclosed_name()
|
||||
.ok_or_else(|| UploadError::BundleEntryInvalid(entry.name().to_string()))?;
|
||||
if enclosed.components().count() != 1 {
|
||||
return Err(UploadError::BundleEntryInvalid(format!(
|
||||
"nested archive paths are not allowed: {}",
|
||||
enclosed.display()
|
||||
)));
|
||||
}
|
||||
let filename = enclosed
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.ok_or_else(|| UploadError::BundleEntryInvalid(entry.name().to_string()))?;
|
||||
reserve_upload_filename(&mut seen_files, filename)?;
|
||||
|
||||
let dest = staging_dir.join(filename);
|
||||
let mut out = std::fs::File::create(&dest).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?;
|
||||
let max_for_file = match filename {
|
||||
MANIFEST_FILENAME => caps.manifest,
|
||||
_ if filename.ends_with(".onnx") => caps.onnx,
|
||||
_ if filename.ends_with(".json") => caps.scaler,
|
||||
_ => caps.bundle,
|
||||
};
|
||||
let copied = io::copy(&mut entry.take((max_for_file as u64).saturating_add(1)), &mut out)
|
||||
.map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?;
|
||||
let copied = usize::try_from(copied)
|
||||
.map_err(|_| UploadError::BundleExtractionFailed("copied size overflow".to_string()))?;
|
||||
if copied > max_for_file {
|
||||
return Err(match filename {
|
||||
MANIFEST_FILENAME => UploadError::ManifestTooLarge(caps.manifest),
|
||||
_ if filename.ends_with(".onnx") => UploadError::OnnxTooLarge(caps.onnx),
|
||||
_ if filename.ends_with(".json") => UploadError::ScalerTooLarge(caps.scaler),
|
||||
_ => UploadError::BundleExtractionFailed(format!("entry '{filename}' exceeds extraction cap")),
|
||||
});
|
||||
}
|
||||
total_uncompressed = total_uncompressed.saturating_add(copied);
|
||||
if total_uncompressed > caps.bundle {
|
||||
return Err(UploadError::BundleExtractionFailed(
|
||||
"bundle expands beyond configured upload limits".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match filename {
|
||||
MANIFEST_FILENAME => {
|
||||
manifest_bytes = Some(copied);
|
||||
}
|
||||
_ if filename.ends_with(".onnx") => {
|
||||
onnx_bytes = onnx_bytes.saturating_add(copied);
|
||||
}
|
||||
_ if filename.ends_with(".json") => {
|
||||
scaler_bytes = Some(copied);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest_bytes = manifest_bytes
|
||||
.ok_or_else(|| UploadError::BundleExtractionFailed("manifest.yaml missing from bundle".to_string()))?;
|
||||
if onnx_bytes == 0 {
|
||||
return Err(UploadError::BundleExtractionFailed(
|
||||
"no onnx model files found in bundle".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(BundleExtractionSummary {
|
||||
manifest_bytes,
|
||||
onnx_bytes,
|
||||
scaler_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sanitize_filename(raw: impl AsRef<str>) -> String {
|
||||
let raw = raw.as_ref();
|
||||
let trimmed = raw.rsplit(['/', '\\']).next().unwrap_or("model.onnx");
|
||||
@ -319,6 +388,7 @@ pub fn sanitize_filename(raw: impl AsRef<str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
|
||||
if first_chunk.is_empty() {
|
||||
return false;
|
||||
@ -341,15 +411,17 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn looks_like_zip(first_chunk: &[u8]) -> bool {
|
||||
first_chunk.starts_with(b"PK")
|
||||
}
|
||||
|
||||
fn promote_error_response(error: PromoteError) -> HttpResponse {
|
||||
let (status, message) = match error {
|
||||
PromoteError::ManifestInvalid { err } => (422, format!("manifest invalid: {err}")),
|
||||
PromoteError::ValidationFailed { err } => (422, format!("model failed validation: {err}")),
|
||||
PromoteError::UnsupportedAdapter => (
|
||||
422,
|
||||
"multi_task adapter is not supported by the v1 upload flow — \
|
||||
submit an autoencoder_only or classifier_only manifest"
|
||||
.to_string(),
|
||||
"unsupported adapter kind for the current model promotion flow".to_string(),
|
||||
),
|
||||
PromoteError::StagingIo { operation, err } => (500, format!("staging io error during {operation}: {err}")),
|
||||
PromoteError::PromoteIo { operation, err } => (500, format!("promote io error during {operation}: {err}")),
|
||||
@ -405,6 +477,12 @@ mod tests {
|
||||
assert!(looks_like_onnx(&buf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_sniff_accepts_local_file_header() {
|
||||
assert!(looks_like_zip(b"PK\x03\x04"));
|
||||
assert!(!looks_like_zip(b"name: not-a-zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_filename_strips_directory_components() {
|
||||
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
|
||||
|
||||
@ -8,6 +8,7 @@ use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::domain::identity::auth::PermissionLevel;
|
||||
use crate::domain::identity::validation::validate_api_key_name;
|
||||
use crate::interface::identity::api_key::ApiKeyRepo;
|
||||
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/api-keys")
|
||||
@ -46,6 +47,7 @@ struct GenerateKeyRequest {
|
||||
async fn generate_key(
|
||||
_auth: AuthClaims,
|
||||
db: web::Data<dyn ApiKeyRepo>,
|
||||
hasher: web::Data<dyn ApiKeyHasher>,
|
||||
body: web::Json<GenerateKeyRequest>,
|
||||
) -> HttpResponse {
|
||||
let raw_key: String = rand::rng()
|
||||
@ -54,7 +56,7 @@ async fn generate_key(
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let key_hash = db.hmac_api_key(&raw_key);
|
||||
let key_hash = hasher.hash_api_key(&raw_key);
|
||||
|
||||
let raw_level = body.level.as_deref().unwrap_or("read_only");
|
||||
let Some(level) = PermissionLevel::from_str(raw_level) else {
|
||||
|
||||
@ -5,9 +5,12 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::adapter::http::helpers::{bad_request, conflict, forbidden, internal_error, json_error, not_found};
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::http::session::SessionCookieService;
|
||||
use crate::core::identity::auth_service::{AuthService, IdentityAdminError, LoginError, RegisterError, UserProfile};
|
||||
use crate::core::identity::auth_service::AuthService;
|
||||
use crate::core::identity::group_service::GroupService;
|
||||
use crate::core::identity::session_service::SessionService;
|
||||
use crate::core::identity::user_service::{UserProfile, UserService};
|
||||
use crate::domain::identity::auth::{Claims, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::{GroupError, LoginError, RegisterError, UserError};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginRequest {
|
||||
@ -28,6 +31,36 @@ struct ChangePasswordRequest {
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateRoleRequest {
|
||||
role: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetPasswordRequest {
|
||||
new_password: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateGroupRequest {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
permissions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateGroupRequest {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
permissions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetUserGroupsRequest {
|
||||
group_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
id: i64,
|
||||
@ -57,16 +90,16 @@ fn append_session_removal_cookies(response: &mut HttpResponseBuilder, cookie_ser
|
||||
}
|
||||
}
|
||||
|
||||
async fn invalidate_group_member_sessions(auth_svc: &AuthService, session_service: &SessionService, group_id: i64) {
|
||||
if let Ok(Some(group)) = auth_svc.get_group(group_id).await {
|
||||
async fn invalidate_group_member_sessions(group_svc: &GroupService, session_service: &SessionService, group_id: i64) {
|
||||
if let Ok(Some(group)) = group_svc.get_group(group_id).await {
|
||||
for user_id in group.members {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn group_member_ids(auth_svc: &AuthService, group_id: i64) -> Vec<i64> {
|
||||
match auth_svc.get_group(group_id).await {
|
||||
async fn group_member_ids(group_svc: &GroupService, group_id: i64) -> Vec<i64> {
|
||||
match group_svc.get_group(group_id).await {
|
||||
Ok(Some(group)) => group.members,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@ -149,23 +182,23 @@ async fn register(
|
||||
.await
|
||||
{
|
||||
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
|
||||
Err(RegisterError::Validation(msg)) => bad_request(msg),
|
||||
Err(e @ RegisterError::Validation { .. }) => bad_request(e.to_string()),
|
||||
Err(RegisterError::InvalidRole) => bad_request("Role must be 'admin' or 'viewer'"),
|
||||
Err(RegisterError::Forbidden) => forbidden("Only administrators can create admin accounts"),
|
||||
Err(RegisterError::HashFailed) => internal_error("Failed to hash password"),
|
||||
Err(RegisterError::Conflict(e)) => conflict(e),
|
||||
Err(RegisterError::Internal(e)) => internal_error(e),
|
||||
Err(e @ RegisterError::Conflict { .. }) => conflict(e.to_string()),
|
||||
Err(e @ RegisterError::Internal { .. }) => internal_error(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn me(
|
||||
req: HttpRequest,
|
||||
auth: AuthClaims,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
cookie_service: web::Data<SessionCookieService>,
|
||||
) -> impl Responder {
|
||||
match auth_svc.user_profile(auth.sub, &auth.username).await {
|
||||
match user_svc.user_profile(auth.sub, &auth.username).await {
|
||||
Ok(profile) => {
|
||||
let csrf_token = req
|
||||
.cookie(cookie_service.cookie_name())
|
||||
@ -179,13 +212,13 @@ async fn me(
|
||||
async fn change_password(
|
||||
auth: AuthClaims,
|
||||
body: web::Json<ChangePasswordRequest>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
cookie_service: web::Data<SessionCookieService>,
|
||||
) -> impl Responder {
|
||||
let change_req = body.into_inner();
|
||||
|
||||
match auth_svc
|
||||
match user_svc
|
||||
.change_password(auth.sub, &change_req.current_password, &change_req.new_password)
|
||||
.await
|
||||
{
|
||||
@ -195,12 +228,12 @@ async fn change_password(
|
||||
append_session_removal_cookies(&mut response, &cookie_service);
|
||||
response.json(serde_json::json!({"message": "Password changed successfully"}))
|
||||
}
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => user_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_users(_auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
match auth_svc.list_users().await {
|
||||
async fn list_users(_auth: AuthClaims, user_svc: web::Data<UserService>) -> impl Responder {
|
||||
match user_svc.list_users().await {
|
||||
Ok(users) => HttpResponse::Ok().json(users),
|
||||
Err(e) => internal_error(e),
|
||||
}
|
||||
@ -209,68 +242,63 @@ async fn list_users(_auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl
|
||||
async fn delete_user(
|
||||
auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
match auth_svc.delete_user(auth.sub, user_id).await {
|
||||
match user_svc.delete_user(auth.sub, user_id).await {
|
||||
Ok(true) => {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"}))
|
||||
}
|
||||
Ok(false) => not_found("User not found"),
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => user_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
_auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
body: web::Json<UpdateRoleRequest>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
let role = match body.get("role").and_then(|v| v.as_str()) {
|
||||
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
|
||||
_ => {
|
||||
return bad_request("Role must be 'admin' or 'viewer'");
|
||||
}
|
||||
};
|
||||
if req.role != ROLE_ADMIN && req.role != ROLE_VIEWER {
|
||||
return bad_request("Role must be 'admin' or 'viewer'");
|
||||
}
|
||||
|
||||
match auth_svc.update_role(_auth.sub, user_id, role).await {
|
||||
match user_svc.update_role(_auth.sub, user_id, &req.role).await {
|
||||
Ok(_) => {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": role}))
|
||||
HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": req.role}))
|
||||
}
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => user_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
_auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
body: web::Json<ResetPasswordRequest>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
cookie_service: web::Data<SessionCookieService>,
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
let new_password = match body
|
||||
.get("new_password")
|
||||
.or_else(|| body.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let new_password = match req.new_password.as_deref().or(req.password.as_deref()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return bad_request("Password is required");
|
||||
}
|
||||
};
|
||||
|
||||
match auth_svc.reset_password(user_id, new_password).await {
|
||||
match user_svc.reset_password(user_id, new_password).await {
|
||||
Ok(()) => {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
let mut response = HttpResponse::Ok();
|
||||
@ -279,24 +307,34 @@ async fn reset_password(
|
||||
}
|
||||
response.finish()
|
||||
}
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => user_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_admin_error(err: IdentityAdminError) -> HttpResponse {
|
||||
fn user_error(err: UserError) -> HttpResponse {
|
||||
match err {
|
||||
IdentityAdminError::Validation(message) => bad_request(message),
|
||||
IdentityAdminError::Unauthorized => json_error(StatusCode::UNAUTHORIZED, "Current password is incorrect"),
|
||||
IdentityAdminError::Forbidden(message) => forbidden(message),
|
||||
IdentityAdminError::NotFound(message) => not_found(message),
|
||||
IdentityAdminError::HashFailed => internal_error("Failed to hash password"),
|
||||
IdentityAdminError::Conflict(e) => conflict(e),
|
||||
IdentityAdminError::Internal(e) => internal_error(e),
|
||||
UserError::Validation { .. } => bad_request(err.to_string()),
|
||||
UserError::Unauthorized => json_error(StatusCode::UNAUTHORIZED, "Current password is incorrect"),
|
||||
UserError::Forbidden { .. } => forbidden(err.to_string()),
|
||||
UserError::NotFound { .. } => not_found(err.to_string()),
|
||||
UserError::HashFailed => internal_error("Failed to hash password"),
|
||||
UserError::Conflict { .. } => conflict(err.to_string()),
|
||||
UserError::Internal { .. } => internal_error(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_groups(_auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
match auth_svc.list_groups().await {
|
||||
fn group_error(err: GroupError) -> HttpResponse {
|
||||
match err {
|
||||
GroupError::Validation { .. } => bad_request(err.to_string()),
|
||||
GroupError::Forbidden { .. } => forbidden(err.to_string()),
|
||||
GroupError::NotFound { .. } => not_found(err.to_string()),
|
||||
GroupError::Conflict { .. } => conflict(err.to_string()),
|
||||
GroupError::Internal { .. } => internal_error(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_groups(_auth: AuthClaims, group_svc: web::Data<GroupService>) -> impl Responder {
|
||||
match group_svc.list_groups().await {
|
||||
Ok(groups) => HttpResponse::Ok().json(groups),
|
||||
Err(e) => internal_error(e),
|
||||
}
|
||||
@ -304,26 +342,27 @@ async fn list_groups(_auth: AuthClaims, auth_svc: web::Data<AuthService>) -> imp
|
||||
|
||||
async fn create_group(
|
||||
_auth: AuthClaims,
|
||||
body: web::Json<serde_json::Value>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
body: web::Json<CreateGroupRequest>,
|
||||
group_svc: web::Data<GroupService>,
|
||||
) -> impl Responder {
|
||||
match auth_svc
|
||||
let req = body.into_inner();
|
||||
match group_svc
|
||||
.create_group(
|
||||
body.get("name").and_then(|v| v.as_str()),
|
||||
body.get("description").and_then(|v| v.as_str()),
|
||||
body.get("permissions"),
|
||||
req.name.as_deref(),
|
||||
req.description.as_deref(),
|
||||
req.permissions.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(group) => HttpResponse::Created().json(group),
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => group_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, group_svc: web::Data<GroupService>) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
match auth_svc.get_group(group_id).await {
|
||||
match group_svc.get_group(group_id).await {
|
||||
Ok(Some(group)) => HttpResponse::Ok().json(group),
|
||||
Ok(None) => not_found("Group not found"),
|
||||
Err(e) => internal_error(e),
|
||||
@ -333,39 +372,40 @@ async fn get_group(_auth: AuthClaims, path: web::Path<i64>, auth_svc: web::Data<
|
||||
async fn update_group(
|
||||
_auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
body: web::Json<UpdateGroupRequest>,
|
||||
group_svc: web::Data<GroupService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
match auth_svc
|
||||
match group_svc
|
||||
.update_group(
|
||||
group_id,
|
||||
body.get("name").and_then(|v| v.as_str()),
|
||||
body.get("description").and_then(|v| v.as_str()),
|
||||
body.get("permissions"),
|
||||
req.name.as_deref(),
|
||||
req.description.as_deref(),
|
||||
req.permissions.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(group) => {
|
||||
invalidate_group_member_sessions(&auth_svc, &session_service, group_id).await;
|
||||
invalidate_group_member_sessions(&group_svc, &session_service, group_id).await;
|
||||
HttpResponse::Ok().json(group)
|
||||
}
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => group_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
_auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
group_svc: web::Data<GroupService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
let member_ids = group_member_ids(&auth_svc, group_id).await;
|
||||
let member_ids = group_member_ids(&group_svc, group_id).await;
|
||||
|
||||
match auth_svc.delete_group(group_id).await {
|
||||
match group_svc.delete_group(group_id).await {
|
||||
Ok(true) => {
|
||||
for user_id in member_ids {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
@ -373,50 +413,34 @@ async fn delete_group(
|
||||
HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"}))
|
||||
}
|
||||
Ok(false) => not_found("Group not found"),
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => group_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_user_groups(
|
||||
_auth: AuthClaims,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
body: web::Json<SetUserGroupsRequest>,
|
||||
user_svc: web::Data<UserService>,
|
||||
session_service: web::Data<SessionService>,
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
let group_ids = match parse_group_ids(&body) {
|
||||
Ok(group_ids) => group_ids,
|
||||
Err(message) => return bad_request(message),
|
||||
};
|
||||
|
||||
match auth_svc.set_user_groups(_auth.sub, user_id, &group_ids).await {
|
||||
match user_svc.set_user_groups(_auth.sub, user_id, &req.group_ids).await {
|
||||
Ok(_) => {
|
||||
session_service.remove_sessions_for_user(user_id);
|
||||
HttpResponse::Ok()
|
||||
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids}))
|
||||
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": req.group_ids}))
|
||||
}
|
||||
Err(e) => identity_admin_error(e),
|
||||
Err(e) => user_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_group_ids(body: &serde_json::Value) -> Result<Vec<i64>, &'static str> {
|
||||
let group_ids = body
|
||||
.get("group_ids")
|
||||
.and_then(|value| value.as_array())
|
||||
.ok_or("group_ids array is required")?;
|
||||
|
||||
group_ids
|
||||
.iter()
|
||||
.map(|value| value.as_i64().ok_or("group_ids must contain only integer ids"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_group_ids;
|
||||
use crate::core::identity::auth_service::parse_permissions;
|
||||
use super::SetUserGroupsRequest;
|
||||
use crate::core::identity::user_service::parse_permissions;
|
||||
use crate::domain::identity::validation::{validate_password, validate_username};
|
||||
|
||||
#[test]
|
||||
@ -451,24 +475,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_group_ids_rejects_malformed_entries() {
|
||||
fn set_user_groups_request_rejects_malformed_entries() {
|
||||
let body = serde_json::json!({
|
||||
"group_ids": [1, "2", null]
|
||||
});
|
||||
|
||||
let err = parse_group_ids(&body).expect_err("mixed group ids should fail");
|
||||
let result: Result<SetUserGroupsRequest, _> = serde_json::from_value(body);
|
||||
|
||||
assert_eq!(err, "group_ids must contain only integer ids");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_group_ids_accepts_integer_entries() {
|
||||
fn set_user_groups_request_accepts_integer_entries() {
|
||||
let body = serde_json::json!({
|
||||
"group_ids": [1, 2, 3]
|
||||
});
|
||||
|
||||
let ids = parse_group_ids(&body).expect("valid group ids");
|
||||
let req: SetUserGroupsRequest = serde_json::from_value(body).expect("valid group ids");
|
||||
|
||||
assert_eq!(ids, vec![1, 2, 3]);
|
||||
assert_eq!(req.group_ids, vec![1, 2, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,12 @@ use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::adapter::http::helpers::{bad_request, forbidden, internal_error, json_error, not_found};
|
||||
use crate::common::utils::log_level::level_severity;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::interface::system::live_logs::{LiveLogQuery, LogEntry, level_severity};
|
||||
use crate::interface::system::live_logs::{LiveLogQuery, LogEntry};
|
||||
|
||||
fn is_valid_log_filename(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
@ -151,15 +153,16 @@ async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<App
|
||||
Ok(_) => {}
|
||||
}
|
||||
|
||||
let content = match fs::read(&canonical) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => return internal_error(format!("Failed to read log file: {}", e)),
|
||||
let file = match tokio::fs::File::open(&canonical).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => return internal_error(format!("Failed to open log file: {}", e)),
|
||||
};
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
HttpResponse::Ok()
|
||||
.insert_header(("Content-Type", "application/octet-stream"))
|
||||
.insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename)))
|
||||
.body(content)
|
||||
.streaming(stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -12,9 +12,13 @@ use macros::log;
|
||||
use crate::adapter::http::helpers::json_error;
|
||||
use crate::adapter::http::session::SessionCookieService;
|
||||
use crate::core::identity::session_service::SessionService;
|
||||
use crate::domain::common::config::constants::{
|
||||
PERMISSION_ACCESS_CONTROL_WRITE, PERMISSION_API_KEYS_ADMIN, PERMISSION_USERS_ADMIN,
|
||||
};
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::interface::app_repo::AppRepo;
|
||||
use crate::interface::identity::api_key::ApiKeyRepo;
|
||||
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
|
||||
use crate::interface::identity::auth_repo::LoginAttemptRepo;
|
||||
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
@ -48,7 +52,7 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
{
|
||||
return None;
|
||||
} else if path.starts_with("/api/auth/") {
|
||||
return Some("users:admin".to_string());
|
||||
return Some(PERMISSION_USERS_ADMIN.to_string());
|
||||
} else if path.starts_with("/api/health/") {
|
||||
"dashboard"
|
||||
} else if path.starts_with("/api/stats/drops") {
|
||||
@ -76,9 +80,9 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
} else if path.starts_with("/api/system/") {
|
||||
"system"
|
||||
} else if path == "/api/api-keys" || path.starts_with("/api/api-keys/") {
|
||||
return Some("api_keys:admin".to_string());
|
||||
return Some(PERMISSION_API_KEYS_ADMIN.to_string());
|
||||
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
|
||||
return Some("access_control:write".to_string());
|
||||
return Some(PERMISSION_ACCESS_CONTROL_WRITE.to_string());
|
||||
} else if path.starts_with("/api/soar/")
|
||||
|| path.starts_with("/api/notifications/")
|
||||
|| path == "/api/report"
|
||||
@ -141,10 +145,17 @@ where
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
|
||||
let api_key_hasher = match req.app_data::<web::Data<dyn ApiKeyHasher>>() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "AppRepo not configured");
|
||||
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "ApiKeyHasher not configured");
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
let login_attempt_repo = match req.app_data::<web::Data<dyn LoginAttemptRepo>>() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "LoginAttemptRepo not configured");
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
@ -153,7 +164,7 @@ where
|
||||
"apikey:{}",
|
||||
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
|
||||
);
|
||||
match repo.get_remaining_lock_secs(&rate_key).await {
|
||||
match login_attempt_repo.get_remaining_lock_secs(&rate_key).await {
|
||||
Ok(Some(remaining)) => {
|
||||
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Too many failed API key attempts",
|
||||
@ -162,7 +173,7 @@ where
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = repo.clear_expired_login_lock(&rate_key).await {
|
||||
if let Err(e) = login_attempt_repo.clear_expired_login_lock(&rate_key).await {
|
||||
log!(AuthError::LoginLockoutLookupFailed(e));
|
||||
let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "API key lockout cleanup failed");
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
@ -175,15 +186,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
match api_key_port.validate_api_key(api_key).await {
|
||||
let key_hash = api_key_hasher.hash_api_key(api_key);
|
||||
match api_key_port.validate_api_key(&key_hash).await {
|
||||
Ok(Some(key_claims)) => {
|
||||
if let Err(e) = repo.clear_login_failures(&rate_key).await {
|
||||
if let Err(e) = login_attempt_repo.clear_login_failures(&rate_key).await {
|
||||
log!(AuthError::LoginClearError(e));
|
||||
}
|
||||
key_claims
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = repo.record_login_failure(&rate_key).await {
|
||||
if let Err(e) = login_attempt_repo.record_login_failure(&rate_key).await {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
let resp = json_error(StatusCode::UNAUTHORIZED, "Invalid or revoked API key");
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
use std::fs;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::adapter::http::helpers::{internal_error, ok_json_or_error};
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
@ -17,19 +16,18 @@ pub fn initialize() -> Scope {
|
||||
|
||||
async fn generate_report(_auth: AuthClaims, reports: web::Data<ReportGenerationService>) -> HttpResponse {
|
||||
match reports.generate_html_report().await {
|
||||
Ok(path) => match fs::read(&path) {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.insert_header((
|
||||
"Content-Disposition",
|
||||
format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "report.html".into())
|
||||
),
|
||||
))
|
||||
.body(content),
|
||||
Ok(path) => match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "report.html".into());
|
||||
let stream = ReaderStream::new(file);
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename)))
|
||||
.streaming(stream)
|
||||
}
|
||||
Err(_) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"path": path.to_string_lossy(),
|
||||
|
||||
@ -13,16 +13,13 @@ use crate::core::response::playbook_service::PlaybookService;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::response::condition::{ConditionType, is_valid_ip_pattern, is_valid_operator};
|
||||
use crate::domain::response::playbook::ActionType;
|
||||
use crate::domain::response::playbook_validator;
|
||||
use crate::interface::response::playbook_data::{ActionInput, CreateConditionInput, CreatePlaybookInput};
|
||||
|
||||
const DEFAULT_DRY_RUN_DEST_IP: &str = "0.0.0.0";
|
||||
const DEFAULT_DRY_RUN_FLOW_COUNT: u32 = 1;
|
||||
const DEFAULT_DRY_RUN_PACKET_RATE: f64 = 0.0;
|
||||
const DEFAULT_DRY_RUN_PROTOCOL: u8 = 6;
|
||||
const RATE_LIMIT_FACTOR_MIN: f64 = 0.01;
|
||||
const RATE_LIMIT_FACTOR_MAX: f64 = 1.0;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatePlaybookRequest {
|
||||
@ -55,19 +52,20 @@ fn map_request_to_input(
|
||||
fallback_cooldown: i64,
|
||||
max_ttl_secs: u64,
|
||||
) -> Result<CreatePlaybookInput, String> {
|
||||
validate_optional_positive_i64("condition_count", body.condition_count)?;
|
||||
validate_optional_positive_i64("condition_window_secs", body.condition_window_secs)?;
|
||||
playbook_validator::validate_optional_positive_i64("condition_count", body.condition_count)
|
||||
.map_err(|e| e.to_string())?;
|
||||
playbook_validator::validate_optional_positive_i64("condition_window_secs", body.condition_window_secs)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cooldown_secs = body.cooldown_secs.unwrap_or(fallback_cooldown);
|
||||
if cooldown_secs < 0 {
|
||||
return Err("cooldown_secs must be greater than or equal to 0".to_string());
|
||||
}
|
||||
playbook_validator::validate_cooldown_secs(cooldown_secs).map_err(|e| e.to_string())?;
|
||||
|
||||
let actions = body
|
||||
.actions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, a)| {
|
||||
validate_action_request(a, max_ttl_secs)?;
|
||||
playbook_validator::validate_action(&a.action_type, a.params.as_ref(), max_ttl_secs)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
@ -95,7 +93,7 @@ fn map_request_to_input(
|
||||
c.value2.clone(),
|
||||
)
|
||||
.map_err(|_| format!("unknown condition_type: {}", c.condition_type))?;
|
||||
validate_condition_input(&input)?;
|
||||
playbook_validator::validate_condition_input(&input).map_err(|e| e.to_string())?;
|
||||
Ok(input)
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
@ -112,179 +110,6 @@ fn map_request_to_input(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_condition_input(condition: &CreateConditionInput) -> Result<(), String> {
|
||||
let condition_type = condition
|
||||
.condition_type
|
||||
.parse::<ConditionType>()
|
||||
.map_err(|_| format!("unknown condition_type: {}", condition.condition_type))?;
|
||||
if !is_valid_operator(&condition_type, &condition.operator) {
|
||||
return Err(format!(
|
||||
"invalid operator '{}' for condition_type '{}'",
|
||||
condition.operator, condition.condition_type
|
||||
));
|
||||
}
|
||||
validate_condition_value(&condition_type, condition)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_condition_value(condition_type: &ConditionType, condition: &CreateConditionInput) -> Result<(), String> {
|
||||
match condition_type {
|
||||
ConditionType::Threshold | ConditionType::FusedConfidenceAbove => {
|
||||
parse_finite_f64(&condition.value, &condition.condition_type)?;
|
||||
}
|
||||
ConditionType::Frequency | ConditionType::MultiSourceMin => {
|
||||
parse_positive_usize(&condition.value, &condition.condition_type)?;
|
||||
if let Some(value2) = &condition.value2 {
|
||||
parse_positive_u64(value2, "value2")?;
|
||||
}
|
||||
}
|
||||
ConditionType::SourceCountry => {
|
||||
if condition.value.split(',').all(|part| part.trim().is_empty()) {
|
||||
return Err("source_country value must contain at least one country code".to_string());
|
||||
}
|
||||
}
|
||||
ConditionType::IpPattern => {
|
||||
if !is_valid_ip_pattern(&condition.value) {
|
||||
return Err(format!("ip_pattern value must be a valid CIDR: {}", condition.value));
|
||||
}
|
||||
}
|
||||
ConditionType::RepeatOffender => {
|
||||
parse_bool_literal(&condition.value, &condition.condition_type)?;
|
||||
}
|
||||
ConditionType::SingleSourceHigh => {
|
||||
DetectionSource::from_str(&condition.value).map_err(|_| {
|
||||
format!(
|
||||
"single_source_high value must be a valid DetectionSource: {}",
|
||||
condition.value
|
||||
)
|
||||
})?;
|
||||
if let Some(value2) = &condition.value2 {
|
||||
parse_finite_f64(value2, "value2")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_finite_f64(value: &str, field: &str) -> Result<f64, String> {
|
||||
match value.parse::<f64>() {
|
||||
Ok(parsed) if parsed.is_finite() => Ok(parsed),
|
||||
_ => Err(format!("{field} value must be a finite number")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_usize(value: &str, field: &str) -> Result<usize, String> {
|
||||
match value.parse::<usize>() {
|
||||
Ok(parsed) if parsed > 0 => Ok(parsed),
|
||||
_ => Err(format!("{field} value must be a positive integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_u64(value: &str, field: &str) -> Result<u64, String> {
|
||||
match value.parse::<u64>() {
|
||||
Ok(parsed) if parsed > 0 => Ok(parsed),
|
||||
_ => Err(format!("{field} must be a positive integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool_literal(value: &str, field: &str) -> Result<bool, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(format!("{field} value must be 'true' or 'false'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_positive_i64(field: &str, value: Option<i64>) -> Result<(), String> {
|
||||
if value.is_some_and(|value| value <= 0) {
|
||||
return Err(format!("{field} must be greater than 0"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_action_request(action: &CreateActionRequest, max_ttl_secs: u64) -> Result<(), String> {
|
||||
let params = action.params.as_ref();
|
||||
if let Some(params) = params
|
||||
&& !params.is_object()
|
||||
{
|
||||
return Err(format!("action '{}' params must be a JSON object", action.action_type));
|
||||
}
|
||||
|
||||
match action.action_type.parse::<ActionType>() {
|
||||
Ok(ActionType::BlockIp) => {
|
||||
let ttl_secs = params.and_then(|p| p.get("ttl_secs"));
|
||||
validate_optional_positive_u64("ttl_secs", ttl_secs)?;
|
||||
validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)
|
||||
}
|
||||
Ok(ActionType::AdjustRateLimit) => {
|
||||
let ttl_secs = params.and_then(|p| p.get("ttl_secs"));
|
||||
validate_optional_positive_u64("ttl_secs", ttl_secs)?;
|
||||
validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)?;
|
||||
validate_optional_rate_limit_factor(params.and_then(|p| p.get("factor")))
|
||||
}
|
||||
Ok(ActionType::SendTelegram | ActionType::SendEmail) => Ok(()),
|
||||
Ok(ActionType::Webhook) => {
|
||||
validate_required_non_empty_string("url", params.and_then(|p| p.get("url")))?;
|
||||
validate_optional_positive_u64("timeout_secs", params.and_then(|p| p.get("timeout_secs")))
|
||||
}
|
||||
Ok(ActionType::Log) => validate_optional_string("level", params.and_then(|p| p.get("level"))),
|
||||
Err(_) => Err(format!("unknown action_type: {}", action.action_type)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_positive_u64(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
match value.as_u64() {
|
||||
Some(value) if value > 0 => Ok(()),
|
||||
Some(_) => Err(format!("{field} must be greater than 0")),
|
||||
None => Err(format!("{field} must be a positive integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_max_u64(field: &str, value: Option<&serde_json::Value>, max: u64) -> Result<(), String> {
|
||||
let Some(value) = value.and_then(|value| value.as_u64()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if value > max {
|
||||
return Err(format!("{field} must be less than or equal to {max}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_optional_rate_limit_factor(value: Option<&serde_json::Value>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
match value.as_f64() {
|
||||
Some(value) if (RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&value) => Ok(()),
|
||||
Some(_) => Err(format!(
|
||||
"factor must be between {RATE_LIMIT_FACTOR_MIN} and {RATE_LIMIT_FACTOR_MAX}"
|
||||
)),
|
||||
None => Err("factor must be a number".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required_non_empty_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> {
|
||||
match value.and_then(|value| value.as_str()) {
|
||||
Some(value) if !value.trim().is_empty() => Ok(()),
|
||||
Some(_) => Err(format!("{field} must not be empty")),
|
||||
None => Err(format!("{field} is required")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
if value.is_string() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{field} must be a string"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/soar")
|
||||
.route("/playbooks", web::get().to(list_playbooks))
|
||||
|
||||
59
net-guardia/src/adapter/identity/api_key_hasher.rs
Normal file
59
net-guardia/src/adapter/identity/api_key_hasher.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub struct HmacApiKeyHasher {
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
impl HmacApiKeyHasher {
|
||||
pub fn new(key: [u8; 32]) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiKeyHasher for HmacApiKeyHasher {
|
||||
fn hash_api_key(&self, raw_key: &str) -> String {
|
||||
let mut mac = match HmacSha256::new_from_slice(&self.key) {
|
||||
Ok(mac) => mac,
|
||||
// SAFETY: HMAC accepts keys of any length; `key` is a fixed 32-byte array.
|
||||
Err(_) => unreachable!("HMAC-SHA256 accepts fixed 32-byte keys"),
|
||||
};
|
||||
mac.update(raw_key.as_bytes());
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in result {
|
||||
// SAFETY: write! on a String is infallible.
|
||||
let _ = write!(&mut hex, "{:02x}", byte);
|
||||
}
|
||||
hex
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_hash() {
|
||||
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
|
||||
let h1 = hasher.hash_api_key("test-key");
|
||||
let h2 = hasher.hash_api_key("test-key");
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_keys_produce_different_hashes() {
|
||||
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
|
||||
let h1 = hasher.hash_api_key("key-a");
|
||||
let h2 = hasher.hash_api_key("key-b");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod api_key_hasher;
|
||||
pub mod password_hasher;
|
||||
|
||||
@ -7,8 +7,8 @@ use tokio::sync::mpsc::error::TrySendError;
|
||||
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::model_files::STAGING_SUBDIR;
|
||||
use crate::interface::detection::model_change_source::{ModelChangeSource, ModelChangeSubscription};
|
||||
use crate::interface::detection::model_files::STAGING_SUBDIR;
|
||||
|
||||
pub struct NotifyModelChangeSource {
|
||||
models_dir: PathBuf,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::domain::detection::manifest::ModelManifest;
|
||||
use crate::domain::detection::model_files::MODELS_DIR;
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
use crate::interface::detection::model_files::MODELS_DIR;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FsModelArtifactResolver;
|
||||
|
||||
@ -5,12 +5,10 @@ use std::path::{Path, PathBuf};
|
||||
use crate::adapter::model_loading::manifest::load_model_manifest;
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::feature_extractor::feature_is_known;
|
||||
use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest};
|
||||
use crate::domain::detection::manifest::{LabelSpec, ModelManifest, OutputSemantic};
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::interface::detection::model_config_loader::ModelConfigLoader;
|
||||
|
||||
const AE_ANOMALY_SCORE_FEATURE: &str = "ae_anomaly_score";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FsModelConfigLoader;
|
||||
|
||||
@ -23,7 +21,7 @@ impl FsModelConfigLoader {
|
||||
fn load_file_at(path: &Path) -> Result<MLInferenceConfig, MLError> {
|
||||
let content = fs::read_to_string(path).map_err(|e| MLError::ConfigLoadFailed(path.to_path_buf(), e))?;
|
||||
let config: MLInferenceConfig = serde_json::from_str(&content).map_err(MLError::ConfigParseFailed)?;
|
||||
validate(&config)?;
|
||||
validate_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@ -32,22 +30,18 @@ impl FsModelConfigLoader {
|
||||
manifest_path: &Path,
|
||||
) -> Result<(MLInferenceConfig, ModelManifest), MLError> {
|
||||
let manifest = load_model_manifest(manifest_path)?;
|
||||
let sidecar_rel = manifest
|
||||
.preprocessing
|
||||
.as_ref()
|
||||
.map(|p| p.scaler_sidecar.as_str())
|
||||
.ok_or_else(|| {
|
||||
MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"preprocessing.scaler_sidecar is required for v1 (scaler arrays live there)".to_string(),
|
||||
)
|
||||
})?;
|
||||
let sidecar_rel = manifest.primary_scaler_sidecar().ok_or_else(|| {
|
||||
MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"primary model requires a preprocessing.standard_scaler sidecar".to_string(),
|
||||
)
|
||||
})?;
|
||||
let sidecar_path = ModelManifest::resolve_relative(manifest_path, sidecar_rel);
|
||||
let mut config = Self::load_file_at(&sidecar_path)?;
|
||||
|
||||
reconcile_features(&manifest, &config, manifest_path)?;
|
||||
validate_manifest_alignment(&manifest, &config, manifest_path)?;
|
||||
apply_manifest_overrides(&manifest, &mut config);
|
||||
validate_for_adapter(&config, manifest.adapter)?;
|
||||
validate_manifest_backed_config(&config, &manifest, manifest_path)?;
|
||||
|
||||
Ok((config, manifest))
|
||||
}
|
||||
@ -63,7 +57,7 @@ impl ModelConfigLoader for FsModelConfigLoader {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(config: &MLInferenceConfig) -> Result<(), MLError> {
|
||||
fn validate_config(config: &MLInferenceConfig) -> Result<(), MLError> {
|
||||
if config.ae_feature_names.is_empty() {
|
||||
return Err(MLError::ConfigInvalid("ae_feature_names is empty"));
|
||||
}
|
||||
@ -94,6 +88,158 @@ fn validate(config: &MLInferenceConfig) -> Result<(), MLError> {
|
||||
for (name, params) in &config.ae_clip_params {
|
||||
validate_clip_range(&format!("ae_clip_params.{name}"), params.lower, params.upper)?;
|
||||
}
|
||||
for name in &config.ae_feature_names {
|
||||
if !feature_is_known(name) {
|
||||
return Err(MLError::ConfigInvalid(format!(
|
||||
"ae_feature_names contains unknown feature '{name}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_manifest_alignment(
|
||||
manifest: &ModelManifest,
|
||||
config: &MLInferenceConfig,
|
||||
manifest_path: &Path,
|
||||
) -> Result<(), MLError> {
|
||||
let Some(primary) = manifest.primary_model() else {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"pipeline does not resolve to a primary model".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if config.ae_feature_names != primary.input_features {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"primary model input_features must match ae_feature_names in the runtime sidecar".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(classifier) = manifest.classifier_model() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if config.classifier_feature_names != classifier.input_features {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"classifier model input_features must match classifier_feature_names in the runtime sidecar".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_manifest_overrides(manifest: &ModelManifest, config: &mut MLInferenceConfig) {
|
||||
config.attack_labels = manifest_labels_to_map(&manifest.labels);
|
||||
config.model_type = manifest.runtime_kind();
|
||||
|
||||
if let Some(primary) = manifest.primary_model()
|
||||
&& let Some(output) = primary
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.semantic == OutputSemantic::AnomalyScore)
|
||||
&& let Some(threshold) = output.threshold
|
||||
{
|
||||
config.ae_threshold = threshold;
|
||||
}
|
||||
|
||||
if let Some(classifier) = manifest.classifier_model()
|
||||
&& let Some(output) = classifier
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.semantic == OutputSemantic::Binary || output.name == "anomaly")
|
||||
&& let Some(threshold) = output.threshold
|
||||
{
|
||||
config.anomaly_threshold = threshold;
|
||||
}
|
||||
if let Some(classifier) = manifest.classifier_model()
|
||||
&& let Some(output) = classifier
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.semantic == OutputSemantic::Multiclass)
|
||||
&& let Some(min_confidence) = output.min_confidence
|
||||
{
|
||||
config.class_min_confidence = min_confidence;
|
||||
}
|
||||
if let Some(classifier) = manifest.classifier_model()
|
||||
&& let Some(output) = classifier.outputs.iter().find(|output| output.name == "c2_score")
|
||||
&& let Some(threshold) = output.threshold
|
||||
{
|
||||
config.c2_threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest_backed_config(
|
||||
config: &MLInferenceConfig,
|
||||
manifest: &ModelManifest,
|
||||
manifest_path: &Path,
|
||||
) -> Result<(), MLError> {
|
||||
let Some(primary) = manifest.primary_model() else {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"manifest primary model is missing".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if config.ae_feature_names.len() != primary.input_features.len() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
format!(
|
||||
"ae_feature_names length mismatch: manifest has {}, sidecar has {}",
|
||||
primary.input_features.len(),
|
||||
config.ae_feature_names.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
if config
|
||||
.ae_feature_names
|
||||
.iter()
|
||||
.zip(primary.input_features.iter())
|
||||
.any(|(lhs, rhs)| lhs != rhs)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"ae_feature_names order does not match primary model input_features".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(classifier) = manifest.classifier_model() {
|
||||
if config.classifier_feature_names.len() != classifier.input_features.len() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
format!(
|
||||
"classifier_feature_names length mismatch: manifest has {}, sidecar has {}",
|
||||
classifier.input_features.len(),
|
||||
config.classifier_feature_names.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
if config
|
||||
.classifier_feature_names
|
||||
.iter()
|
||||
.zip(classifier.input_features.iter())
|
||||
.any(|(lhs, rhs)| lhs != rhs)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"classifier_feature_names order does not match classifier model input_features".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(primary_output) = primary
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.semantic == OutputSemantic::AnomalyScore)
|
||||
&& let Some(threshold) = primary_output.threshold
|
||||
&& (config.ae_threshold - threshold).abs() > f32::EPSILON
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
"ae_threshold does not match manifest output threshold".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -133,134 +279,6 @@ fn validate_clip_range(field: &str, lower: f64, upper: f64) -> Result<(), MLErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_for_adapter(config: &MLInferenceConfig, adapter: AdapterKind) -> Result<(), MLError> {
|
||||
if matches!(adapter, AdapterKind::MultiTask | AdapterKind::ClassifierOnly)
|
||||
&& config.classifier_feature_names.is_empty()
|
||||
{
|
||||
return Err(MLError::ConfigInvalid(
|
||||
"classifier adapters require classifier_feature_names",
|
||||
));
|
||||
}
|
||||
|
||||
match adapter {
|
||||
AdapterKind::MultiTask => {
|
||||
validate_output_names(config, &["anomaly", "class_probs", "c2_score"], "MultiTaskModel")?;
|
||||
validate_multitask_classifier_features(config)?;
|
||||
}
|
||||
AdapterKind::ClassifierOnly => {
|
||||
validate_output_names(config, &["class_probs"], "ClassifierOnly adapter")?;
|
||||
validate_feature_names("classifier_feature_names", &config.classifier_feature_names)?;
|
||||
}
|
||||
AdapterKind::AutoencoderOnly => {
|
||||
validate_output_names(config, &["reconstruction"], "AutoencoderOnly adapter")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_output_names(
|
||||
config: &MLInferenceConfig,
|
||||
expected: &[&str],
|
||||
adapter_name: &'static str,
|
||||
) -> Result<(), MLError> {
|
||||
if config
|
||||
.output_names
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.eq(expected.iter().copied())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(MLError::ConfigInvalid(format!(
|
||||
"{adapter_name} requires output_names {:?} in that order",
|
||||
expected
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_multitask_classifier_features(config: &MLInferenceConfig) -> Result<(), MLError> {
|
||||
let expected_len = config.ae_feature_names.len() + 1;
|
||||
if config.classifier_feature_names.len() != expected_len {
|
||||
return Err(MLError::ConfigInvalid(format!(
|
||||
"MultiTaskModel classifier_feature_names must contain AE features plus {AE_ANOMALY_SCORE_FEATURE} \
|
||||
(expected {expected_len}, got {})",
|
||||
config.classifier_feature_names.len()
|
||||
)));
|
||||
}
|
||||
if !config
|
||||
.classifier_feature_names
|
||||
.iter()
|
||||
.take(config.ae_feature_names.len())
|
||||
.zip(config.ae_feature_names.iter())
|
||||
.all(|(classifier_name, ae_name)| classifier_name == ae_name)
|
||||
{
|
||||
return Err(MLError::ConfigInvalid(
|
||||
"MultiTaskModel classifier_feature_names must start with ae_feature_names in the same order",
|
||||
));
|
||||
}
|
||||
if config.classifier_feature_names.last().map(String::as_str) != Some(AE_ANOMALY_SCORE_FEATURE) {
|
||||
return Err(MLError::ConfigInvalid(format!(
|
||||
"MultiTaskModel classifier_feature_names must end with {AE_ANOMALY_SCORE_FEATURE}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_feature_names(field: &str, feature_names: &[String]) -> Result<(), MLError> {
|
||||
if let Some(name) = feature_names.iter().find(|name| !feature_is_known(name.trim())) {
|
||||
return Err(MLError::ConfigInvalid(format!(
|
||||
"{field} contains unknown feature '{name}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_features(
|
||||
manifest: &ModelManifest,
|
||||
config: &MLInferenceConfig,
|
||||
manifest_path: &Path,
|
||||
) -> Result<(), MLError> {
|
||||
if manifest.features.len() != config.ae_feature_names.len() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
format!(
|
||||
"feature count mismatch with sidecar: manifest declares {}, sidecar lists {}",
|
||||
manifest.features.len(),
|
||||
config.ae_feature_names.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
for (i, (mf, sf)) in manifest.features.iter().zip(config.ae_feature_names.iter()).enumerate() {
|
||||
if mf != sf {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.to_path_buf(),
|
||||
format!("feature[{i}] mismatch: manifest='{mf}' vs sidecar='{sf}'"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_manifest_overrides(manifest: &ModelManifest, config: &mut MLInferenceConfig) {
|
||||
if !manifest.labels.is_empty() {
|
||||
config.attack_labels = manifest_labels_to_map(&manifest.labels);
|
||||
}
|
||||
if let Some(v) = manifest.thresholds.anomaly {
|
||||
config.anomaly_threshold = v;
|
||||
}
|
||||
if let Some(v) = manifest.thresholds.c2 {
|
||||
config.c2_threshold = v;
|
||||
}
|
||||
if let Some(v) = manifest.thresholds.class_min_confidence {
|
||||
config.class_min_confidence = v;
|
||||
}
|
||||
if let Some(v) = manifest.thresholds.ae {
|
||||
config.ae_threshold = v;
|
||||
}
|
||||
if let Some(v) = manifest.thresholds.alert_multiplier {
|
||||
config.alert_threshold_multiplier = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_labels_to_map(labels: &BTreeMap<String, LabelSpec>) -> HashMap<String, String> {
|
||||
labels.iter().map(|(k, v)| (k.clone(), v.name.clone())).collect()
|
||||
}
|
||||
@ -271,150 +289,92 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::detection::manifest::{
|
||||
AdapterKind, ModelSpec, OutputHeadSpec, OutputSemantic, PreprocessingStep,
|
||||
};
|
||||
use crate::domain::detection::ml_detection::ClipParams;
|
||||
|
||||
#[test]
|
||||
fn v10_manifest_and_sidecar_load_successfully() {
|
||||
let manifest_path = Path::new("models/manifest.yaml");
|
||||
if !manifest_path.exists() {
|
||||
eprintln!("skipping: models/manifest.yaml absent (not in repo root?)");
|
||||
return;
|
||||
fn test_manifest() -> ModelManifest {
|
||||
ModelManifest {
|
||||
name: "test".into(),
|
||||
version: 2,
|
||||
models: vec![
|
||||
ModelSpec {
|
||||
id: "anomaly_detector".into(),
|
||||
file: "deep_autoencoder.onnx".into(),
|
||||
input_features: vec!["flow_duration".into(), "fwd_packets".into()],
|
||||
preprocessing: vec![
|
||||
PreprocessingStep::StandardScaler {
|
||||
sidecar: "sidecar.json".into(),
|
||||
},
|
||||
PreprocessingStep::Clip { min: -5.0, max: 5.0 },
|
||||
],
|
||||
outputs: vec![OutputHeadSpec {
|
||||
name: "ae_anomaly_score".into(),
|
||||
shape: vec!["1".into()],
|
||||
semantic: OutputSemantic::AnomalyScore,
|
||||
threshold: Some(0.23),
|
||||
min_confidence: None,
|
||||
}],
|
||||
},
|
||||
ModelSpec {
|
||||
id: "classifier".into(),
|
||||
file: "classifier.onnx".into(),
|
||||
input_features: vec!["flow_duration".into(), "fwd_packets".into(), "ae_anomaly_score".into()],
|
||||
preprocessing: vec![],
|
||||
outputs: vec![
|
||||
OutputHeadSpec {
|
||||
name: "anomaly".into(),
|
||||
shape: vec!["1".into()],
|
||||
semantic: OutputSemantic::Binary,
|
||||
threshold: Some(0.91),
|
||||
min_confidence: None,
|
||||
},
|
||||
OutputHeadSpec {
|
||||
name: "class_probs".into(),
|
||||
shape: vec!["10".into()],
|
||||
semantic: OutputSemantic::Multiclass,
|
||||
threshold: None,
|
||||
min_confidence: Some(0.4),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
pipeline: vec!["anomaly_detector".into(), "classifier".into()],
|
||||
labels: BTreeMap::from([
|
||||
(
|
||||
"0".into(),
|
||||
LabelSpec {
|
||||
name: "Bot".into(),
|
||||
confirmations: Some(1),
|
||||
playbook: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"7".into(),
|
||||
LabelSpec {
|
||||
name: "Normal".into(),
|
||||
confirmations: None,
|
||||
playbook: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
alert_rules: vec![crate::domain::detection::manifest::AlertRuleSpec {
|
||||
condition: "anomaly > threshold".into(),
|
||||
source_label: "anomaly".into(),
|
||||
}],
|
||||
}
|
||||
let loader = FsModelConfigLoader;
|
||||
let (cfg, manifest) = loader
|
||||
.load_manifest_with_sidecar(manifest_path)
|
||||
.expect("v10 manifest + sidecar should load cleanly");
|
||||
assert_eq!(manifest.name, "netguardia-v10");
|
||||
assert_eq!(cfg.ae_feature_names.len(), 31);
|
||||
assert_eq!(cfg.classifier_feature_names.len(), 32);
|
||||
assert_eq!(cfg.attack_labels.get("0").map(String::as_str), Some("Bot"));
|
||||
assert_eq!(cfg.attack_labels.get("7").map(String::as_str), Some("Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_mismatch_between_manifest_and_sidecar_is_rejected() {
|
||||
let sidecar = test_config();
|
||||
|
||||
let tmp = env::temp_dir().join("netguardia-m1-mismatch-test");
|
||||
fn load_config_accepts_manifest_sidecar_pair() {
|
||||
let manifest = test_manifest();
|
||||
let tmp = env::temp_dir().join("netguardia-v2-config-loader");
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
let sidecar_path = tmp.join("sidecar.json");
|
||||
let manifest_path = tmp.join("manifest.yaml");
|
||||
let sidecar_path = tmp.join("sidecar.json");
|
||||
let mut f = fs::File::create(&sidecar_path).unwrap();
|
||||
f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes())
|
||||
.unwrap();
|
||||
let manifest_yaml = r#"
|
||||
name: test
|
||||
adapter: multi_task
|
||||
models:
|
||||
autoencoder: ae.onnx
|
||||
classifier: c.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- dst_port
|
||||
preprocessing:
|
||||
scaler_sidecar: sidecar.json
|
||||
"#;
|
||||
fs::write(&manifest_path, manifest_yaml).unwrap();
|
||||
let loader = FsModelConfigLoader;
|
||||
let err = loader
|
||||
.load_manifest_with_sidecar(&manifest_path)
|
||||
.expect_err("should reject count mismatch");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sidecar_numeric_values_are_rejected() {
|
||||
for mutate in [
|
||||
|cfg: &mut MLInferenceConfig| cfg.ae_scaler_mean[0] = f64::NAN,
|
||||
|cfg: &mut MLInferenceConfig| cfg.ae_scaler_std[0] = -1.0,
|
||||
|cfg: &mut MLInferenceConfig| cfg.ae_post_clip_min = 10.0,
|
||||
|cfg: &mut MLInferenceConfig| cfg.anomaly_threshold = f32::NAN,
|
||||
|cfg: &mut MLInferenceConfig| cfg.c2_threshold = -0.1,
|
||||
|cfg: &mut MLInferenceConfig| cfg.class_min_confidence = 1.1,
|
||||
|cfg: &mut MLInferenceConfig| cfg.alert_threshold_multiplier = 0.0,
|
||||
] {
|
||||
let mut cfg = test_config();
|
||||
mutate(&mut cfg);
|
||||
|
||||
let result = validate(&cfg);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sidecar_clip_params_are_rejected() {
|
||||
let mut cfg = test_config();
|
||||
cfg.ae_clip_params.insert(
|
||||
"flow_duration".into(),
|
||||
ClipParams {
|
||||
lower: 10.0,
|
||||
upper: 1.0,
|
||||
},
|
||||
);
|
||||
|
||||
let result = validate(&cfg);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_adapters_require_classifier_features() {
|
||||
let mut cfg = test_config();
|
||||
cfg.classifier_feature_names.clear();
|
||||
|
||||
assert!(validate_for_adapter(&cfg, AdapterKind::MultiTask).is_err());
|
||||
|
||||
cfg.output_names = vec!["class_probs".into()];
|
||||
assert!(validate_for_adapter(&cfg, AdapterKind::ClassifierOnly).is_err());
|
||||
cfg.output_names = vec!["reconstruction".into()];
|
||||
assert!(validate_for_adapter(&cfg, AdapterKind::AutoencoderOnly).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multitask_classifier_features_must_match_runner_layout() {
|
||||
let mut cfg = test_config();
|
||||
cfg.classifier_feature_names.swap(0, 1);
|
||||
|
||||
let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject reordered features");
|
||||
|
||||
assert!(err.to_string().contains("same order"), "got {err:?}");
|
||||
|
||||
let mut cfg = test_config();
|
||||
cfg.classifier_feature_names.pop();
|
||||
let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject missing AE score");
|
||||
assert!(err.to_string().contains("expected 3, got 2"), "got {err:?}");
|
||||
|
||||
let mut cfg = test_config();
|
||||
*cfg.classifier_feature_names.last_mut().unwrap() = "fwd_packets".into();
|
||||
let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject wrong final feature");
|
||||
assert!(err.to_string().contains("ae_anomaly_score"), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_only_rejects_unknown_features() {
|
||||
let mut cfg = test_config();
|
||||
cfg.output_names = vec!["class_probs".into()];
|
||||
cfg.classifier_feature_names = vec!["flow_duration".into(), "not_a_feature".into()];
|
||||
|
||||
let err = validate_for_adapter(&cfg, AdapterKind::ClassifierOnly).expect_err("should reject unknown feature");
|
||||
|
||||
assert!(err.to_string().contains("not_a_feature"), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_output_names_must_match_runtime_order() {
|
||||
let mut cfg = test_config();
|
||||
cfg.output_names = vec!["class_probs".into(), "anomaly".into(), "c2_score".into()];
|
||||
|
||||
let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject reordered outputs");
|
||||
|
||||
assert!(err.to_string().contains("output_names"), "got {err:?}");
|
||||
}
|
||||
|
||||
fn test_config() -> MLInferenceConfig {
|
||||
MLInferenceConfig {
|
||||
let sidecar = MLInferenceConfig {
|
||||
ae_feature_names: vec!["flow_duration".into(), "fwd_packets".into()],
|
||||
ae_clip_params: HashMap::from([
|
||||
("flow_duration".into(), ClipParams { lower: 0.0, upper: 1.0 }),
|
||||
@ -424,16 +384,58 @@ preprocessing:
|
||||
ae_scaler_std: vec![1.0, 1.0],
|
||||
ae_post_clip_min: -5.0,
|
||||
ae_post_clip_max: 5.0,
|
||||
ae_threshold: 0.5,
|
||||
ae_threshold: 0.23,
|
||||
classifier_feature_names: vec!["flow_duration".into(), "fwd_packets".into(), "ae_anomaly_score".into()],
|
||||
attack_labels: HashMap::new(),
|
||||
anomaly_threshold: 0.91,
|
||||
c2_threshold: 0.5,
|
||||
class_min_confidence: 0.4,
|
||||
alert_threshold_multiplier: 1.2,
|
||||
model_type: AdapterKind::MultiTask,
|
||||
output_names: vec!["anomaly".into(), "class_probs".into()],
|
||||
ae_feature_weights: HashMap::new(),
|
||||
};
|
||||
f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes())
|
||||
.unwrap();
|
||||
fs::write(&manifest_path, serde_yaml_ng::to_string(&manifest).unwrap()).unwrap();
|
||||
let loader = FsModelConfigLoader;
|
||||
let (cfg, loaded) = loader
|
||||
.load_manifest_with_sidecar(&manifest_path)
|
||||
.expect("load v2 manifest + sidecar");
|
||||
assert_eq!(loaded.name, "test");
|
||||
assert_eq!(
|
||||
cfg.ae_feature_names,
|
||||
vec!["flow_duration".to_string(), "fwd_packets".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.classifier_feature_names.last().map(String::as_str),
|
||||
Some("ae_anomaly_score")
|
||||
);
|
||||
assert_eq!(cfg.attack_labels.get("0").map(String::as_str), Some("Bot"));
|
||||
assert_eq!(cfg.model_type, AdapterKind::MultiTask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sidecar_numeric_values_are_rejected() {
|
||||
let mut cfg = MLInferenceConfig {
|
||||
ae_feature_names: vec!["flow_duration".into()],
|
||||
ae_clip_params: HashMap::from([("flow_duration".into(), ClipParams { lower: 0.0, upper: 1.0 })]),
|
||||
ae_scaler_mean: vec![0.0],
|
||||
ae_scaler_std: vec![1.0],
|
||||
ae_post_clip_min: -5.0,
|
||||
ae_post_clip_max: 5.0,
|
||||
ae_threshold: 0.5,
|
||||
classifier_feature_names: vec!["flow_duration".into()],
|
||||
attack_labels: HashMap::new(),
|
||||
anomaly_threshold: 0.5,
|
||||
c2_threshold: 0.5,
|
||||
class_min_confidence: 0.4,
|
||||
alert_threshold_multiplier: 1.2,
|
||||
model_type: AdapterKind::MultiTask,
|
||||
output_names: vec!["anomaly".into(), "class_probs".into(), "c2_score".into()],
|
||||
model_type: AdapterKind::ClassifierOnly,
|
||||
output_names: vec!["class_probs".into()],
|
||||
ae_feature_weights: HashMap::new(),
|
||||
}
|
||||
};
|
||||
cfg.ae_scaler_std[0] = -1.0;
|
||||
assert!(validate_config(&cfg).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,8 +15,11 @@ pub struct FsModelPromotionStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ModelPromotionStore for FsModelPromotionStore {
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
path.try_exists()
|
||||
async fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
let path = path.to_path_buf();
|
||||
task::spawn_blocking(move || path.try_exists())
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(io::Error::other(format!("exists join: {e}"))))
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
|
||||
|
||||
@ -199,6 +199,10 @@ fn decode_acl_list_type(raw_list_type: String) -> Result<ListType, RusqliteError
|
||||
|
||||
#[async_trait]
|
||||
impl AclRepo for Database {
|
||||
async fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
|
||||
self.list_acl_rules().await
|
||||
}
|
||||
|
||||
async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
|
||||
self.has_manual_acl_rule(ip_address).await
|
||||
}
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::Database;
|
||||
use crate::common::error::Error;
|
||||
@ -12,31 +8,14 @@ use crate::domain::identity::auth::{Claims, PermissionLevel};
|
||||
use crate::domain::identity::user::ApiKeyView;
|
||||
use crate::interface::identity::api_key::ApiKeyRepo;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
impl Database {
|
||||
pub fn hmac_api_key(&self, raw_key: &str) -> String {
|
||||
let mut mac = match HmacSha256::new_from_slice(&self.api_key_hmac) {
|
||||
Ok(mac) => mac,
|
||||
// SAFETY: HMAC accepts keys of any length; `api_key_hmac` is a fixed 32-byte key.
|
||||
Err(_) => unreachable!("HMAC-SHA256 accepts fixed 32-byte keys"),
|
||||
};
|
||||
mac.update(raw_key.as_bytes());
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in result {
|
||||
// SAFETY: write! on a String is infallible.
|
||||
let _ = write!(&mut hex, "{:02x}", byte);
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
pub async fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
|
||||
let digest = self.hmac_api_key(api_key);
|
||||
pub async fn validate_api_key(&self, key_hash: &str) -> Result<Option<Claims>, Error> {
|
||||
let digest = key_hash.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row(
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let result = tx.query_row(
|
||||
"SELECT id, name, permission_level FROM api_keys WHERE key_hash = ?1",
|
||||
params![digest],
|
||||
|row| {
|
||||
@ -50,10 +29,11 @@ impl Database {
|
||||
|
||||
match result {
|
||||
Ok((id, name, level)) => {
|
||||
conn.execute(
|
||||
tx.execute(
|
||||
"UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
let perm_level = PermissionLevel::from_str(&level).ok_or_else(|| {
|
||||
DatabaseError::PersistedValueInvalid("api_keys", "permission_level", level.clone())
|
||||
@ -121,12 +101,8 @@ impl Database {
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepo for Database {
|
||||
async fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
|
||||
self.validate_api_key(api_key).await
|
||||
}
|
||||
|
||||
fn hmac_api_key(&self, raw_key: &str) -> String {
|
||||
self.hmac_api_key(raw_key)
|
||||
async fn validate_api_key(&self, key_hash: &str) -> Result<Option<Claims>, Error> {
|
||||
self.validate_api_key(key_hash).await
|
||||
}
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
|
||||
@ -144,17 +120,21 @@ impl ApiKeyRepo for Database {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::adapter::identity::api_key_hasher::HmacApiKeyHasher;
|
||||
use crate::interface::identity::api_key_hasher::ApiKeyHasher;
|
||||
|
||||
use super::Database;
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_full_access_api_key_grants_admin_permissions() {
|
||||
let db = Database::new(":memory:").await.expect("test db");
|
||||
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
|
||||
let raw_key = "ng-test-full-access";
|
||||
let digest = db.hmac_api_key(raw_key);
|
||||
let digest = hasher.hash_api_key(raw_key);
|
||||
|
||||
db.insert_api_key(&digest, "automation", "full_access").await.unwrap();
|
||||
|
||||
let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims");
|
||||
let claims = db.validate_api_key(&digest).await.unwrap().expect("claims");
|
||||
assert!(claims.permissions.contains(&"api_keys:admin".to_string()));
|
||||
assert!(claims.permissions.contains(&"system:admin".to_string()));
|
||||
assert!(claims.permissions.contains(&"users:admin".to_string()));
|
||||
@ -163,12 +143,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn validate_read_write_api_key_does_not_grant_admin_permissions() {
|
||||
let db = Database::new(":memory:").await.expect("test db");
|
||||
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
|
||||
let raw_key = "ng-test-read-write";
|
||||
let digest = db.hmac_api_key(raw_key);
|
||||
let digest = hasher.hash_api_key(raw_key);
|
||||
|
||||
db.insert_api_key(&digest, "automation", "read_write").await.unwrap();
|
||||
|
||||
let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims");
|
||||
let claims = db.validate_api_key(&digest).await.unwrap().expect("claims");
|
||||
assert!(!claims.permissions.contains(&"api_keys:admin".to_string()));
|
||||
assert!(!claims.permissions.contains(&"system:admin".to_string()));
|
||||
assert!(!claims.permissions.contains(&"users:admin".to_string()));
|
||||
@ -177,11 +158,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn validate_api_key_rejects_invalid_persisted_permission_level() {
|
||||
let db = Database::new(":memory:").await.expect("test db");
|
||||
let hasher = HmacApiKeyHasher::new([0xAB; 32]);
|
||||
let raw_key = "ng-test-invalid-level";
|
||||
let digest = db.hmac_api_key(raw_key);
|
||||
let digest = hasher.hash_api_key(raw_key);
|
||||
|
||||
db.insert_api_key(&digest, "automation", "owner").await.unwrap();
|
||||
|
||||
assert!(db.validate_api_key(raw_key).await.is_err());
|
||||
assert!(db.validate_api_key(&digest).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use rusqlite::{Error as RusqliteError, params};
|
||||
use super::Database;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::system::SystemError;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, ROLE_ADMIN};
|
||||
use crate::interface::system::config_repo::ConfigRepo;
|
||||
use crate::interface::system::setup::SetupRepo;
|
||||
|
||||
@ -142,14 +143,25 @@ impl Database {
|
||||
params![channel, config_json],
|
||||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO users (username, password_hash, role, force_password_change) \
|
||||
VALUES (?1, ?2, ?3, 0) \
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = ?2, force_password_change = 0",
|
||||
params![admin_username, password_hash, ROLE_ADMIN],
|
||||
)?;
|
||||
let admin_id = tx.query_row(
|
||||
"SELECT id FROM users WHERE username = ?1",
|
||||
params![admin_username],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
let admin_group_id = tx.query_row(
|
||||
"SELECT id FROM user_groups WHERE name = ?1",
|
||||
params![GROUP_ADMIN],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2",
|
||||
params![password_hash, admin_id],
|
||||
"INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
|
||||
params![admin_id, admin_group_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO system_state (key, value) VALUES ('setup_complete', 'true')",
|
||||
@ -242,39 +254,9 @@ mod tests {
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, ROLE_ADMIN};
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_setup_atomically_rolls_back_when_admin_missing() {
|
||||
let db = Database::new(":memory:").await.expect("database");
|
||||
let admin_id = db
|
||||
.insert_user(DEFAULT_ADMIN_USERNAME, "old-hash", ROLE_ADMIN, true)
|
||||
.await
|
||||
.expect("insert admin");
|
||||
db.delete_user(admin_id).await.expect("delete admin");
|
||||
|
||||
let err = db
|
||||
.complete_setup_atomically(
|
||||
vec![("ingress_interface".to_string(), "eth0".to_string())],
|
||||
vec![("smtp_password".to_string(), "encrypted-secret".to_string())],
|
||||
vec![("telegram".to_string(), "{}".to_string())],
|
||||
DEFAULT_ADMIN_USERNAME,
|
||||
"new-hash",
|
||||
)
|
||||
.await
|
||||
.expect_err("missing admin should abort transaction");
|
||||
|
||||
assert!(err.to_string().contains("no rows"));
|
||||
assert_eq!(db.get_config_value("ingress_interface").await.unwrap(), None);
|
||||
assert_eq!(db.get_app_secret("smtp_password").await.unwrap(), None);
|
||||
assert_eq!(db.get_notification_config("telegram").await.unwrap(), None);
|
||||
assert_eq!(db.get_system_state("setup_complete").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_setup_atomically_commits_all_setup_state() {
|
||||
let db = Database::new(":memory:").await.expect("database");
|
||||
db.insert_user(DEFAULT_ADMIN_USERNAME, "old-hash", ROLE_ADMIN, true)
|
||||
.await
|
||||
.expect("insert admin");
|
||||
|
||||
db.complete_setup_atomically(
|
||||
vec![("ingress_interface".to_string(), "eth0".to_string())],
|
||||
|
||||
@ -6,7 +6,9 @@ use rusqlite::types::Type;
|
||||
use super::Database;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::database::DatabaseError;
|
||||
use crate::interface::data_plane::enforcement::EnforcementRepo;
|
||||
use crate::interface::data_plane::enforcement::DnsEnforcementPort;
|
||||
use crate::interface::data_plane::enforcement::GeoEnforcementPort;
|
||||
use crate::interface::data_plane::enforcement::RateLimitWritePort;
|
||||
|
||||
impl Database {
|
||||
pub async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> {
|
||||
@ -150,10 +152,21 @@ fn decode_rate_limit_value(raw_value: i64) -> Result<u64, RusqliteError> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EnforcementRepo for Database {
|
||||
impl RateLimitWritePort for Database {
|
||||
async fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, Error> {
|
||||
self.load_rate_limit_config().await
|
||||
}
|
||||
|
||||
async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> {
|
||||
self.set_rate_limits(values).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DnsEnforcementPort for Database {
|
||||
async fn load_dns_domains(&self) -> Result<Vec<String>, Error> {
|
||||
self.load_dns_domains().await
|
||||
}
|
||||
|
||||
async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
self.insert_dns_domains(domains).await
|
||||
@ -162,6 +175,13 @@ impl EnforcementRepo for Database {
|
||||
async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
self.delete_dns_domains(domains).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeoEnforcementPort for Database {
|
||||
async fn load_geo_countries(&self) -> Result<Vec<String>, Error> {
|
||||
self.load_geo_countries().await
|
||||
}
|
||||
|
||||
async fn insert_geo_countries(&self, codes: &[String]) -> Result<(), Error> {
|
||||
self.insert_geo_countries(codes).await
|
||||
|
||||
@ -52,7 +52,6 @@ fn db_encryption_key() -> Option<String> {
|
||||
|
||||
pub struct Database {
|
||||
pool: Client,
|
||||
api_key_hmac: [u8; 32],
|
||||
}
|
||||
|
||||
impl Database {
|
||||
@ -89,14 +88,14 @@ impl Database {
|
||||
.await
|
||||
.map_err(|_| DatabaseError::EncryptionKeyInvalid)?;
|
||||
|
||||
let api_key_hmac = Self::derive_api_key_hmac(path, encryption_key.as_deref())?;
|
||||
let db = Self { pool, api_key_hmac };
|
||||
let db = Self { pool };
|
||||
db.create_tables().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn derive_api_key_hmac(path: &str, encryption_key: Option<&str>) -> Result<[u8; 32], Error> {
|
||||
let root_key = api_key_hmac_root_key(path, encryption_key)?;
|
||||
pub fn derive_api_key_hmac(path: &str) -> Result<[u8; 32], Error> {
|
||||
let encryption_key = db_encryption_key();
|
||||
let root_key = api_key_hmac_root_key(path, encryption_key.as_deref())?;
|
||||
let hk = Hkdf::<Sha256>::new(Some(b"netguardia-v1-salt"), root_key.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
if hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).is_err() {
|
||||
|
||||
@ -4,12 +4,13 @@ use rusqlite::{Transaction, params};
|
||||
use super::Database;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::database::DatabaseError;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::domain::response::defaults::{DEFAULT_PLAYBOOKS, DefaultPlaybook};
|
||||
use crate::interface::response::playbook_data::{
|
||||
ActionInput, ActionView, ActiveBlockView, ConditionView, CreateConditionInput, CreatePlaybookInput, ExecutionView,
|
||||
PendingUnblock, PlaybookView, UpdatePlaybookInput,
|
||||
};
|
||||
use crate::interface::response::soar::SoarRepo;
|
||||
use crate::interface::response::soar::{PlaybookRepo, SoarBlockRepo};
|
||||
|
||||
struct PlaybookActionRow {
|
||||
pb_id: i64,
|
||||
@ -203,7 +204,7 @@ fn seed_default_playbooks_tx(tx: &Transaction<'_>, defaults: &[DefaultPlaybook])
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SoarRepo for Database {
|
||||
impl PlaybookRepo for Database {
|
||||
async fn list_playbooks(&self) -> Result<Vec<PlaybookView>, Error> {
|
||||
let rows = self.list_playbooks_with_actions().await?;
|
||||
let mut result: Vec<PlaybookView> = Vec::new();
|
||||
@ -250,49 +251,6 @@ impl SoarRepo for Database {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
|
||||
self.count_active_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_active_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
|
||||
self.find_soar_block_by_id(id).await
|
||||
}
|
||||
|
||||
async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_expired_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
self.list_pending_unblocks().await
|
||||
}
|
||||
|
||||
async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
self.list_soar_executions(limit).await
|
||||
}
|
||||
|
||||
async fn seed_default_playbooks(&self) -> Result<(), Error> {
|
||||
self.seed_default_playbooks().await
|
||||
}
|
||||
|
||||
async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
|
||||
self.insert_pending_unblock(source_ip).await
|
||||
}
|
||||
|
||||
async fn insert_soar_execution(
|
||||
&self,
|
||||
playbook_id: i64,
|
||||
source_ip: Option<&str>,
|
||||
trigger_event: &str,
|
||||
actions_json: &str,
|
||||
) -> Result<i64, Error> {
|
||||
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn insert_playbook_atomic(
|
||||
&self,
|
||||
input: &CreatePlaybookInput,
|
||||
@ -382,6 +340,52 @@ impl SoarRepo for Database {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
|
||||
self.delete_playbook(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SoarBlockRepo for Database {
|
||||
async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
|
||||
self.count_active_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_active_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
|
||||
self.find_soar_block_by_id(id).await
|
||||
}
|
||||
|
||||
async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.list_expired_soar_blocks().await
|
||||
}
|
||||
|
||||
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
self.list_pending_unblocks().await
|
||||
}
|
||||
|
||||
async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
self.list_soar_executions(limit).await
|
||||
}
|
||||
|
||||
async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
|
||||
self.insert_pending_unblock(source_ip).await
|
||||
}
|
||||
|
||||
async fn insert_soar_execution(
|
||||
&self,
|
||||
playbook_id: i64,
|
||||
source_ip: Option<&str>,
|
||||
trigger_event: &str,
|
||||
actions_json: &str,
|
||||
) -> Result<i64, Error> {
|
||||
self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
|
||||
self.mark_soar_block_unblocked(id).await
|
||||
}
|
||||
@ -394,13 +398,30 @@ impl SoarRepo for Database {
|
||||
self.mark_pending_unblock_exhausted(id, last_error).await
|
||||
}
|
||||
|
||||
async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
|
||||
self.delete_playbook(id).await
|
||||
}
|
||||
|
||||
async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> {
|
||||
self.delete_pending_unblock(id).await
|
||||
}
|
||||
|
||||
async fn commit_soar_block_to_db(
|
||||
&self,
|
||||
source_ip: &str,
|
||||
ip_version: IpVersion,
|
||||
playbook_id: i64,
|
||||
expires_at: &str,
|
||||
) -> Result<i64, Error> {
|
||||
self.commit_soar_block_to_db(source_ip, ip_version, playbook_id, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_soar_unblock_to_db(
|
||||
&self,
|
||||
soar_block_id: i64,
|
||||
ip_version: IpVersion,
|
||||
source_ip: &str,
|
||||
) -> Result<(), Error> {
|
||||
self.commit_soar_unblock_to_db(soar_block_id, ip_version, source_ip)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
|
||||
use super::Database;
|
||||
use crate::common::error::Error;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::interface::response::playbook_data::{ActiveBlockView, PendingUnblock};
|
||||
use crate::interface::system::db_admin::DbAdminRepo;
|
||||
|
||||
fn active_block_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActiveBlockView> {
|
||||
Ok(ActiveBlockView {
|
||||
@ -246,30 +244,6 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbAdminRepo for Database {
|
||||
async fn commit_soar_block_to_db(
|
||||
&self,
|
||||
source_ip: &str,
|
||||
ip_version: IpVersion,
|
||||
playbook_id: i64,
|
||||
expires_at: &str,
|
||||
) -> Result<i64, Error> {
|
||||
self.commit_soar_block_to_db(source_ip, ip_version, playbook_id, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_soar_unblock_to_db(
|
||||
&self,
|
||||
soar_block_id: i64,
|
||||
ip_version: IpVersion,
|
||||
source_ip: &str,
|
||||
) -> Result<(), Error> {
|
||||
self.commit_soar_unblock_to_db(soar_block_id, ip_version, source_ip)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -8,14 +8,14 @@ use macros::log;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::common::log::suricata::SuricataLog;
|
||||
use crate::core::detection::send_detection_or_log;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::translate;
|
||||
use crate::domain::detection::log::{DetectionLog, SuricataLog};
|
||||
|
||||
const IANA_PROTO_ICMP: u8 = 1;
|
||||
const IANA_PROTO_TCP: u8 = 6;
|
||||
@ -190,20 +190,6 @@ fn may_be_alert_event(raw: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn send_detection_or_log(tx: &mpsc::Sender<DetectionEvent>, event: DetectionEvent) -> bool {
|
||||
match tx.try_send(event) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => {
|
||||
log!(DetectionLog::DetectionChannelDrop(
|
||||
format!("{:?}", dropped.source),
|
||||
dropped.attack_type,
|
||||
dropped.source_ip,
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -4,6 +4,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use macros::log;
|
||||
use reqwest::Client;
|
||||
use tokio::time::sleep;
|
||||
@ -177,7 +178,12 @@ impl TelegramAdapter {
|
||||
let country = telegram_html_escape(country_str);
|
||||
let threat_type = telegram_html_escape(&payload.threat_type);
|
||||
let action_description = telegram_html_escape(&payload.action_description);
|
||||
let timestamp = telegram_html_escape(&payload.timestamp);
|
||||
let ts_formatted = Utc
|
||||
.timestamp_opt(payload.timestamp, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
|
||||
.unwrap_or_else(|| payload.timestamp.to_string());
|
||||
let timestamp = telegram_html_escape(&ts_formatted);
|
||||
format!(
|
||||
"🛡 <b>[NetGuardia] {action}</b>\n\
|
||||
Source: <code>{src}</code> ({country})\n\
|
||||
@ -315,7 +321,7 @@ mod tests {
|
||||
threat_type: "\"><b>owned</b>".to_string(),
|
||||
confidence: 0.9,
|
||||
action_description: "blocked <now> & notified".to_string(),
|
||||
timestamp: "2026-05-07T00:00:00Z".to_string(),
|
||||
timestamp: 1778284800,
|
||||
};
|
||||
|
||||
let message = TelegramAdapter::format_alert_message(&payload);
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::handle;
|
||||
|
||||
use super::ws_bridge;
|
||||
use crate::domain::common::system::health::SystemHealthMetrics;
|
||||
use crate::interface::system::health_query::HealthQuery;
|
||||
|
||||
pub async fn websocket_system_health(
|
||||
@ -12,6 +15,11 @@ pub async fn websocket_system_health(
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
let rx = health.subscribe_to_metrics();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
spawn(ws_bridge::broadcast_loop(
|
||||
session,
|
||||
msg_stream,
|
||||
rx,
|
||||
|event: &Arc<SystemHealthMetrics>| ws_bridge::serialize_json(event.as_ref()),
|
||||
));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@ -9,16 +9,14 @@ use crate::adapter::http::session::SessionCookieService;
|
||||
use crate::core::common::statistics::FlowStatistics;
|
||||
use crate::core::identity::session_service::SessionService;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
use crate::domain::common::config::constants::{
|
||||
PERMISSION_AI_DETECTION_READ, PERMISSION_DASHBOARD_READ, PERMISSION_DROPS_READ, PERMISSION_FUSION_READ,
|
||||
PERMISSION_TRAFFIC_MAP_READ,
|
||||
};
|
||||
use crate::domain::common::event::ThreatDetectedEvent;
|
||||
use crate::domain::identity::auth::Claims;
|
||||
use crate::interface::system::health_query::HealthQuery;
|
||||
|
||||
const PERMISSION_DASHBOARD_READ: &str = "dashboard:read";
|
||||
const PERMISSION_AI_DETECTION_READ: &str = "ai_detection:read";
|
||||
const PERMISSION_FUSION_READ: &str = "fusion:read";
|
||||
const PERMISSION_TRAFFIC_MAP_READ: &str = "traffic_map:read";
|
||||
const PERMISSION_DROPS_READ: &str = "drops:read";
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/ws")
|
||||
.route("/health", web::get().to(health_ws))
|
||||
|
||||
@ -29,7 +29,7 @@ async fn handle_client_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
|
||||
pub fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
|
||||
@ -4,6 +4,7 @@ pub mod database;
|
||||
pub mod http;
|
||||
pub mod io;
|
||||
pub mod notification;
|
||||
pub mod suricata;
|
||||
pub mod system;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -14,10 +15,10 @@ use crate::common::error::database::DatabaseError;
|
||||
use crate::common::error::http::HttpError;
|
||||
use crate::common::error::io::IOError;
|
||||
use crate::common::error::notification::NotificationError;
|
||||
use crate::common::error::suricata::SuricataError;
|
||||
use crate::common::error::system::SystemError;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::error::SuricataError;
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::domain::report::error::ReportError;
|
||||
use crate::domain::response::error::SoarError;
|
||||
|
||||
19
net-guardia/src/common/error/suricata.rs
Normal file
19
net-guardia/src/common/error/suricata.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use macros::traceable;
|
||||
|
||||
traceable! {
|
||||
SuricataError {
|
||||
#[no_source]
|
||||
#[error("Suricata binary not found at '{path}'")]
|
||||
BinaryNotFound { path: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Suricata config not found at '{path}'")]
|
||||
ConfigNotFound { path: String } => tracing::Level::ERROR,
|
||||
|
||||
#[error("Failed to spawn Suricata subprocess")]
|
||||
SpawnFailed => tracing::Level::ERROR,
|
||||
|
||||
#[error("Failed to open eve.json stream at '{path}'")]
|
||||
EveOpenFailed { path: String } => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
@ -29,10 +29,6 @@ traceable! {
|
||||
#[error("Configuration file not found")]
|
||||
ConfigNotFound => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Failed to send shutdown signal")]
|
||||
ShutdownSignalFailed => tracing::Level::ERROR,
|
||||
|
||||
#[error("Unexpected error")]
|
||||
UnexpectedError => tracing::Level::ERROR,
|
||||
|
||||
|
||||
@ -18,8 +18,8 @@ loggable! {
|
||||
#[error("AuditLogger: DB write failed for drift event: {error}")]
|
||||
AuditDriftDbWriteFailed { error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("AuditLogger lagged by {count} events")]
|
||||
AuditLagged { count: u64 } => tracing::Level::WARN,
|
||||
#[error("AuditLogger [{channel}] lagged by {count} events")]
|
||||
AuditLagged { channel: String, count: u64 } => tracing::Level::WARN,
|
||||
|
||||
#[error("AuditLogger: event channel closed")]
|
||||
AuditChannelClosed => tracing::Level::INFO,
|
||||
|
||||
@ -6,4 +6,5 @@ pub mod health;
|
||||
pub mod http;
|
||||
pub mod notification;
|
||||
pub mod reporting;
|
||||
pub mod suricata;
|
||||
pub mod system;
|
||||
|
||||
48
net-guardia/src/common/log/suricata.rs
Normal file
48
net-guardia/src/common/log/suricata.rs
Normal file
@ -0,0 +1,48 @@
|
||||
use macros::loggable;
|
||||
use tracing;
|
||||
|
||||
loggable! {
|
||||
SuricataLog {
|
||||
#[error("Suricata bridge disabled by config")]
|
||||
Disabled => tracing::Level::INFO,
|
||||
|
||||
#[error("Spawning Suricata: {binary} -c {config} -i {iface}")]
|
||||
Spawning { binary: String, config: String, iface: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess started (pid={pid})")]
|
||||
Started { pid: u32 } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess exited unexpectedly: {reason}. Restart in {backoff}s")]
|
||||
CrashedRestartPending { reason: String, backoff: u64 } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata subprocess stopped: {reason}")]
|
||||
Stopped { reason: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess sent SIGTERM for graceful shutdown")]
|
||||
ShutdownRequested => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess SIGTERM failed: {error}")]
|
||||
ShutdownSignalFailed { error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata subprocess SIGKILL failed after timeout: {error}")]
|
||||
ShutdownKillFailed { error: String } => tracing::Level::ERROR,
|
||||
|
||||
#[error("Suricata eve.json monitor waiting for file: {path}")]
|
||||
MonitorWaitingForFile { path: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata eve.json monitor failed to open {path}: {error}")]
|
||||
MonitorOpenFailed { path: String, error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata eve.json monitor failed to seek to end of {path}: {error}")]
|
||||
MonitorSeekFailed { path: String, error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata eve.json monitor attached to {path}")]
|
||||
MonitorAttached { path: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata eve.json rotated — reopening")]
|
||||
MonitorFileRotated => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata alert forwarded: sid={sid} {src}->{dst} {signature}")]
|
||||
AlertForwarded { sid: u32, src: String, dst: String, signature: String } => tracing::Level::DEBUG,
|
||||
}
|
||||
}
|
||||
9
net-guardia/src/common/utils/log_level.rs
Normal file
9
net-guardia/src/common/utils/log_level.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub fn level_severity(level: &str) -> u8 {
|
||||
match level {
|
||||
"ERROR" => 1,
|
||||
"WARN" => 2,
|
||||
"INFO" => 3,
|
||||
"DEBUG" => 4,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod ip_address;
|
||||
pub mod log_level;
|
||||
pub mod packet_parser;
|
||||
pub mod security;
|
||||
|
||||
@ -6,8 +6,9 @@ use macros::log;
|
||||
|
||||
use crate::core::correlation::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation};
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
struct TimedSourceSet {
|
||||
|
||||
@ -4,15 +4,16 @@ use std::time::Duration;
|
||||
use arc_swap::ArcSwap;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::core::correlation::botnet::BotnetDetector;
|
||||
use crate::core::correlation::lateral::LateralMovementDetector;
|
||||
use crate::core::correlation::scan::ScanDetector;
|
||||
use crate::core::detection::send_detection_or_log;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionEvent, FlowObservation};
|
||||
use crate::domain::common::event::DetectionEvent;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
pub struct CorrelationEngine {
|
||||
@ -66,13 +67,13 @@ impl CorrelationEngine {
|
||||
|
||||
fn process_alert(&self, alert: &FlowObservation) {
|
||||
if let Some(event) = self.botnet.process(alert) {
|
||||
let _ = send_or_log(&self.detection_tx, event);
|
||||
let _ = send_detection_or_log(&self.detection_tx, event);
|
||||
}
|
||||
if let Some(event) = self.scan.process(alert) {
|
||||
let _ = send_or_log(&self.detection_tx, event);
|
||||
let _ = send_detection_or_log(&self.detection_tx, event);
|
||||
}
|
||||
if let Some(event) = self.lateral.process(alert) {
|
||||
let _ = send_or_log(&self.detection_tx, event);
|
||||
let _ = send_detection_or_log(&self.detection_tx, event);
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,26 +85,12 @@ impl CorrelationEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn send_or_log(tx: &mpsc::Sender<DetectionEvent>, event: DetectionEvent) -> bool {
|
||||
match tx.try_send(event) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => {
|
||||
log!(DetectionLog::DetectionChannelDrop(
|
||||
format!("{:?}", dropped.source),
|
||||
dropped.attack_type,
|
||||
dropped.source_ip,
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::common::event::DetectionSource;
|
||||
use crate::core::detection::send_detection_or_log;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
|
||||
fn detection_event() -> DetectionEvent {
|
||||
DetectionEvent {
|
||||
@ -122,25 +109,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_or_log_reports_success() {
|
||||
fn send_detection_or_log_reports_success() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
|
||||
assert!(send_or_log(&tx, detection_event()));
|
||||
assert!(send_detection_or_log(&tx, detection_event()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_or_log_reports_full_channel_drop() {
|
||||
fn send_detection_or_log_reports_full_channel_drop() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
tx.try_send(detection_event()).unwrap();
|
||||
|
||||
assert!(!send_or_log(&tx, detection_event()));
|
||||
assert!(!send_detection_or_log(&tx, detection_event()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_or_log_reports_closed_channel_drop() {
|
||||
fn send_detection_or_log_reports_closed_channel_drop() {
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(rx);
|
||||
|
||||
assert!(!send_or_log(&tx, detection_event()));
|
||||
assert!(!send_detection_or_log(&tx, detection_event()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,9 @@ use macros::log;
|
||||
use crate::common::utils::ip_address::is_internal_ip;
|
||||
use crate::core::correlation::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation};
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
struct TimedDestSet {
|
||||
|
||||
@ -6,8 +6,9 @@ use macros::log;
|
||||
|
||||
use crate::core::correlation::correlation_cleanup::capped_cleanup;
|
||||
use crate::domain::common::config::correlation::CorrelationDetectorParams;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation};
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
struct TimedPortSet {
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::{SocketAddrV4, SocketAddrV6};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
|
||||
use std::sync::Arc;
|
||||
|
||||
use macros::log;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::log::data_plane::DataPlaneLog;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_version::IpVersion;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
use crate::interface::data_plane::access_control_admin::AccessControlAdminPort;
|
||||
use crate::interface::data_plane::acl::AclRepo;
|
||||
use crate::interface::data_plane::enforcement::EnforcementRepo;
|
||||
use crate::interface::data_plane::enforcement::GeoEnforcementPort;
|
||||
use crate::interface::data_plane::geo_block_api::GeoBlockPort;
|
||||
|
||||
pub struct AclService {
|
||||
acl_repo: Arc<dyn AclRepo>,
|
||||
enforcement_repo: Arc<dyn EnforcementRepo>,
|
||||
enforcement_repo: Arc<dyn GeoEnforcementPort>,
|
||||
access_control: Arc<dyn AccessControlAdminPort>,
|
||||
geo_block: Arc<dyn GeoBlockPort>,
|
||||
}
|
||||
@ -24,7 +25,7 @@ pub struct AclService {
|
||||
impl AclService {
|
||||
pub fn new(
|
||||
acl_repo: Arc<dyn AclRepo>,
|
||||
enforcement_repo: Arc<dyn EnforcementRepo>,
|
||||
enforcement_repo: Arc<dyn GeoEnforcementPort>,
|
||||
access_control: Arc<dyn AccessControlAdminPort>,
|
||||
geo_block: Arc<dyn GeoBlockPort>,
|
||||
) -> Self {
|
||||
@ -36,6 +37,70 @@ impl AclService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restore(&self) {
|
||||
self.restore_geo_countries().await;
|
||||
self.restore_acl_rules().await;
|
||||
}
|
||||
|
||||
async fn restore_geo_countries(&self) {
|
||||
let countries = match self.enforcement_repo.load_geo_countries().await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
_ => return,
|
||||
};
|
||||
match self.geo_block.block_countries(&countries) {
|
||||
Ok(_) => log!(DataPlaneLog::GeoCountriesRestored(countries.len())),
|
||||
Err(e) => log!(DataPlaneLog::GeoRestoreFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_acl_rules(&self) {
|
||||
let rules = match self.acl_repo.list_acl_rules().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut restored = 0u32;
|
||||
for rule in &rules {
|
||||
let result = match rule.ip_version {
|
||||
IpVersion::V4 => match rule.ip_address.parse::<Ipv4Addr>() {
|
||||
Ok(addr) => self.access_control.add_ipv4_list(
|
||||
rule.direction,
|
||||
rule.list_type,
|
||||
SocketAddrV4::new(addr, rule.port),
|
||||
),
|
||||
Err(e) => {
|
||||
log!(DataPlaneLog::AclIpv4ParseFailed(rule.ip_address.clone(), e.to_string()));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
IpVersion::V6 => match rule.ip_address.parse::<Ipv6Addr>() {
|
||||
Ok(addr) => self.access_control.add_ipv6_list(
|
||||
rule.direction,
|
||||
rule.list_type,
|
||||
SocketAddrV6::new(addr, rule.port, 0, 0),
|
||||
),
|
||||
Err(e) => {
|
||||
log!(DataPlaneLog::AclIpv6ParseFailed(rule.ip_address.clone(), e.to_string()));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log!(DataPlaneLog::AclRuleRestoreFailed(
|
||||
rule.direction.as_str().to_string(),
|
||||
rule.list_type.as_str().to_string(),
|
||||
rule.ip_address.clone(),
|
||||
rule.port,
|
||||
e.to_string()
|
||||
));
|
||||
} else {
|
||||
restored += 1;
|
||||
}
|
||||
}
|
||||
if restored > 0 {
|
||||
log!(DataPlaneLog::AclRulesRestored(restored as usize));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_ipv4(
|
||||
&self,
|
||||
direction: FlowDirection,
|
||||
|
||||
@ -9,23 +9,38 @@ use crate::common::log::data_plane::DataPlaneLog;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::data_plane::dns_filter_api::DnsFilterPort;
|
||||
use crate::interface::data_plane::enforcement::EnforcementRepo;
|
||||
use crate::interface::data_plane::enforcement::DnsEnforcementPort;
|
||||
|
||||
pub struct DnsFilterService {
|
||||
db: Arc<dyn EnforcementRepo>,
|
||||
db: Arc<dyn DnsEnforcementPort>,
|
||||
dns_filter: Arc<dyn DnsFilterPort>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
impl DnsFilterService {
|
||||
pub fn new(
|
||||
db: Arc<dyn EnforcementRepo>,
|
||||
db: Arc<dyn DnsEnforcementPort>,
|
||||
dns_filter: Arc<dyn DnsFilterPort>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
) -> Self {
|
||||
Self { db, dns_filter, config }
|
||||
}
|
||||
|
||||
pub async fn restore(&self) {
|
||||
let domains = match self.db.load_dns_domains().await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
for domain in &domains {
|
||||
if let Err(e) = self.dns_filter.add_domain(domain) {
|
||||
log!(DataPlaneLog::DnsRestoreFailed(domain.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
if !domains.is_empty() {
|
||||
log!(DataPlaneLog::DnsBlacklistRestored(domains.len()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_domains(&self) -> Vec<String> {
|
||||
self.dns_filter.list_domains()
|
||||
}
|
||||
@ -137,9 +152,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EnforcementRepo for FailingDnsRepo {
|
||||
async fn set_rate_limits(&self, _values: &[(String, u64)]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
impl DnsEnforcementPort for FailingDnsRepo {
|
||||
async fn load_dns_domains(&self) -> Result<Vec<String>, Error> {
|
||||
Ok(self.domains.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
@ -157,14 +172,6 @@ mod tests {
|
||||
self.domains.lock().unwrap().retain(|d| !domains.contains(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_geo_countries(&self, _codes: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_geo_countries(&self, _codes: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_config() -> Arc<ArcSwap<AppConfig>> {
|
||||
|
||||
@ -6,7 +6,7 @@ use crate::common::error::Error;
|
||||
use crate::common::log::data_plane::DataPlaneLog;
|
||||
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::data_plane::enforcement::EnforcementRepo;
|
||||
use crate::interface::data_plane::enforcement::RateLimitWritePort;
|
||||
use crate::interface::data_plane::rate_limit_api::RateLimitPort;
|
||||
|
||||
const PACKET_RATE_KEY: &str = "packet_rate";
|
||||
@ -22,15 +22,30 @@ struct RateLimitChange {
|
||||
}
|
||||
|
||||
pub struct RateLimitService {
|
||||
db: Arc<dyn EnforcementRepo>,
|
||||
db: Arc<dyn RateLimitWritePort>,
|
||||
config: Arc<dyn RateLimitPort>,
|
||||
}
|
||||
|
||||
impl RateLimitService {
|
||||
pub fn new(db: Arc<dyn EnforcementRepo>, config: Arc<dyn RateLimitPort>) -> Self {
|
||||
pub fn new(db: Arc<dyn RateLimitWritePort>, config: Arc<dyn RateLimitPort>) -> Self {
|
||||
Self { db, config }
|
||||
}
|
||||
|
||||
pub async fn restore(&self) {
|
||||
let configs = match self.db.load_rate_limit_config().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
for (key, value) in &configs {
|
||||
if let Err(e) = self.apply(key, *value) {
|
||||
log!(DataPlaneLog::RateLimitRestoreFailed(key.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
if !configs.is_empty() {
|
||||
log!(DataPlaneLog::RateLimitsRestored(configs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_settings(&self) -> Result<RateLimitSettings, Error> {
|
||||
Ok(RateLimitSettings {
|
||||
packet_rate: Some(self.config.get_packet_rate()?),
|
||||
@ -153,7 +168,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EnforcementRepo for FakeRepo {
|
||||
impl RateLimitWritePort for FakeRepo {
|
||||
async fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, Error> {
|
||||
Ok(self.values.lock().expect("test lock").clone())
|
||||
}
|
||||
|
||||
async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> {
|
||||
if self.fail {
|
||||
Err(DatabaseError::QueryFailed("forced db failure"))?;
|
||||
@ -161,22 +180,6 @@ mod tests {
|
||||
self.values.lock().expect("test lock").extend_from_slice(values);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_dns_domains(&self, _domains: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_dns_domains(&self, _domains: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_geo_countries(&self, _codes: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_geo_countries(&self, _codes: &[String]) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRateLimitPort {
|
||||
|
||||
@ -5,13 +5,13 @@ use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation};
|
||||
use crate::domain::common::event::{DetectionEvent, DetectionSource};
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
pub struct BeaconingDetector {
|
||||
@ -58,13 +58,7 @@ impl BeaconingDetector {
|
||||
}
|
||||
_ = analysis_interval.tick() => {
|
||||
for event in self.state.analyze() {
|
||||
if let Err(TrySendError::Full(d)) = self.detection_tx.try_send(event) {
|
||||
log!(DetectionLog::DetectionChannelDrop(
|
||||
format!("{:?}", d.source),
|
||||
d.attack_type,
|
||||
d.source_ip,
|
||||
));
|
||||
}
|
||||
super::send_detection_or_log(&self.detection_tx, event);
|
||||
}
|
||||
self.state.cleanup();
|
||||
}
|
||||
@ -244,8 +238,9 @@ mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::core::detection::beaconing::{BeaconingState, CachedFlow, compute_cv};
|
||||
use crate::domain::common::event::{DetectionSource, FlowObservation};
|
||||
use crate::domain::common::event::DetectionSource;
|
||||
use crate::domain::detection::attack_type::CanonicalAttackType;
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
|
||||
fn test_key(protocol: u8, dst_port: u16) -> super::BeaconingKey {
|
||||
super::BeaconingKey {
|
||||
|
||||
@ -1,3 +1,24 @@
|
||||
use macros::log;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
|
||||
use crate::domain::common::event::DetectionEvent;
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
|
||||
pub mod beaconing;
|
||||
pub mod metrics;
|
||||
pub mod orchestrator;
|
||||
|
||||
pub fn send_detection_or_log(tx: &mpsc::Sender<DetectionEvent>, event: DetectionEvent) -> bool {
|
||||
match tx.try_send(event) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => {
|
||||
log!(DetectionLog::DetectionChannelDrop(
|
||||
format!("{:?}", dropped.source),
|
||||
dropped.attack_type,
|
||||
dropped.source_ip,
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::common::log::audit::AuditLog;
|
||||
@ -16,9 +15,10 @@ use crate::core::detection::metrics::FusionMetrics;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR};
|
||||
use crate::domain::common::event::{
|
||||
AuditEvent, DetectionDiagnostic, DetectionEvent, DetectionSource, FlowObservation, ThreatDetectedEvent,
|
||||
AuditEvent, DetectionDiagnostic, DetectionEvent, DetectionSource, ThreatDetectedEvent,
|
||||
};
|
||||
use crate::domain::detection::attack_type::{CanonicalAttackType, translate};
|
||||
use crate::domain::detection::flow_observation::FlowObservation;
|
||||
use crate::domain::detection::fusion_math::{FusionWindowLengths, fused_confidence};
|
||||
use crate::domain::detection::log::DetectionLog;
|
||||
use crate::domain::detection::ml_detection::AlertMessage;
|
||||
@ -376,7 +376,7 @@ pub async fn bridge_ml_to_detection(mut rx: broadcast::Receiver<AlertMessage>, t
|
||||
anomaly_score: alert.anomaly_score,
|
||||
c2_score: alert.c2_score,
|
||||
};
|
||||
if !send_detection_event_or_log(&tx, event) && tx.is_closed() {
|
||||
if !super::send_detection_or_log(&tx, event) && tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -419,20 +419,6 @@ pub async fn bridge_ml_to_flow_observation(
|
||||
}
|
||||
}
|
||||
|
||||
fn send_detection_event_or_log(tx: &mpsc::Sender<DetectionEvent>, event: DetectionEvent) -> bool {
|
||||
match tx.try_send(event) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => {
|
||||
log!(DetectionLog::DetectionChannelDrop(
|
||||
format!("{:?}", dropped.source),
|
||||
dropped.attack_type,
|
||||
dropped.source_ip,
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fusion_audit_detail(src_ip: &str, attack_type: &str, fused: f32, per_source: &[SourceSample]) -> String {
|
||||
let per_source_json: Vec<serde_json::Value> = per_source
|
||||
.iter()
|
||||
@ -552,18 +538,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_detection_event_or_log_reports_full_channel_drop() {
|
||||
fn send_detection_or_log_reports_full_channel_drop() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
tx.try_send(detection_event()).expect("fill channel");
|
||||
|
||||
assert!(!send_detection_event_or_log(&tx, detection_event()));
|
||||
assert!(!super::super::send_detection_or_log(&tx, detection_event()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_detection_event_or_log_reports_closed_channel_drop() {
|
||||
fn send_detection_or_log_reports_closed_channel_drop() {
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(rx);
|
||||
|
||||
assert!(!send_detection_event_or_log(&tx, detection_event()));
|
||||
assert!(!super::super::send_detection_or_log(&tx, detection_event()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,10 +4,9 @@ use macros::log;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::codec::CodecError;
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::domain::identity::user::{GroupMemberView, UserGroupView};
|
||||
use crate::core::identity::user_service::role_from_group_names;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::{AuthError, LoginError, RegisterError};
|
||||
use crate::domain::identity::validation::{validate_password, validate_username};
|
||||
use crate::interface::identity::auth_repo::IdentityAuthRepo;
|
||||
use crate::interface::identity::password_hasher::PasswordHasher;
|
||||
@ -28,98 +27,6 @@ pub struct LoginResult {
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserListResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub force_password_change: bool,
|
||||
pub created_at: String,
|
||||
pub groups: Vec<UserGroupMembershipResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserGroupMembershipResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupMemberResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupListResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub members: Vec<GroupMemberResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupDetailResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub members: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupMutationResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoginError {
|
||||
Locked { retry_after_secs: u64 },
|
||||
InvalidCredentials,
|
||||
InternalError,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RegisterError {
|
||||
Validation(&'static str),
|
||||
InvalidRole,
|
||||
Forbidden,
|
||||
HashFailed,
|
||||
Conflict(Error),
|
||||
Internal(Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityAdminError {
|
||||
Validation(String),
|
||||
Unauthorized,
|
||||
Forbidden(String),
|
||||
NotFound(String),
|
||||
HashFailed,
|
||||
Conflict(Error),
|
||||
Internal(Error),
|
||||
}
|
||||
|
||||
pub fn parse_permissions(raw: &str) -> Result<serde_json::Value, Error> {
|
||||
let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(db: Arc<dyn IdentityAuthRepo>, password_hasher: Arc<dyn PasswordHasher>) -> Self {
|
||||
Self { db, password_hasher }
|
||||
@ -128,9 +35,7 @@ impl AuthService {
|
||||
pub async fn login(&self, username: &str, raw_password: &str) -> Result<LoginResult, LoginError> {
|
||||
match self.db.get_remaining_lock_secs(username).await {
|
||||
Ok(Some(remaining)) => {
|
||||
return Err(LoginError::Locked {
|
||||
retry_after_secs: remaining,
|
||||
});
|
||||
return Err(LoginError::Locked(remaining));
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = self.db.clear_expired_login_lock(username).await {
|
||||
@ -226,9 +131,7 @@ impl AuthService {
|
||||
if let Err(cleanup_err) = self.db.delete_user(new_id).await {
|
||||
log!(AuthError::GroupAssignmentFailed(cleanup_err));
|
||||
}
|
||||
return Err(RegisterError::Internal(
|
||||
AuthError::GroupAssignmentFailed(message).into(),
|
||||
));
|
||||
return Err(RegisterError::Internal(message));
|
||||
}
|
||||
|
||||
Ok(new_id)
|
||||
@ -240,365 +143,13 @@ impl AuthService {
|
||||
.into_iter()
|
||||
.find(|group| group.name == group_name)
|
||||
.map(|group| group.id)
|
||||
.ok_or_else(|| RegisterError::Internal(AuthError::DefaultGroupMissing(group_name).into()))
|
||||
.ok_or_else(|| RegisterError::Internal(format!("Default group '{group_name}' is missing")))
|
||||
}
|
||||
|
||||
pub async fn user_profile(&self, user_id: i64, username: &str) -> Result<UserProfile, Error> {
|
||||
let groups_raw = self.db.list_groups_for_user(user_id).await?;
|
||||
let role = role_from_group_names(groups_raw.iter().map(|group| group.name.as_str())).to_string();
|
||||
let groups = groups_raw.into_iter().map(|group| group.name).collect();
|
||||
let permissions = self.db.list_user_permissions(user_id).await?;
|
||||
|
||||
Ok(UserProfile {
|
||||
id: user_id,
|
||||
username: username.to_string(),
|
||||
role,
|
||||
permissions,
|
||||
groups,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn derive_role(&self, user_id: i64) -> Result<String, Error> {
|
||||
async fn derive_role(&self, user_id: i64) -> Result<String, Error> {
|
||||
let groups = self.db.list_groups_for_user(user_id).await?;
|
||||
Ok(role_from_group_names(groups.iter().map(|group| group.name.as_str())).to_string())
|
||||
}
|
||||
|
||||
pub async fn list_users(&self) -> Result<Vec<UserListResponse>, Error> {
|
||||
let users = self.db.list_users_with_groups().await?;
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.map(|user| {
|
||||
let role = role_from_group_names(user.groups.iter().map(|group| group.group_name.as_str())).to_string();
|
||||
let groups: Vec<UserGroupMembershipResponse> = user
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|group| UserGroupMembershipResponse {
|
||||
id: group.group_id,
|
||||
name: group.group_name,
|
||||
})
|
||||
.collect();
|
||||
UserListResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role,
|
||||
force_password_change: user.force_password_change,
|
||||
created_at: user.created_at,
|
||||
groups,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: i64,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), IdentityAdminError> {
|
||||
validate_password(new_password).map_err(|msg| IdentityAdminError::Validation(msg.to_string()))?;
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?
|
||||
.ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?;
|
||||
|
||||
match self
|
||||
.password_hasher
|
||||
.verify_password(current_password, &user.password_hash)
|
||||
{
|
||||
Ok(true) => {}
|
||||
_ => return Err(IdentityAdminError::Unauthorized),
|
||||
}
|
||||
|
||||
let new_hash = self
|
||||
.password_hasher
|
||||
.hash_password(new_password)
|
||||
.map_err(|_| IdentityAdminError::HashFailed)?;
|
||||
self.db
|
||||
.update_user_password(user_id, &new_hash)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
|
||||
pub async fn delete_user(&self, caller_user_id: i64, target_user_id: i64) -> Result<bool, IdentityAdminError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(IdentityAdminError::Validation(
|
||||
"Cannot delete your own account".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?;
|
||||
if let Some(user) = user.as_ref()
|
||||
&& user.username == DEFAULT_ADMIN_USERNAME
|
||||
{
|
||||
return Err(IdentityAdminError::Forbidden(
|
||||
"Cannot delete the built-in admin account".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db
|
||||
.delete_user(target_user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
|
||||
pub async fn update_role(
|
||||
&self,
|
||||
caller_user_id: i64,
|
||||
target_user_id: i64,
|
||||
role: &str,
|
||||
) -> Result<(), IdentityAdminError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(IdentityAdminError::Validation(
|
||||
"Cannot change your own role".to_string(),
|
||||
));
|
||||
}
|
||||
if role != ROLE_ADMIN && role != ROLE_VIEWER {
|
||||
return Err(IdentityAdminError::Validation(
|
||||
"Role must be 'admin' or 'viewer'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?
|
||||
.ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?;
|
||||
|
||||
if user.username == DEFAULT_ADMIN_USERNAME {
|
||||
return Err(IdentityAdminError::Forbidden(
|
||||
"Cannot change the built-in admin account role".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_user_role(target_user_id, role)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
|
||||
pub async fn reset_password(&self, target_user_id: i64, new_password: &str) -> Result<(), IdentityAdminError> {
|
||||
validate_password(new_password).map_err(|msg| IdentityAdminError::Validation(msg.to_string()))?;
|
||||
|
||||
self.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?
|
||||
.ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?;
|
||||
|
||||
let hash = self
|
||||
.password_hasher
|
||||
.hash_password(new_password)
|
||||
.map_err(|_| IdentityAdminError::HashFailed)?;
|
||||
self.db
|
||||
.reset_user_password(target_user_id, &hash)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
|
||||
pub async fn list_groups(&self) -> Result<Vec<GroupListResponse>, Error> {
|
||||
let groups = self.db.list_user_groups().await?;
|
||||
let mut result = Vec::with_capacity(groups.len());
|
||||
for group in groups {
|
||||
let permissions = parse_permissions(&group.permissions)?;
|
||||
let member_views = self.db.list_group_members(group.id).await?;
|
||||
let members = member_views.into_iter().map(group_member_response).collect();
|
||||
result.push(GroupListResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
permissions,
|
||||
created_at: group.created_at,
|
||||
members,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn create_group(
|
||||
&self,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
permissions: Option<&serde_json::Value>,
|
||||
) -> Result<GroupMutationResponse, IdentityAdminError> {
|
||||
let name = match name {
|
||||
Some(name) if !name.is_empty() => name,
|
||||
_ => return Err(IdentityAdminError::Validation("Group name is required".to_string())),
|
||||
};
|
||||
let description = description.unwrap_or("");
|
||||
let (permissions, permissions_json) = permission_array_json(permissions, "[]")?;
|
||||
|
||||
let id = self
|
||||
.db
|
||||
.create_user_group(name, description, &permissions)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Conflict)?;
|
||||
|
||||
Ok(GroupMutationResponse {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
permissions: permissions_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_group(&self, group_id: i64) -> Result<Option<GroupDetailResponse>, Error> {
|
||||
let Some(group) = self.db.get_user_group(group_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let permissions = parse_permissions(&group.permissions)?;
|
||||
let members = self.db.list_group_member_ids(group_id).await?;
|
||||
Ok(Some(group_detail_response(group, permissions, members)))
|
||||
}
|
||||
|
||||
pub async fn update_group(
|
||||
&self,
|
||||
group_id: i64,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
permissions: Option<&serde_json::Value>,
|
||||
) -> Result<GroupMutationResponse, IdentityAdminError> {
|
||||
let existing = self
|
||||
.db
|
||||
.get_user_group(group_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?
|
||||
.ok_or_else(|| IdentityAdminError::NotFound("Group not found".to_string()))?;
|
||||
|
||||
if is_builtin_group(&existing.name) {
|
||||
return Err(IdentityAdminError::Forbidden(
|
||||
"Cannot modify built-in groups".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let name = name.unwrap_or(&existing.name).to_string();
|
||||
let description = description.unwrap_or(&existing.description).to_string();
|
||||
let (permissions, permissions_json) = permission_array_json(permissions, &existing.permissions)?;
|
||||
|
||||
self.db
|
||||
.update_user_group(group_id, &name, &description, &permissions)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?;
|
||||
|
||||
Ok(GroupMutationResponse {
|
||||
id: group_id,
|
||||
name,
|
||||
description,
|
||||
permissions: permissions_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(&self, group_id: i64) -> Result<bool, IdentityAdminError> {
|
||||
let group = self
|
||||
.db
|
||||
.get_user_group(group_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?;
|
||||
if let Some(group) = group.as_ref()
|
||||
&& is_builtin_group(&group.name)
|
||||
{
|
||||
return Err(IdentityAdminError::Forbidden(
|
||||
"Cannot delete built-in groups".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db
|
||||
.delete_user_group(group_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
|
||||
pub async fn set_user_groups(
|
||||
&self,
|
||||
caller_user_id: i64,
|
||||
target_user_id: i64,
|
||||
group_ids: &[i64],
|
||||
) -> Result<(), IdentityAdminError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(IdentityAdminError::Validation(
|
||||
"Cannot modify your own groups".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)?
|
||||
.ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?;
|
||||
|
||||
if user.username == DEFAULT_ADMIN_USERNAME {
|
||||
return Err(IdentityAdminError::Forbidden(
|
||||
"Cannot modify groups for the built-in admin account".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db
|
||||
.set_user_groups(target_user_id, group_ids)
|
||||
.await
|
||||
.map_err(IdentityAdminError::Internal)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_member_response(member: GroupMemberView) -> GroupMemberResponse {
|
||||
GroupMemberResponse {
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_detail_response(
|
||||
group: UserGroupView,
|
||||
permissions: serde_json::Value,
|
||||
members: Vec<i64>,
|
||||
) -> GroupDetailResponse {
|
||||
GroupDetailResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
permissions,
|
||||
created_at: group.created_at,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_builtin_group(name: &str) -> bool {
|
||||
name == GROUP_ADMIN || name == GROUP_VIEWER
|
||||
}
|
||||
|
||||
fn permission_array_json(
|
||||
permissions: Option<&serde_json::Value>,
|
||||
default: &str,
|
||||
) -> Result<(String, serde_json::Value), IdentityAdminError> {
|
||||
match permissions {
|
||||
Some(value) => {
|
||||
let permissions = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| IdentityAdminError::Validation("Permissions must be an array of strings".to_string()))?;
|
||||
let json = serde_json::to_string(&permissions)
|
||||
.map_err(|err| IdentityAdminError::Internal(CodecError::SerializeFailed(err).into()))?;
|
||||
let value = serde_json::Value::Array(permissions.into_iter().map(serde_json::Value::String).collect());
|
||||
Ok((json, value))
|
||||
}
|
||||
None => Ok((
|
||||
default.to_string(),
|
||||
parse_permissions(default).map_err(IdentityAdminError::Internal)?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn role_from_group_names<'a>(names: impl IntoIterator<Item = &'a str>) -> &'static str {
|
||||
if names.into_iter().any(|name| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
ROLE_VIEWER
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -610,7 +161,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::identity::password_hasher::Argon2PasswordHasher;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::database::DatabaseError;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::user::{GroupMemberView, UserGroupView, UserView, UserWithGroupsView};
|
||||
use crate::interface::identity::auth_repo::{IdentityAuthRepo, LoginAttemptRepo, UserGroupRepo, UserRepo};
|
||||
use crate::interface::identity::password_hasher::PasswordHasher;
|
||||
@ -765,114 +318,6 @@ mod tests {
|
||||
assert!(matches!(result, Err(LoginError::InternalError)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_verifies_current_password_and_updates_hash() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
auth.change_password(user_id, "Correct Horse 123!", "New Password 456!")
|
||||
.await
|
||||
.expect("change password");
|
||||
|
||||
let user = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
!hasher
|
||||
.verify_password("Correct Horse 123!", &user.password_hash)
|
||||
.expect("verify old")
|
||||
);
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("New Password 456!", &user.password_hash)
|
||||
.expect("verify new")
|
||||
);
|
||||
assert!(!user.force_password_change);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_verifies_password_for_target_user_id() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let alice_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
create_viewer(&db, "bob", "Bob Password 123!").await;
|
||||
|
||||
let err = auth
|
||||
.change_password(alice_id, "Bob Password 123!", "New Password 456!")
|
||||
.await
|
||||
.expect_err("another user's password must not authorize the change");
|
||||
|
||||
assert!(matches!(err, IdentityAdminError::Unauthorized));
|
||||
|
||||
let alice = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("Correct Horse 123!", &alice.password_hash)
|
||||
.expect("verify alice password")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_role_rejects_self_change_before_db_update() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
let err = auth
|
||||
.update_role(user_id, user_id, ROLE_ADMIN)
|
||||
.await
|
||||
.expect_err("self role change should be rejected");
|
||||
|
||||
assert!(matches!(err, IdentityAdminError::Validation(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_role_rejects_builtin_admin_role_change() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash_password("Default Admin 123!").expect("hash");
|
||||
let admin_id = db
|
||||
.insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false)
|
||||
.await
|
||||
.expect("insert built-in admin");
|
||||
let admin_group = db
|
||||
.list_user_groups()
|
||||
.await
|
||||
.expect("groups")
|
||||
.into_iter()
|
||||
.find(|g| g.name == GROUP_ADMIN)
|
||||
.expect("admin group");
|
||||
db.set_user_groups(admin_id, &[admin_group.id])
|
||||
.await
|
||||
.expect("assign admin group");
|
||||
|
||||
let err = auth
|
||||
.update_role(999, admin_id, ROLE_VIEWER)
|
||||
.await
|
||||
.expect_err("built-in admin role change should be rejected");
|
||||
|
||||
assert!(matches!(err, IdentityAdminError::Forbidden(_)));
|
||||
let groups = db.list_groups_for_user(admin_id).await.expect("admin groups");
|
||||
assert!(groups.iter().any(|group| group.name == GROUP_ADMIN));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_password_marks_force_password_change() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
auth.reset_password(user_id, "Reset Password 456!")
|
||||
.await
|
||||
.expect("reset password");
|
||||
|
||||
let user = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("Reset Password 456!", &user.password_hash)
|
||||
.expect("verify reset")
|
||||
);
|
||||
assert!(user.force_password_change);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_password_change_login_has_no_admin_permissions() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
@ -899,87 +344,6 @@ mod tests {
|
||||
assert!(login.permissions.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_workflows_parse_permissions_and_manage_membership() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
let permissions = serde_json::json!(["dashboard:view"]);
|
||||
|
||||
let created = auth
|
||||
.create_group(Some("operators"), Some("Ops"), Some(&permissions))
|
||||
.await
|
||||
.expect("create group");
|
||||
assert_eq!(created.name, "operators");
|
||||
assert_eq!(created.permissions, permissions);
|
||||
|
||||
auth.set_user_groups(0, user_id, &[created.id])
|
||||
.await
|
||||
.expect("set groups");
|
||||
|
||||
let detail = auth
|
||||
.get_group(created.id)
|
||||
.await
|
||||
.expect("get group")
|
||||
.expect("group exists");
|
||||
assert_eq!(detail.members, vec![user_id]);
|
||||
|
||||
let updated_permissions = serde_json::json!(["dashboard:view", "audit:read"]);
|
||||
let updated = auth
|
||||
.update_group(created.id, Some("operators2"), None, Some(&updated_permissions))
|
||||
.await
|
||||
.expect("update group");
|
||||
assert_eq!(updated.name, "operators2");
|
||||
assert_eq!(updated.permissions, updated_permissions);
|
||||
|
||||
let groups = auth.list_groups().await.expect("list groups");
|
||||
assert!(groups.iter().any(|group| group.id == created.id));
|
||||
|
||||
assert!(auth.delete_group(created.id).await.expect("delete group"));
|
||||
assert!(db.get_user_group(created.id).await.expect("get deleted").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_permissions_must_be_string_arrays() {
|
||||
let (_db, auth) = auth_fixture().await;
|
||||
|
||||
let create_err = match auth
|
||||
.create_group(Some("operators"), None, Some(&serde_json::json!([1])))
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("numeric permissions must be rejected"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(matches!(create_err, IdentityAdminError::Validation(_)));
|
||||
|
||||
let created = auth
|
||||
.create_group(Some("operators"), None, Some(&serde_json::json!(["dashboard:read"])))
|
||||
.await
|
||||
.expect("create group");
|
||||
let update_err = match auth
|
||||
.update_group(created.id, None, None, Some(&serde_json::json!("dashboard:read")))
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("non-array permissions must be rejected"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(update_err, IdentityAdminError::Validation(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_user_groups_rejects_self_membership_change() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
let err = auth
|
||||
.set_user_groups(user_id, user_id, &[])
|
||||
.await
|
||||
.expect_err("self group change should be rejected");
|
||||
|
||||
assert!(matches!(err, IdentityAdminError::Validation(_)));
|
||||
assert!(!db.list_groups_for_user(user_id).await.expect("groups").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relogin_permissions_follow_role_promotion_and_demotion() {
|
||||
let (db, auth) = auth_fixture().await;
|
||||
@ -1026,7 +390,7 @@ mod tests {
|
||||
.await
|
||||
.expect_err("missing default group should fail registration");
|
||||
|
||||
assert!(matches!(err, RegisterError::Internal(_)));
|
||||
assert!(matches!(err, RegisterError::Internal { .. }));
|
||||
assert!(db.find_user("carol").await.expect("find user").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
305
net-guardia/src/core/identity/group_service.rs
Normal file
305
net-guardia/src/core/identity/group_service.rs
Normal file
@ -0,0 +1,305 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::codec::CodecError;
|
||||
use crate::core::identity::user_service::parse_permissions;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER};
|
||||
use crate::domain::identity::error::GroupError;
|
||||
use crate::domain::identity::user::{GroupMemberView, UserGroupView};
|
||||
use crate::interface::identity::auth_repo::UserGroupRepo;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupMemberResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupListResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub members: Vec<GroupMemberResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupDetailResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub members: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupMutationResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub permissions: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct GroupService {
|
||||
db: Arc<dyn UserGroupRepo>,
|
||||
}
|
||||
|
||||
impl GroupService {
|
||||
pub fn new(db: Arc<dyn UserGroupRepo>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn list_groups(&self) -> Result<Vec<GroupListResponse>, Error> {
|
||||
let groups = self.db.list_user_groups().await?;
|
||||
let mut result = Vec::with_capacity(groups.len());
|
||||
for group in groups {
|
||||
let permissions = parse_permissions(&group.permissions)?;
|
||||
let member_views = self.db.list_group_members(group.id).await?;
|
||||
let members = member_views.into_iter().map(group_member_response).collect();
|
||||
result.push(GroupListResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
permissions,
|
||||
created_at: group.created_at,
|
||||
members,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn create_group(
|
||||
&self,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
permissions: Option<&serde_json::Value>,
|
||||
) -> Result<GroupMutationResponse, GroupError> {
|
||||
let name = match name {
|
||||
Some(name) if !name.is_empty() => name,
|
||||
_ => return Err(GroupError::Validation("Group name is required".to_string())),
|
||||
};
|
||||
let description = description.unwrap_or("");
|
||||
let (permissions, permissions_json) = permission_array_json(permissions, "[]")?;
|
||||
|
||||
let id = self
|
||||
.db
|
||||
.create_user_group(name, description, &permissions)
|
||||
.await
|
||||
.map_err(GroupError::Conflict)?;
|
||||
|
||||
Ok(GroupMutationResponse {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
permissions: permissions_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_group(&self, group_id: i64) -> Result<Option<GroupDetailResponse>, Error> {
|
||||
let Some(group) = self.db.get_user_group(group_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let permissions = parse_permissions(&group.permissions)?;
|
||||
let members = self.db.list_group_member_ids(group_id).await?;
|
||||
Ok(Some(group_detail_response(group, permissions, members)))
|
||||
}
|
||||
|
||||
pub async fn update_group(
|
||||
&self,
|
||||
group_id: i64,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
permissions: Option<&serde_json::Value>,
|
||||
) -> Result<GroupMutationResponse, GroupError> {
|
||||
let existing = self
|
||||
.db
|
||||
.get_user_group(group_id)
|
||||
.await
|
||||
.map_err(GroupError::Internal)?
|
||||
.ok_or_else(|| GroupError::NotFound("Group not found".to_string()))?;
|
||||
|
||||
if is_builtin_group(&existing.name) {
|
||||
return Err(GroupError::Forbidden("Cannot modify built-in groups".to_string()));
|
||||
}
|
||||
|
||||
let name = name.unwrap_or(&existing.name).to_string();
|
||||
let description = description.unwrap_or(&existing.description).to_string();
|
||||
let (permissions, permissions_json) = permission_array_json(permissions, &existing.permissions)?;
|
||||
|
||||
self.db
|
||||
.update_user_group(group_id, &name, &description, &permissions)
|
||||
.await
|
||||
.map_err(GroupError::Internal)?;
|
||||
|
||||
Ok(GroupMutationResponse {
|
||||
id: group_id,
|
||||
name,
|
||||
description,
|
||||
permissions: permissions_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(&self, group_id: i64) -> Result<bool, GroupError> {
|
||||
let group = self.db.get_user_group(group_id).await.map_err(GroupError::Internal)?;
|
||||
if let Some(group) = group.as_ref()
|
||||
&& is_builtin_group(&group.name)
|
||||
{
|
||||
return Err(GroupError::Forbidden("Cannot delete built-in groups".to_string()));
|
||||
}
|
||||
|
||||
self.db.delete_user_group(group_id).await.map_err(GroupError::Internal)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_member_response(member: GroupMemberView) -> GroupMemberResponse {
|
||||
GroupMemberResponse {
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_detail_response(
|
||||
group: UserGroupView,
|
||||
permissions: serde_json::Value,
|
||||
members: Vec<i64>,
|
||||
) -> GroupDetailResponse {
|
||||
GroupDetailResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
permissions,
|
||||
created_at: group.created_at,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_builtin_group(name: &str) -> bool {
|
||||
name == GROUP_ADMIN || name == GROUP_VIEWER
|
||||
}
|
||||
|
||||
fn permission_array_json(
|
||||
permissions: Option<&serde_json::Value>,
|
||||
default: &str,
|
||||
) -> Result<(String, serde_json::Value), GroupError> {
|
||||
match permissions {
|
||||
Some(value) => {
|
||||
let permissions = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| GroupError::Validation("Permissions must be an array of strings".to_string()))?;
|
||||
let json = serde_json::to_string(&permissions)
|
||||
.map_err(|err| GroupError::Internal(CodecError::SerializeFailed(err)))?;
|
||||
let value = serde_json::Value::Array(permissions.into_iter().map(serde_json::Value::String).collect());
|
||||
Ok((json, value))
|
||||
}
|
||||
None => Ok((
|
||||
default.to_string(),
|
||||
parse_permissions(default).map_err(GroupError::Internal)?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::adapter::identity::password_hasher::Argon2PasswordHasher;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::domain::identity::auth::{GROUP_VIEWER, ROLE_VIEWER};
|
||||
use crate::interface::identity::auth_repo::UserGroupRepo;
|
||||
use crate::interface::identity::password_hasher::PasswordHasher;
|
||||
|
||||
async fn group_service_fixture() -> (Arc<Database>, GroupService) {
|
||||
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
|
||||
let svc = GroupService::new(db.clone() as Arc<dyn UserGroupRepo>);
|
||||
(db, svc)
|
||||
}
|
||||
|
||||
async fn create_viewer(db: &Database, username: &str, password: &str) -> i64 {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash_password(password).expect("hash");
|
||||
let user_id = db
|
||||
.insert_user(username, &hash, ROLE_VIEWER, false)
|
||||
.await
|
||||
.expect("insert user");
|
||||
let viewer_group = db
|
||||
.list_user_groups()
|
||||
.await
|
||||
.expect("groups")
|
||||
.into_iter()
|
||||
.find(|g| g.name == GROUP_VIEWER)
|
||||
.expect("viewer group");
|
||||
db.set_user_groups(user_id, &[viewer_group.id])
|
||||
.await
|
||||
.expect("assign viewer group");
|
||||
user_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_workflows_parse_permissions_and_manage_membership() {
|
||||
let (db, svc) = group_service_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
let permissions = serde_json::json!(["dashboard:view"]);
|
||||
|
||||
let created = svc
|
||||
.create_group(Some("operators"), Some("Ops"), Some(&permissions))
|
||||
.await
|
||||
.expect("create group");
|
||||
assert_eq!(created.name, "operators");
|
||||
assert_eq!(created.permissions, permissions);
|
||||
|
||||
db.set_user_groups(user_id, &[created.id]).await.expect("set groups");
|
||||
|
||||
let detail = svc
|
||||
.get_group(created.id)
|
||||
.await
|
||||
.expect("get group")
|
||||
.expect("group exists");
|
||||
assert_eq!(detail.members, vec![user_id]);
|
||||
|
||||
let updated_permissions = serde_json::json!(["dashboard:view", "audit:read"]);
|
||||
let updated = svc
|
||||
.update_group(created.id, Some("operators2"), None, Some(&updated_permissions))
|
||||
.await
|
||||
.expect("update group");
|
||||
assert_eq!(updated.name, "operators2");
|
||||
assert_eq!(updated.permissions, updated_permissions);
|
||||
|
||||
let groups = svc.list_groups().await.expect("list groups");
|
||||
assert!(groups.iter().any(|group| group.id == created.id));
|
||||
|
||||
assert!(svc.delete_group(created.id).await.expect("delete group"));
|
||||
assert!(db.get_user_group(created.id).await.expect("get deleted").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_permissions_must_be_string_arrays() {
|
||||
let (_db, svc) = group_service_fixture().await;
|
||||
|
||||
let create_err = match svc
|
||||
.create_group(Some("operators"), None, Some(&serde_json::json!([1])))
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("numeric permissions must be rejected"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(matches!(create_err, GroupError::Validation { .. }));
|
||||
|
||||
let created = svc
|
||||
.create_group(Some("operators"), None, Some(&serde_json::json!(["dashboard:read"])))
|
||||
.await
|
||||
.expect("create group");
|
||||
let update_err = match svc
|
||||
.update_group(created.id, None, None, Some(&serde_json::json!("dashboard:read")))
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("non-array permissions must be rejected"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(update_err, GroupError::Validation { .. }));
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
pub mod auth_service;
|
||||
pub mod group_service;
|
||||
pub mod session_service;
|
||||
pub mod user_service;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
@ -33,9 +34,12 @@ pub enum CsrfTokenStatus {
|
||||
MissingSession,
|
||||
}
|
||||
|
||||
const GC_INTERVAL_LOGINS: u32 = 64;
|
||||
|
||||
pub struct SessionService {
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
sessions: DashMap<String, SessionRecord>,
|
||||
login_counter: AtomicU32,
|
||||
}
|
||||
|
||||
impl SessionService {
|
||||
@ -43,12 +47,19 @@ impl SessionService {
|
||||
Self {
|
||||
config,
|
||||
sessions: DashMap::new(),
|
||||
login_counter: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(&self, claims: Claims) -> CreatedSession {
|
||||
let now = now_secs();
|
||||
self.remove_expired_sessions(now);
|
||||
if self
|
||||
.login_counter
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.is_multiple_of(GC_INTERVAL_LOGINS)
|
||||
{
|
||||
self.remove_expired_sessions(now);
|
||||
}
|
||||
let session_id = random_url_token();
|
||||
let csrf_token = random_url_token();
|
||||
let max_age_secs = self.session_max_age_secs();
|
||||
|
||||
403
net-guardia/src/core/identity/user_service.rs
Normal file
403
net-guardia/src/core/identity/user_service.rs
Normal file
@ -0,0 +1,403 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::codec::CodecError;
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::error::UserError;
|
||||
use crate::domain::identity::validation::validate_password;
|
||||
use crate::interface::identity::auth_repo::{UserGroupRepo, UserRepo};
|
||||
use crate::interface::identity::password_hasher::PasswordHasher;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserListResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub force_password_change: bool,
|
||||
pub created_at: String,
|
||||
pub groups: Vec<UserGroupMembershipResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserGroupMembershipResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub struct UserService {
|
||||
db: Arc<dyn UserRepo>,
|
||||
group_db: Arc<dyn UserGroupRepo>,
|
||||
password_hasher: Arc<dyn PasswordHasher>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(
|
||||
db: Arc<dyn UserRepo>,
|
||||
group_db: Arc<dyn UserGroupRepo>,
|
||||
password_hasher: Arc<dyn PasswordHasher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
group_db,
|
||||
password_hasher,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_profile(&self, user_id: i64, username: &str) -> Result<UserProfile, Error> {
|
||||
let groups_raw = self.group_db.list_groups_for_user(user_id).await?;
|
||||
let role = role_from_group_names(groups_raw.iter().map(|group| group.name.as_str())).to_string();
|
||||
let groups = groups_raw.into_iter().map(|group| group.name).collect();
|
||||
let permissions = self.group_db.list_user_permissions(user_id).await?;
|
||||
|
||||
Ok(UserProfile {
|
||||
id: user_id,
|
||||
username: username.to_string(),
|
||||
role,
|
||||
permissions,
|
||||
groups,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_users(&self) -> Result<Vec<UserListResponse>, Error> {
|
||||
let users = self.db.list_users_with_groups().await?;
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.map(|user| {
|
||||
let role = role_from_group_names(user.groups.iter().map(|group| group.group_name.as_str())).to_string();
|
||||
let groups: Vec<UserGroupMembershipResponse> = user
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|group| UserGroupMembershipResponse {
|
||||
id: group.group_id,
|
||||
name: group.group_name,
|
||||
})
|
||||
.collect();
|
||||
UserListResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role,
|
||||
force_password_change: user.force_password_change,
|
||||
created_at: user.created_at,
|
||||
groups,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: i64,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), UserError> {
|
||||
validate_password(new_password).map_err(|msg| UserError::Validation(msg.to_string()))?;
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(UserError::Internal)?
|
||||
.ok_or_else(|| UserError::NotFound("User not found".to_string()))?;
|
||||
|
||||
match self
|
||||
.password_hasher
|
||||
.verify_password(current_password, &user.password_hash)
|
||||
{
|
||||
Ok(true) => {}
|
||||
_ => return Err(UserError::Unauthorized),
|
||||
}
|
||||
|
||||
let new_hash = self
|
||||
.password_hasher
|
||||
.hash_password(new_password)
|
||||
.map_err(|_| UserError::HashFailed)?;
|
||||
self.db
|
||||
.update_user_password(user_id, &new_hash)
|
||||
.await
|
||||
.map_err(UserError::Internal)
|
||||
}
|
||||
|
||||
pub async fn delete_user(&self, caller_user_id: i64, target_user_id: i64) -> Result<bool, UserError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(UserError::Validation("Cannot delete your own account".to_string()));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(UserError::Internal)?;
|
||||
if let Some(user) = user.as_ref()
|
||||
&& user.username == DEFAULT_ADMIN_USERNAME
|
||||
{
|
||||
return Err(UserError::Forbidden(
|
||||
"Cannot delete the built-in admin account".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db.delete_user(target_user_id).await.map_err(UserError::Internal)
|
||||
}
|
||||
|
||||
pub async fn update_role(&self, caller_user_id: i64, target_user_id: i64, role: &str) -> Result<(), UserError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(UserError::Validation("Cannot change your own role".to_string()));
|
||||
}
|
||||
if role != ROLE_ADMIN && role != ROLE_VIEWER {
|
||||
return Err(UserError::Validation("Role must be 'admin' or 'viewer'".to_string()));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(UserError::Internal)?
|
||||
.ok_or_else(|| UserError::NotFound("User not found".to_string()))?;
|
||||
|
||||
if user.username == DEFAULT_ADMIN_USERNAME {
|
||||
return Err(UserError::Forbidden(
|
||||
"Cannot change the built-in admin account role".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_user_role(target_user_id, role)
|
||||
.await
|
||||
.map_err(UserError::Internal)
|
||||
}
|
||||
|
||||
pub async fn reset_password(&self, target_user_id: i64, new_password: &str) -> Result<(), UserError> {
|
||||
validate_password(new_password).map_err(|msg| UserError::Validation(msg.to_string()))?;
|
||||
|
||||
self.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(UserError::Internal)?
|
||||
.ok_or_else(|| UserError::NotFound("User not found".to_string()))?;
|
||||
|
||||
let hash = self
|
||||
.password_hasher
|
||||
.hash_password(new_password)
|
||||
.map_err(|_| UserError::HashFailed)?;
|
||||
self.db
|
||||
.reset_user_password(target_user_id, &hash)
|
||||
.await
|
||||
.map_err(UserError::Internal)
|
||||
}
|
||||
|
||||
pub async fn set_user_groups(
|
||||
&self,
|
||||
caller_user_id: i64,
|
||||
target_user_id: i64,
|
||||
group_ids: &[i64],
|
||||
) -> Result<(), UserError> {
|
||||
if caller_user_id == target_user_id {
|
||||
return Err(UserError::Validation("Cannot modify your own groups".to_string()));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.db
|
||||
.find_user_by_id(target_user_id)
|
||||
.await
|
||||
.map_err(UserError::Internal)?
|
||||
.ok_or_else(|| UserError::NotFound("User not found".to_string()))?;
|
||||
|
||||
if user.username == DEFAULT_ADMIN_USERNAME {
|
||||
return Err(UserError::Forbidden(
|
||||
"Cannot modify groups for the built-in admin account".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.group_db
|
||||
.set_user_groups(target_user_id, group_ids)
|
||||
.await
|
||||
.map_err(UserError::Internal)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_permissions(raw: &str) -> Result<serde_json::Value, Error> {
|
||||
let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn role_from_group_names<'a>(names: impl IntoIterator<Item = &'a str>) -> &'static str {
|
||||
if names.into_iter().any(|name| name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
ROLE_VIEWER
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::adapter::identity::password_hasher::Argon2PasswordHasher;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::interface::identity::auth_repo::{UserGroupRepo, UserRepo};
|
||||
use crate::interface::identity::password_hasher::PasswordHasher;
|
||||
|
||||
async fn user_service_fixture() -> (Arc<Database>, UserService) {
|
||||
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
|
||||
let svc = UserService::new(
|
||||
db.clone() as Arc<dyn UserRepo>,
|
||||
db.clone() as Arc<dyn UserGroupRepo>,
|
||||
Arc::new(Argon2PasswordHasher),
|
||||
);
|
||||
(db, svc)
|
||||
}
|
||||
|
||||
async fn create_viewer(db: &Database, username: &str, password: &str) -> i64 {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash_password(password).expect("hash");
|
||||
let user_id = db
|
||||
.insert_user(username, &hash, ROLE_VIEWER, false)
|
||||
.await
|
||||
.expect("insert user");
|
||||
let viewer_group = db
|
||||
.list_user_groups()
|
||||
.await
|
||||
.expect("groups")
|
||||
.into_iter()
|
||||
.find(|g| g.name == GROUP_VIEWER)
|
||||
.expect("viewer group");
|
||||
db.set_user_groups(user_id, &[viewer_group.id])
|
||||
.await
|
||||
.expect("assign viewer group");
|
||||
user_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_verifies_current_password_and_updates_hash() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
svc.change_password(user_id, "Correct Horse 123!", "New Password 456!")
|
||||
.await
|
||||
.expect("change password");
|
||||
|
||||
let user = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
!hasher
|
||||
.verify_password("Correct Horse 123!", &user.password_hash)
|
||||
.expect("verify old")
|
||||
);
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("New Password 456!", &user.password_hash)
|
||||
.expect("verify new")
|
||||
);
|
||||
assert!(!user.force_password_change);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_verifies_password_for_target_user_id() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let alice_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
create_viewer(&db, "bob", "Bob Password 123!").await;
|
||||
|
||||
let err = svc
|
||||
.change_password(alice_id, "Bob Password 123!", "New Password 456!")
|
||||
.await
|
||||
.expect_err("another user's password must not authorize the change");
|
||||
|
||||
assert!(matches!(err, UserError::Unauthorized));
|
||||
|
||||
let alice = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("Correct Horse 123!", &alice.password_hash)
|
||||
.expect("verify alice password")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_role_rejects_self_change_before_db_update() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
let err = svc
|
||||
.update_role(user_id, user_id, ROLE_ADMIN)
|
||||
.await
|
||||
.expect_err("self role change should be rejected");
|
||||
|
||||
assert!(matches!(err, UserError::Validation { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_role_rejects_builtin_admin_role_change() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let hash = hasher.hash_password("Default Admin 123!").expect("hash");
|
||||
let admin_id = db
|
||||
.insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false)
|
||||
.await
|
||||
.expect("insert built-in admin");
|
||||
let admin_group = db
|
||||
.list_user_groups()
|
||||
.await
|
||||
.expect("groups")
|
||||
.into_iter()
|
||||
.find(|g| g.name == GROUP_ADMIN)
|
||||
.expect("admin group");
|
||||
db.set_user_groups(admin_id, &[admin_group.id])
|
||||
.await
|
||||
.expect("assign admin group");
|
||||
|
||||
let err = svc
|
||||
.update_role(999, admin_id, ROLE_VIEWER)
|
||||
.await
|
||||
.expect_err("built-in admin role change should be rejected");
|
||||
|
||||
assert!(matches!(err, UserError::Forbidden { .. }));
|
||||
let groups = db.list_groups_for_user(admin_id).await.expect("admin groups");
|
||||
assert!(groups.iter().any(|group| group.name == GROUP_ADMIN));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_password_marks_force_password_change() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
svc.reset_password(user_id, "Reset Password 456!")
|
||||
.await
|
||||
.expect("reset password");
|
||||
|
||||
let user = db.find_user("alice").await.expect("find user").expect("user exists");
|
||||
let hasher = Argon2PasswordHasher;
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password("Reset Password 456!", &user.password_hash)
|
||||
.expect("verify reset")
|
||||
);
|
||||
assert!(user.force_password_change);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_user_groups_rejects_self_membership_change() {
|
||||
let (db, svc) = user_service_fixture().await;
|
||||
let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await;
|
||||
|
||||
let err = svc
|
||||
.set_user_groups(user_id, user_id, &[])
|
||||
.await
|
||||
.expect_err("self group change should be rejected");
|
||||
|
||||
assert!(matches!(err, UserError::Validation { .. }));
|
||||
assert!(!db.list_groups_for_user(user_id).await.expect("groups").is_empty());
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ use super::drift_detector::DriftDetectorHandle;
|
||||
use super::runner::Inference;
|
||||
use crate::core::inference::aggregator::AttackAggregator;
|
||||
use crate::core::inference::flow_tracker::FlowTracker;
|
||||
use crate::domain::common::config::constants::KNOWN_C2_PORTS;
|
||||
use crate::domain::data_plane::user_packet::UserPacket;
|
||||
use crate::domain::detection::flow_features::FlowFeatures;
|
||||
use crate::domain::detection::flow_tracker::{FlowLimits, FlowSnapshot};
|
||||
@ -132,8 +133,8 @@ impl Engine {
|
||||
},
|
||||
TCP_PROTOCOL => match flow_key.dst_port {
|
||||
DNS_PORT => 2,
|
||||
4444 | 8443 | 8080 | 1337 | 31337 => 2,
|
||||
3333 | 45700 => 2,
|
||||
port if KNOWN_C2_PORTS.contains(&port) => 2,
|
||||
_ => floored,
|
||||
},
|
||||
_ => floored,
|
||||
@ -219,7 +220,7 @@ impl Engine {
|
||||
let feature_names = FlowFeatures::all_feature_names_owned();
|
||||
for flow in flows {
|
||||
let features = FlowFeatures::extract_from_stats(&flow.feature_stats, feature_names);
|
||||
sink.log_row(features.to_csv_record());
|
||||
sink.log_row(features.to_csv_line());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,12 @@ use crate::domain::detection::flow_tracker::{FlowData, FlowLimits, FlowSnapshot}
|
||||
use crate::domain::detection::ml_detection::FlowKey;
|
||||
use netguardia_abi::define::tcp_flags::*;
|
||||
|
||||
type FlowEntry = Arc<Mutex<FlowData>>;
|
||||
struct TrackedFlow {
|
||||
data: FlowData,
|
||||
last_inferred_us: u64,
|
||||
}
|
||||
|
||||
type FlowEntry = Arc<Mutex<TrackedFlow>>;
|
||||
|
||||
pub struct FlowTracker {
|
||||
active: Cache<FlowKey, FlowEntry>,
|
||||
@ -30,14 +35,14 @@ impl FlowTracker {
|
||||
|
||||
if let Some(entry) = self.active.get(&packet_key) {
|
||||
packet.is_forward = true;
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
entry.lock().data.add_packet(&packet, &self.limits);
|
||||
return;
|
||||
}
|
||||
|
||||
let reversed_key = packet_key.reverse();
|
||||
if let Some(entry) = self.active.get(&reversed_key) {
|
||||
packet.is_forward = false;
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
entry.lock().data.add_packet(&packet, &self.limits);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -73,13 +78,19 @@ impl FlowTracker {
|
||||
|
||||
let key_for_init = actual_key;
|
||||
let entry = self.active.get_with(actual_key, || {
|
||||
Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction)))
|
||||
Arc::new(Mutex::new(TrackedFlow {
|
||||
data: FlowData::new(key_for_init, &packet, initiator_direction),
|
||||
last_inferred_us: 0,
|
||||
}))
|
||||
});
|
||||
entry.lock().add_packet(&packet, &self.limits);
|
||||
entry.lock().data.add_packet(&packet, &self.limits);
|
||||
}
|
||||
|
||||
pub fn get_flow_stats<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
|
||||
self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect()
|
||||
self.active
|
||||
.iter()
|
||||
.map(|(_, entry)| convert(&entry.lock().data))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_filtered_flow_stats<T>(
|
||||
@ -90,26 +101,25 @@ impl FlowTracker {
|
||||
self.active
|
||||
.iter()
|
||||
.filter_map(|(_, entry)| {
|
||||
let flow = entry.lock();
|
||||
filter(&flow).then(|| convert(&flow))
|
||||
let tracked = entry.lock();
|
||||
filter(&tracked.data).then(|| convert(&tracked.data))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_uninferred_flows(&self, limit: usize) -> Vec<FlowSnapshot> {
|
||||
let mut result = Vec::new();
|
||||
let mut clones = Vec::new();
|
||||
for (_, entry) in self.active.iter() {
|
||||
if result.len() >= limit {
|
||||
if clones.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let mut flow = entry.lock();
|
||||
if flow.last_time_us > flow.last_inferred_us {
|
||||
let snapshot = FlowSnapshot::from_flow(&flow);
|
||||
flow.last_inferred_us = flow.last_time_us;
|
||||
result.push(snapshot);
|
||||
let mut tracked = entry.lock();
|
||||
if tracked.data.last_time_us > tracked.last_inferred_us {
|
||||
clones.push(tracked.data.clone());
|
||||
tracked.last_inferred_us = tracked.data.last_time_us;
|
||||
}
|
||||
}
|
||||
result
|
||||
clones.iter().map(FlowSnapshot::from_flow_data).collect()
|
||||
}
|
||||
|
||||
pub fn flow_count(&self) -> usize {
|
||||
@ -124,8 +134,8 @@ impl FlowTracker {
|
||||
for (_, entry) in self.active.iter() {
|
||||
let flow = entry.lock();
|
||||
total_flows += 1;
|
||||
total_bytes += flow.fwd_total_bytes + flow.bwd_total_bytes;
|
||||
total_packets += flow.packet_count();
|
||||
total_bytes += flow.data.fwd_total_bytes + flow.data.bwd_total_bytes;
|
||||
total_packets += flow.data.packet_count();
|
||||
}
|
||||
|
||||
(total_flows, total_bytes, total_packets)
|
||||
@ -135,8 +145,8 @@ impl FlowTracker {
|
||||
let mut keys_to_remove = Vec::new();
|
||||
for (key, entry) in self.active.iter() {
|
||||
let flow = entry.lock();
|
||||
let idle = now_us.saturating_sub(flow.last_time_us);
|
||||
let is_terminated = flow.fin_count > 0 || flow.rst_count > 0;
|
||||
let idle = now_us.saturating_sub(flow.data.last_time_us);
|
||||
let is_terminated = flow.data.fin_count > 0 || flow.data.rst_count > 0;
|
||||
let stale = if is_terminated {
|
||||
idle >= self.limits.terminated_timeout_us
|
||||
} else {
|
||||
|
||||
@ -13,7 +13,6 @@ use crate::adapter::model_loading::artifact_resolver::FsModelArtifactResolver;
|
||||
use crate::adapter::model_loading::onnx_runtime::OnnxRuntimeLoader;
|
||||
use crate::common::error::Error;
|
||||
use crate::common::error::io::IOError;
|
||||
use crate::common::error::system::SystemError;
|
||||
use crate::core::detection::metrics::FusionMetrics;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
use crate::core::inference::drift_detector::DriftDetectorHandle;
|
||||
@ -29,11 +28,11 @@ use crate::domain::detection::flow_tracker::FlowLimits;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::manifest::ModelManifest;
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::domain::detection::model_source::ModelInfo;
|
||||
use crate::infrastructure::flow_trace_logger::{RotationPolicy, TrafficLogger};
|
||||
use crate::interface::detection::flow_trace_sink::FlowTraceSink;
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::interface::detection::model_runtime::ModelRuntimeLoader;
|
||||
|
||||
pub struct InferenceRuntime {
|
||||
@ -75,15 +74,15 @@ impl InferenceRuntime {
|
||||
Ok(adapter) => {
|
||||
let info = ModelInfo::new(
|
||||
manifest.name.clone(),
|
||||
manifest.adapter.as_str().to_string(),
|
||||
manifest.runtime_kind().as_str().to_string(),
|
||||
current_epoch_secs(),
|
||||
manifest.features.len(),
|
||||
manifest.runtime_feature_count(),
|
||||
);
|
||||
log!(MLLog::ModelsLoaded(format!(
|
||||
"{} ({}) — {} features, {} labels",
|
||||
manifest.name,
|
||||
manifest.adapter.as_str(),
|
||||
manifest.features.len(),
|
||||
manifest.runtime_kind().as_str(),
|
||||
manifest.runtime_feature_count(),
|
||||
manifest.labels.len()
|
||||
)));
|
||||
ModelSourceState::Active { adapter, info }
|
||||
@ -195,9 +194,7 @@ impl InferenceRuntime {
|
||||
|
||||
pub fn terminate(&self) {
|
||||
while let Some(shutdown) = self.shutdowns.pop() {
|
||||
if shutdown.send(()).is_err() {
|
||||
log!(SystemError::ShutdownSignalFailed);
|
||||
}
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,37 +18,37 @@ pub fn build_adapter(
|
||||
runtime_loader: &dyn ModelRuntimeLoader,
|
||||
artifact_resolver: &dyn ModelArtifactResolver,
|
||||
) -> Result<MLModelAdapter, MLError> {
|
||||
match manifest.adapter {
|
||||
match manifest.runtime_kind() {
|
||||
AdapterKind::AutoencoderOnly => {
|
||||
let Some(model_name) = manifest.models.model.as_deref() else {
|
||||
let Some(model) = manifest.primary_model() else {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(),
|
||||
"autoencoder_only adapter requires models.model".to_string(),
|
||||
"pipeline requires a primary model".to_string(),
|
||||
));
|
||||
};
|
||||
let path = artifact_resolver.resolve_model_path(manifest_path, model_name);
|
||||
let path = artifact_resolver.resolve_model_path(manifest_path, &model.file);
|
||||
let n_features = inference_config.num_ae_features();
|
||||
let model = runtime_loader.load(&path, model_name, n_features, batch_size, onnx_load_timeout)?;
|
||||
let runtime = runtime_loader.load(&path, &model.file, n_features, batch_size, onnx_load_timeout)?;
|
||||
Ok(MLModelAdapter::AutoencoderOnly {
|
||||
model,
|
||||
model: runtime,
|
||||
batch_size,
|
||||
n_features,
|
||||
})
|
||||
}
|
||||
AdapterKind::ClassifierOnly => {
|
||||
let Some(model_name) = manifest.models.model.as_deref() else {
|
||||
let Some(model) = manifest.primary_model() else {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(),
|
||||
"classifier_only adapter requires models.model".to_string(),
|
||||
"pipeline requires a primary model".to_string(),
|
||||
));
|
||||
};
|
||||
let path = artifact_resolver.resolve_model_path(manifest_path, model_name);
|
||||
let path = artifact_resolver.resolve_model_path(manifest_path, &model.file);
|
||||
let n_features = inference_config.num_classifier_features();
|
||||
let model = runtime_loader.load(&path, model_name, n_features, batch_size, onnx_load_timeout)?;
|
||||
let runtime = runtime_loader.load(&path, &model.file, n_features, batch_size, onnx_load_timeout)?;
|
||||
let labels = manifest.labels.clone();
|
||||
let normal_idx = find_label_index(&labels, "Normal");
|
||||
Ok(MLModelAdapter::ClassifierOnly {
|
||||
model,
|
||||
model: runtime,
|
||||
batch_size,
|
||||
n_features,
|
||||
labels,
|
||||
@ -56,24 +56,22 @@ pub fn build_adapter(
|
||||
})
|
||||
}
|
||||
AdapterKind::MultiTask => {
|
||||
let Some(ae_name) = manifest.models.autoencoder.as_deref() else {
|
||||
let mut pipeline = manifest.pipeline_models()?;
|
||||
if pipeline.len() < 2 {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(),
|
||||
"multi_task adapter requires models.autoencoder".to_string(),
|
||||
"multi-stage pipeline requires at least two models".to_string(),
|
||||
));
|
||||
};
|
||||
let Some(cls_name) = manifest.models.classifier.as_deref() else {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(),
|
||||
"multi_task adapter requires models.classifier".to_string(),
|
||||
));
|
||||
};
|
||||
}
|
||||
let ae_model = pipeline.remove(0);
|
||||
let classifier_model = pipeline.remove(0);
|
||||
let n_ae = inference_config.num_ae_features();
|
||||
let n_cls = inference_config.num_classifier_features();
|
||||
let ae_path = artifact_resolver.resolve_model_path(manifest_path, ae_name);
|
||||
let cls_path = artifact_resolver.resolve_model_path(manifest_path, cls_name);
|
||||
let ae = runtime_loader.load(&ae_path, ae_name, n_ae, batch_size, onnx_load_timeout)?;
|
||||
let classifier = runtime_loader.load(&cls_path, cls_name, n_cls, batch_size, onnx_load_timeout)?;
|
||||
let ae_path = artifact_resolver.resolve_model_path(manifest_path, &ae_model.file);
|
||||
let cls_path = artifact_resolver.resolve_model_path(manifest_path, &classifier_model.file);
|
||||
let ae = runtime_loader.load(&ae_path, &ae_model.file, n_ae, batch_size, onnx_load_timeout)?;
|
||||
let classifier =
|
||||
runtime_loader.load(&cls_path, &classifier_model.file, n_cls, batch_size, onnx_load_timeout)?;
|
||||
let labels = manifest.labels.clone();
|
||||
let normal_idx = find_label_index(&labels, "Normal");
|
||||
let c2_idx = find_label_index(&labels, "C2 Communication");
|
||||
@ -105,7 +103,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::detection::manifest::ModelPaths;
|
||||
use crate::domain::detection::manifest::{ModelSpec, OutputHeadSpec, OutputSemantic, PreprocessingStep};
|
||||
use crate::domain::detection::ml_detection::ClipParams;
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
use crate::interface::detection::model_runtime::{ModelRuntime, MultiTaskBatchOutput};
|
||||
@ -192,16 +190,44 @@ mod tests {
|
||||
};
|
||||
let manifest = ModelManifest {
|
||||
name: "test".to_string(),
|
||||
adapter: AdapterKind::MultiTask,
|
||||
features: config.ae_feature_names.clone(),
|
||||
models: ModelPaths {
|
||||
autoencoder: Some("ae.onnx".to_string()),
|
||||
classifier: Some("classifier.onnx".to_string()),
|
||||
model: None,
|
||||
},
|
||||
preprocessing: None,
|
||||
thresholds: Default::default(),
|
||||
version: 2,
|
||||
models: vec![
|
||||
ModelSpec {
|
||||
id: "ae".to_string(),
|
||||
file: "ae.onnx".to_string(),
|
||||
input_features: config.ae_feature_names.clone(),
|
||||
preprocessing: vec![PreprocessingStep::StandardScaler {
|
||||
sidecar: "sidecar.json".to_string(),
|
||||
}],
|
||||
outputs: vec![OutputHeadSpec {
|
||||
name: "reconstruction_error".to_string(),
|
||||
shape: vec!["1".to_string()],
|
||||
semantic: OutputSemantic::AnomalyScore,
|
||||
threshold: Some(0.5),
|
||||
min_confidence: None,
|
||||
}],
|
||||
},
|
||||
ModelSpec {
|
||||
id: "classifier".to_string(),
|
||||
file: "classifier.onnx".to_string(),
|
||||
input_features: vec![
|
||||
"duration".to_string(),
|
||||
"bytes".to_string(),
|
||||
"ae_anomaly_score".to_string(),
|
||||
],
|
||||
preprocessing: vec![],
|
||||
outputs: vec![OutputHeadSpec {
|
||||
name: "class_probs".to_string(),
|
||||
shape: vec!["10".to_string()],
|
||||
semantic: OutputSemantic::Multiclass,
|
||||
threshold: None,
|
||||
min_confidence: Some(0.4),
|
||||
}],
|
||||
},
|
||||
],
|
||||
pipeline: vec!["ae".to_string(), "classifier".to_string()],
|
||||
labels: BTreeMap::new(),
|
||||
alert_rules: vec![],
|
||||
};
|
||||
|
||||
let adapter = build_adapter(
|
||||
|
||||
@ -10,10 +10,9 @@ use crate::core::inference::model_loader::build_adapter;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::manifest::AdapterKind;
|
||||
use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
use crate::interface::detection::model_config_loader::ModelConfigLoader;
|
||||
use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::interface::detection::model_promotion_store::ModelPromotionStore;
|
||||
use crate::interface::detection::model_runtime::ModelRuntimeLoader;
|
||||
use crate::interface::system::audit::AuditRepo;
|
||||
@ -55,8 +54,6 @@ impl Drop for PromoteGuard<'_> {
|
||||
|
||||
pub struct StagedModelPromotion<'a> {
|
||||
pub staging_dir: &'a Path,
|
||||
pub uploaded_onnx_filename: &'a str,
|
||||
pub uploaded_scaler_filename: Option<&'a str>,
|
||||
pub inference: &'a Inference,
|
||||
pub audit_repo: &'a dyn AuditRepo,
|
||||
pub promote_gate: &'a PromoteGate,
|
||||
@ -77,41 +74,16 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
|
||||
.load_manifest(&staging_manifest)
|
||||
.map_err(PromoteError::ManifestInvalid)?;
|
||||
|
||||
if matches!(manifest_preview.adapter, AdapterKind::MultiTask) {
|
||||
return Err(PromoteError::UnsupportedAdapter);
|
||||
}
|
||||
|
||||
let declared_onnx = manifest_preview
|
||||
.models
|
||||
.model
|
||||
.clone()
|
||||
.ok_or_else(|| PromoteError::ManifestInvalid("single-onnx adapters require models.model".to_string()))?;
|
||||
validate_manifest_basename("models.model", &declared_onnx)?;
|
||||
if let Some(ref pp) = manifest_preview.preprocessing {
|
||||
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
|
||||
}
|
||||
|
||||
let staged_onnx = align_uploaded_file_with_manifest(
|
||||
ctx.promotion_store,
|
||||
ctx.staging_dir,
|
||||
ctx.uploaded_onnx_filename,
|
||||
&declared_onnx,
|
||||
"uploaded onnx filename",
|
||||
"rename staged onnx",
|
||||
)
|
||||
.await?;
|
||||
if let (Some(pp), Some(uploaded_scaler_filename)) =
|
||||
(manifest_preview.preprocessing.as_ref(), ctx.uploaded_scaler_filename)
|
||||
{
|
||||
align_uploaded_file_with_manifest(
|
||||
ctx.promotion_store,
|
||||
ctx.staging_dir,
|
||||
uploaded_scaler_filename,
|
||||
&pp.scaler_sidecar,
|
||||
"uploaded scaler filename",
|
||||
"rename staged scaler sidecar",
|
||||
)
|
||||
.await?;
|
||||
let Some(primary_model) = manifest_preview.primary_model() else {
|
||||
return Err(PromoteError::ManifestInvalid(
|
||||
"manifest requires a primary model".to_string(),
|
||||
));
|
||||
};
|
||||
let declared_onnx = primary_model.file.clone();
|
||||
validate_manifest_basename("models[primary].file", &declared_onnx)?;
|
||||
let primary_scaler_sidecar = manifest_preview.primary_scaler_sidecar();
|
||||
if let Some(sidecar) = primary_scaler_sidecar {
|
||||
validate_manifest_basename("preprocessing.scaler_sidecar", sidecar)?;
|
||||
}
|
||||
|
||||
let guard = ctx.promote_gate.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
|
||||
@ -131,6 +103,14 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
|
||||
)
|
||||
.map_err(PromoteError::ValidationFailed)?;
|
||||
|
||||
let before_status = ctx.inference.model_source_status();
|
||||
let models_dir = PathBuf::from(MODELS_DIR);
|
||||
let target_onnx = models_dir.join(&declared_onnx);
|
||||
let target_manifest = models_dir.join(MANIFEST_FILENAME);
|
||||
let staged_onnx = ctx.staging_dir.join(&declared_onnx);
|
||||
let staged_sidecar = primary_scaler_sidecar.map(|sidecar| ctx.staging_dir.join(sidecar));
|
||||
let target_sidecar = primary_scaler_sidecar.map(|sidecar| models_dir.join(sidecar));
|
||||
|
||||
let manifest_sha256 = ctx
|
||||
.promotion_store
|
||||
.sha256_file(&staging_manifest)
|
||||
@ -142,26 +122,11 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
|
||||
.await
|
||||
.map_err(|e| PromoteError::StagingIo("sha256 onnx", e))?;
|
||||
|
||||
let before_status = ctx.inference.model_source_status();
|
||||
let models_dir = PathBuf::from(MODELS_DIR);
|
||||
let target_onnx = models_dir.join(&declared_onnx);
|
||||
let target_manifest = models_dir.join(MANIFEST_FILENAME);
|
||||
let staged_sidecar = if let Some(ref pp) = manifest.preprocessing {
|
||||
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
|
||||
Some(ctx.staging_dir.join(&pp.scaler_sidecar))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target_sidecar = manifest
|
||||
.preprocessing
|
||||
.as_ref()
|
||||
.map(|pp| models_dir.join(&pp.scaler_sidecar));
|
||||
|
||||
let backup_dir = models_dir.join(format!(".promote-backup-{}", Uuid::new_v4()));
|
||||
let before_json = serde_json::to_value(&before_status).map_err(PromoteError::AuditDetailSerialize)?;
|
||||
let audit_detail = serde_json::json!({
|
||||
"manifest_name": manifest.name,
|
||||
"adapter_kind": manifest.adapter.as_str(),
|
||||
"adapter_kind": manifest.runtime_kind().as_str(),
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"onnx_sha256": onnx_sha256,
|
||||
"before": before_json,
|
||||
@ -189,8 +154,8 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
|
||||
drop(guard);
|
||||
|
||||
Ok(PromoteReport {
|
||||
manifest_name: manifest.name,
|
||||
adapter_kind: manifest.adapter.as_str().to_string(),
|
||||
manifest_name: manifest.name.clone(),
|
||||
adapter_kind: manifest.runtime_kind().as_str().to_string(),
|
||||
manifest_sha256,
|
||||
onnx_sha256,
|
||||
})
|
||||
@ -225,27 +190,6 @@ fn validate_manifest_basename(field: &str, value: &str) -> Result<(), PromoteErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn align_uploaded_file_with_manifest(
|
||||
store: &dyn ModelPromotionStore,
|
||||
staging_dir: &Path,
|
||||
uploaded_filename: &str,
|
||||
declared_filename: &str,
|
||||
uploaded_field: &str,
|
||||
operation: &'static str,
|
||||
) -> Result<PathBuf, PromoteError> {
|
||||
validate_manifest_basename(uploaded_field, uploaded_filename)?;
|
||||
validate_manifest_basename("manifest artifact filename", declared_filename)?;
|
||||
let uploaded_path = staging_dir.join(uploaded_filename);
|
||||
let declared_path = staging_dir.join(declared_filename);
|
||||
if uploaded_path != declared_path {
|
||||
store
|
||||
.rename(&uploaded_path, &declared_path)
|
||||
.await
|
||||
.map_err(|e| PromoteError::StagingIo(operation, e))?;
|
||||
}
|
||||
Ok(declared_path)
|
||||
}
|
||||
|
||||
pub struct PromoteFileSet {
|
||||
pub staging_manifest: PathBuf,
|
||||
pub staged_onnx: PathBuf,
|
||||
@ -273,23 +217,15 @@ async fn promote_files_atomically_with_required_audit(
|
||||
audit_actor: &str,
|
||||
audit_detail: &str,
|
||||
) -> Result<(), PromoteError> {
|
||||
let backups = promote_files_with_backups(store, files).await?;
|
||||
let audit_result = audit_repo
|
||||
audit_repo
|
||||
.insert_audit_log(audit_actor, AUDIT_ACTION_MODEL_SWAP, audit_detail)
|
||||
.await
|
||||
.map_err(PromoteError::AuditWrite);
|
||||
.map_err(PromoteError::AuditWrite)?;
|
||||
|
||||
match audit_result {
|
||||
Ok(()) => {
|
||||
cleanup_backup_dir(store, &files.backup_dir).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
rollback_promote(store, files, backups).await;
|
||||
cleanup_backup_dir(store, &files.backup_dir).await;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
let backups = promote_files_with_backups(store, files).await?;
|
||||
cleanup_backup_dir(store, &files.backup_dir).await;
|
||||
drop(backups);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn promote_files_with_backups(
|
||||
@ -355,6 +291,7 @@ async fn backup_existing(
|
||||
) -> Result<Option<PathBuf>, PromoteError> {
|
||||
if !store
|
||||
.exists(target)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("check existing target {}", target.display()), e))?
|
||||
{
|
||||
return Ok(None);
|
||||
@ -385,7 +322,7 @@ async fn rollback_promote(store: &dyn ModelPromotionStore, files: &PromoteFileSe
|
||||
}
|
||||
|
||||
async fn remove_if_exists(store: &dyn ModelPromotionStore, path: &Path) {
|
||||
match store.exists(path) {
|
||||
match store.exists(path).await {
|
||||
Ok(true) => {
|
||||
if let Err(err) = store.remove_file(path).await {
|
||||
log!(MLLog::ModelPromotionRollbackFailed(
|
||||
@ -501,53 +438,20 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn align_uploaded_file_renames_to_manifest_declared_filename() {
|
||||
let dir = scratch_dir("align-sidecar");
|
||||
let uploaded = dir.join("upload.json");
|
||||
let declared = dir.join("sidecar.json");
|
||||
fs::write(&uploaded, b"sidecar").unwrap();
|
||||
let store = FsModelPromotionStore;
|
||||
|
||||
let aligned = align_uploaded_file_with_manifest(
|
||||
&store,
|
||||
&dir,
|
||||
"upload.json",
|
||||
"sidecar.json",
|
||||
"uploaded scaler filename",
|
||||
"rename staged scaler sidecar",
|
||||
)
|
||||
.await
|
||||
.expect("align");
|
||||
|
||||
assert_eq!(aligned, declared);
|
||||
assert!(!uploaded.exists());
|
||||
assert_eq!(fs::read(&declared).unwrap(), b"sidecar");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
#[test]
|
||||
fn validate_manifest_basename_accepts_plain_filenames() {
|
||||
assert!(validate_manifest_basename("field", "sidecar.json").is_ok());
|
||||
assert!(validate_manifest_basename("field", "classifier.onnx").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn align_uploaded_file_rejects_uploaded_paths() {
|
||||
let dir = scratch_dir("reject-uploaded-path");
|
||||
let store = FsModelPromotionStore;
|
||||
|
||||
let err = align_uploaded_file_with_manifest(
|
||||
&store,
|
||||
&dir,
|
||||
"../upload.json",
|
||||
"sidecar.json",
|
||||
"uploaded scaler filename",
|
||||
"rename staged scaler sidecar",
|
||||
)
|
||||
.await
|
||||
.expect_err("path-like uploaded filename should fail");
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_basename_rejects_path_values() {
|
||||
let err = validate_manifest_basename("field", "../upload.json").expect_err("path-like value should fail");
|
||||
assert!(err.to_string().contains("must be a basename"));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_audit_failure_rolls_back_promoted_files() {
|
||||
async fn required_audit_failure_prevents_file_promotion() {
|
||||
let tmp = scratch_dir("audit-rollback");
|
||||
let staging = tmp.join("staging");
|
||||
let models = tmp.join("models");
|
||||
@ -569,8 +473,8 @@ mod tests {
|
||||
let err = promote_files_atomically_with_required_audit(
|
||||
&store,
|
||||
&PromoteFileSet {
|
||||
staging_manifest,
|
||||
staged_onnx,
|
||||
staging_manifest: staging_manifest.clone(),
|
||||
staged_onnx: staged_onnx.clone(),
|
||||
staged_sidecar: None,
|
||||
target_manifest: target_manifest.clone(),
|
||||
target_onnx: target_onnx.clone(),
|
||||
@ -587,6 +491,8 @@ mod tests {
|
||||
assert!(matches!(err, PromoteError::AuditWrite { .. }));
|
||||
assert_eq!(fs::read(&target_manifest).unwrap(), b"old manifest");
|
||||
assert_eq!(fs::read(&target_onnx).unwrap(), b"old onnx");
|
||||
assert!(staging_manifest.exists());
|
||||
assert!(staged_onnx.exists());
|
||||
assert!(!backup.exists());
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
@ -14,11 +14,11 @@ use crate::core::inference::model_adapter::ModelSourceState;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::log::MLLog;
|
||||
use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::domain::detection::model_source::ModelInfo;
|
||||
use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver;
|
||||
use crate::interface::detection::model_change_source::ModelChangeSource;
|
||||
use crate::interface::detection::model_config_loader::ModelConfigLoader;
|
||||
use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR};
|
||||
use crate::interface::detection::model_runtime::ModelRuntimeLoader;
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -141,9 +141,9 @@ pub fn reload_model_from_disk(
|
||||
Ok(adapter) => {
|
||||
let info = ModelInfo::new(
|
||||
manifest.name.clone(),
|
||||
manifest.adapter.as_str().to_string(),
|
||||
manifest.runtime_kind().as_str().to_string(),
|
||||
current_epoch_secs(),
|
||||
manifest.features.len(),
|
||||
manifest.runtime_feature_count(),
|
||||
);
|
||||
inference.swap_state(ModelSourceState::Active {
|
||||
adapter,
|
||||
|
||||
@ -2,12 +2,11 @@ use std::cmp::Ordering as CmpOrdering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use macros::log;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::core::inference::model_adapter::{MLModelAdapter, ModelSourceState, RunnableModel};
|
||||
use crate::core::inference::model_runtime_batch::{
|
||||
@ -29,37 +28,16 @@ pub struct Inference {
|
||||
pub config: Arc<MLInferenceConfig>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
qps_recent: AtomicU32,
|
||||
circuit_breaker: Mutex<CircuitBreakerState>,
|
||||
cb_phase: AtomicU8,
|
||||
cb_failure_count: AtomicU32,
|
||||
cb_window_start_secs: AtomicU64,
|
||||
cb_open_since_secs: AtomicU64,
|
||||
classifier_ae_feature_indices: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CircuitBreakerPhase {
|
||||
Closed,
|
||||
Open { since_secs: u64 },
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct CircuitBreakerState {
|
||||
phase: CircuitBreakerPhase,
|
||||
failure_count: u32,
|
||||
window_start_secs: u64,
|
||||
}
|
||||
|
||||
impl CircuitBreakerState {
|
||||
fn closed() -> Self {
|
||||
Self {
|
||||
phase: CircuitBreakerPhase::Closed,
|
||||
failure_count: 0,
|
||||
window_start_secs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
*self = Self::closed();
|
||||
}
|
||||
}
|
||||
const CB_CLOSED: u8 = 0;
|
||||
const CB_OPEN: u8 = 1;
|
||||
const CB_HALF_OPEN: u8 = 2;
|
||||
|
||||
impl Inference {
|
||||
pub fn new(
|
||||
@ -73,7 +51,10 @@ impl Inference {
|
||||
config,
|
||||
app_config,
|
||||
qps_recent: AtomicU32::new(0),
|
||||
circuit_breaker: Mutex::new(CircuitBreakerState::closed()),
|
||||
cb_phase: AtomicU8::new(CB_CLOSED),
|
||||
cb_failure_count: AtomicU32::new(0),
|
||||
cb_window_start_secs: AtomicU64::new(0),
|
||||
cb_open_since_secs: AtomicU64::new(0),
|
||||
classifier_ae_feature_indices,
|
||||
}
|
||||
}
|
||||
@ -297,13 +278,17 @@ impl Inference {
|
||||
let n = flows.len();
|
||||
let mut scores = Vec::with_capacity(n);
|
||||
let mut chunk_features = Vec::with_capacity(batch_size * n_features);
|
||||
let mut scratch = FlowFeatures {
|
||||
features: Vec::with_capacity(n_features),
|
||||
feature_num: 0,
|
||||
};
|
||||
|
||||
for chunk_start in (0..n).step_by(batch_size) {
|
||||
let chunk_end = (chunk_start + batch_size).min(n);
|
||||
let chunk_rows = chunk_end - chunk_start;
|
||||
chunk_features.clear();
|
||||
for flow in &flows[chunk_start..chunk_end] {
|
||||
self.preprocess_ae_features_into(flow, &mut chunk_features);
|
||||
self.preprocess_ae_features_into(flow, &mut scratch, &mut chunk_features);
|
||||
}
|
||||
match run_autoencoder_flat_batch(model, &chunk_features, chunk_rows, batch_size, n_features) {
|
||||
Ok(s) => scores.extend_from_slice(&s),
|
||||
@ -353,13 +338,17 @@ impl Inference {
|
||||
let class_min_conf = self.config.class_min_confidence;
|
||||
let mut results = Vec::with_capacity(n);
|
||||
let mut chunk_features = Vec::with_capacity(batch_size * n_features);
|
||||
let mut scratch = FlowFeatures {
|
||||
features: Vec::with_capacity(n_features),
|
||||
feature_num: 0,
|
||||
};
|
||||
|
||||
for chunk_start in (0..n).step_by(batch_size) {
|
||||
let chunk_end = (chunk_start + batch_size).min(n);
|
||||
let chunk_rows = chunk_end - chunk_start;
|
||||
chunk_features.clear();
|
||||
for flow in &flows[chunk_start..chunk_end] {
|
||||
self.preprocess_classifier_features_into(flow, &mut chunk_features);
|
||||
self.preprocess_classifier_features_into(flow, &mut scratch, &mut chunk_features);
|
||||
}
|
||||
let class_probs =
|
||||
match run_classifier_flat_batch(model, &chunk_features, chunk_rows, batch_size, n_features) {
|
||||
@ -398,24 +387,28 @@ impl Inference {
|
||||
results
|
||||
}
|
||||
|
||||
fn preprocess_ae_features_into(&self, flow: &FlowSnapshot, out: &mut Vec<f32>) {
|
||||
let mut features = FlowFeatures::extract_from_stats(&flow.feature_stats, &self.config.ae_feature_names);
|
||||
features.winsorize(&self.config.ae_clip_params, &self.config.ae_feature_names);
|
||||
features.normalize(&self.config.ae_scaler_mean, &self.config.ae_scaler_std);
|
||||
features.clip(self.config.ae_post_clip_min, self.config.ae_post_clip_max);
|
||||
out.extend(features.features.iter().map(|&x| x as f32));
|
||||
fn preprocess_ae_features_into(&self, flow: &FlowSnapshot, scratch: &mut FlowFeatures, out: &mut Vec<f32>) {
|
||||
scratch.reuse_extract_from_stats(&flow.feature_stats, &self.config.ae_feature_names);
|
||||
scratch.winsorize(&self.config.ae_clip_params, &self.config.ae_feature_names);
|
||||
scratch.normalize(&self.config.ae_scaler_mean, &self.config.ae_scaler_std);
|
||||
scratch.clip(self.config.ae_post_clip_min, self.config.ae_post_clip_max);
|
||||
out.extend(scratch.features.iter().map(|&x| x as f32));
|
||||
}
|
||||
|
||||
fn preprocess_classifier_features_into(&self, flow: &FlowSnapshot, out: &mut Vec<f32>) {
|
||||
let mut features = FlowFeatures::extract_from_stats(&flow.feature_stats, &self.config.classifier_feature_names);
|
||||
apply_ae_preprocessing_by_feature_index(&mut features, &self.classifier_ae_feature_indices, &self.config);
|
||||
out.extend(features.features.iter().map(|&x| x as f32));
|
||||
fn preprocess_classifier_features_into(&self, flow: &FlowSnapshot, scratch: &mut FlowFeatures, out: &mut Vec<f32>) {
|
||||
scratch.reuse_extract_from_stats(&flow.feature_stats, &self.config.classifier_feature_names);
|
||||
apply_ae_preprocessing_by_feature_index(scratch, &self.classifier_ae_feature_indices, &self.config);
|
||||
out.extend(scratch.features.iter().map(|&x| x as f32));
|
||||
}
|
||||
|
||||
fn preprocess_ae_feature_batch(&self, flows: &[FlowSnapshot], n_features: usize) -> Vec<f32> {
|
||||
let mut rows = Vec::with_capacity(flows.len() * n_features);
|
||||
let mut scratch = FlowFeatures {
|
||||
features: Vec::with_capacity(n_features),
|
||||
feature_num: 0,
|
||||
};
|
||||
for flow in flows {
|
||||
self.preprocess_ae_features_into(flow, &mut rows);
|
||||
self.preprocess_ae_features_into(flow, &mut scratch, &mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
@ -433,23 +426,33 @@ impl Inference {
|
||||
}
|
||||
|
||||
fn is_circuit_open(&self) -> bool {
|
||||
let mut state = self.circuit_breaker.lock();
|
||||
let CircuitBreakerPhase::Open { since_secs } = state.phase else {
|
||||
return matches!(state.phase, CircuitBreakerPhase::HalfOpen);
|
||||
};
|
||||
|
||||
let cooldown = self.app_config.load().ml.circuit_breaker.cooldown_secs;
|
||||
let elapsed = Self::now_secs().saturating_sub(since_secs);
|
||||
if elapsed >= cooldown {
|
||||
state.phase = CircuitBreakerPhase::HalfOpen;
|
||||
log!(MLLog::CircuitBreakerReset(cooldown));
|
||||
return false;
|
||||
let phase = self.cb_phase.load(Ordering::Acquire);
|
||||
match phase {
|
||||
CB_OPEN => {
|
||||
let cooldown = self.app_config.load().ml.circuit_breaker.cooldown_secs;
|
||||
let since_secs = self.cb_open_since_secs.load(Ordering::Acquire);
|
||||
let elapsed = Self::now_secs().saturating_sub(since_secs);
|
||||
if elapsed >= cooldown {
|
||||
if self
|
||||
.cb_phase
|
||||
.compare_exchange(CB_OPEN, CB_HALF_OPEN, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
log!(MLLog::CircuitBreakerReset(cooldown));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
CB_HALF_OPEN => true,
|
||||
_ => false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn record_success(&self) {
|
||||
self.circuit_breaker.lock().reset();
|
||||
self.cb_phase.store(CB_CLOSED, Ordering::Release);
|
||||
self.cb_failure_count.store(0, Ordering::Release);
|
||||
self.cb_window_start_secs.store(0, Ordering::Release);
|
||||
}
|
||||
|
||||
fn record_failure(&self) {
|
||||
@ -457,30 +460,43 @@ impl Inference {
|
||||
let cfg = self.app_config.load();
|
||||
let window_secs = cfg.ml.circuit_breaker.window_secs;
|
||||
let threshold = cfg.ml.circuit_breaker.threshold;
|
||||
let mut state = self.circuit_breaker.lock();
|
||||
|
||||
match state.phase {
|
||||
CircuitBreakerPhase::Open { .. } => return,
|
||||
CircuitBreakerPhase::HalfOpen => {
|
||||
state.phase = CircuitBreakerPhase::Open { since_secs: now };
|
||||
state.failure_count = threshold.max(1);
|
||||
state.window_start_secs = now;
|
||||
log!(MLLog::CircuitBreakerOpen(state.failure_count, window_secs));
|
||||
let phase = self.cb_phase.load(Ordering::Acquire);
|
||||
match phase {
|
||||
CB_OPEN => return,
|
||||
CB_HALF_OPEN => {
|
||||
if self
|
||||
.cb_phase
|
||||
.compare_exchange(CB_HALF_OPEN, CB_OPEN, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
self.cb_open_since_secs.store(now, Ordering::Release);
|
||||
self.cb_failure_count.store(threshold.max(1), Ordering::Release);
|
||||
self.cb_window_start_secs.store(now, Ordering::Release);
|
||||
log!(MLLog::CircuitBreakerOpen(threshold.max(1), window_secs));
|
||||
}
|
||||
return;
|
||||
}
|
||||
CircuitBreakerPhase::Closed => {}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if state.window_start_secs == 0 || now.saturating_sub(state.window_start_secs) > window_secs {
|
||||
state.window_start_secs = now;
|
||||
state.failure_count = 1;
|
||||
let window_start = self.cb_window_start_secs.load(Ordering::Acquire);
|
||||
if window_start == 0 || now.saturating_sub(window_start) > window_secs {
|
||||
self.cb_window_start_secs.store(now, Ordering::Release);
|
||||
self.cb_failure_count.store(1, Ordering::Release);
|
||||
} else {
|
||||
state.failure_count = state.failure_count.saturating_add(1);
|
||||
self.cb_failure_count.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
if state.failure_count >= threshold {
|
||||
state.phase = CircuitBreakerPhase::Open { since_secs: now };
|
||||
log!(MLLog::CircuitBreakerOpen(state.failure_count, window_secs));
|
||||
let count = self.cb_failure_count.load(Ordering::Acquire);
|
||||
if count >= threshold
|
||||
&& self
|
||||
.cb_phase
|
||||
.compare_exchange(CB_CLOSED, CB_OPEN, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
self.cb_open_since_secs.store(now, Ordering::Release);
|
||||
log!(MLLog::CircuitBreakerOpen(count, window_secs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -622,8 +638,12 @@ mod tests {
|
||||
Arc::new(ArcSwap::from_pointee(AppConfig::defaults())),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let mut scratch = FlowFeatures {
|
||||
features: Vec::new(),
|
||||
feature_num: 0,
|
||||
};
|
||||
|
||||
inference.preprocess_classifier_features_into(&sample_flow(), &mut out);
|
||||
inference.preprocess_classifier_features_into(&sample_flow(), &mut scratch, &mut out);
|
||||
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(
|
||||
@ -732,6 +752,6 @@ mod tests {
|
||||
is_forward: true,
|
||||
};
|
||||
let flow = FlowData::new(FlowKey::from_packet(&packet), &packet, Direction::Ingress);
|
||||
FlowSnapshot::from_flow(&flow)
|
||||
FlowSnapshot::from_flow_data(&flow)
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,7 +223,7 @@ impl SoarEngine {
|
||||
"SOAR auto-response triggered (hits: {}{})",
|
||||
event.flow_count, repeat_tag,
|
||||
),
|
||||
timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
timestamp: Utc::now().timestamp(),
|
||||
};
|
||||
notifier.send_alert(&payload).await?;
|
||||
Ok("Telegram notification sent".to_string())
|
||||
|
||||
@ -45,6 +45,7 @@ impl SoarEngine {
|
||||
for block in &active_blocks {
|
||||
if let Err(e) = self.access_control.block_ip(&block.source_ip) {
|
||||
log!(SoarLog::RecoveryFailed(block.source_ip.clone(), e.to_string()));
|
||||
log!(SoarLog::BlockEnforcementGap(block.source_ip.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ use crate::interface::reporting::email_sender::{
|
||||
EmailSender as EmailSenderTrait, EmailSenderFactory as EmailSenderFactoryTrait,
|
||||
};
|
||||
use crate::interface::response::playbook_data::{ActionInput, CreatePlaybookInput};
|
||||
use crate::interface::response::soar::SoarRepo;
|
||||
use crate::interface::response::soar::PlaybookRepo;
|
||||
use crate::interface::response::webhook_sender::WebhookSender as WebhookSenderTrait;
|
||||
use crate::interface::system::secret_store::SecretStorePort;
|
||||
|
||||
|
||||
@ -49,7 +49,10 @@ impl FrequencyTracker {
|
||||
let window = Duration::from_secs(window_secs);
|
||||
|
||||
let count = {
|
||||
let mut entry = self.events.entry(key).or_insert_with(|| FrequencyEntry::new(now));
|
||||
let mut entry = self
|
||||
.events
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| FrequencyEntry::new(now));
|
||||
let entry = entry.value_mut();
|
||||
entry.last_seen = now;
|
||||
let deque = &mut entry.events;
|
||||
@ -72,7 +75,7 @@ impl FrequencyTracker {
|
||||
};
|
||||
|
||||
if self.events.len() > self.max_tracked_keys {
|
||||
self.evict_oldest(self.events.len() - self.max_tracked_keys);
|
||||
self.evict_oldest_excluding(self.events.len() - self.max_tracked_keys, &key);
|
||||
}
|
||||
|
||||
count
|
||||
@ -104,9 +107,18 @@ impl FrequencyTracker {
|
||||
}
|
||||
|
||||
fn evict_oldest(&self, count: usize) -> u32 {
|
||||
self.evict_oldest_inner(count, None)
|
||||
}
|
||||
|
||||
fn evict_oldest_excluding(&self, count: usize, exclude: &FreqKey) -> u32 {
|
||||
self.evict_oldest_inner(count, Some(exclude))
|
||||
}
|
||||
|
||||
fn evict_oldest_inner(&self, count: usize, exclude: Option<&FreqKey>) -> u32 {
|
||||
let mut entries: Vec<(FreqKey, Instant)> = self
|
||||
.events
|
||||
.iter()
|
||||
.filter(|entry| exclude.is_none_or(|ex| entry.key() != ex))
|
||||
.map(|entry| (entry.key().clone(), entry.last_seen))
|
||||
.collect();
|
||||
if count < entries.len() {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
#[derive(Debug, Clone)]
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: i64,
|
||||
pub actor: String,
|
||||
|
||||
@ -3,6 +3,16 @@ pub const FUSION_AUDIT_ACTOR: &str = "FusionEngine";
|
||||
pub const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
|
||||
pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
|
||||
pub const PERMISSION_SYSTEM_ADMIN: &str = "system:admin";
|
||||
pub const PERMISSION_USERS_ADMIN: &str = "users:admin";
|
||||
pub const PERMISSION_API_KEYS_ADMIN: &str = "api_keys:admin";
|
||||
pub const PERMISSION_ACCESS_CONTROL_WRITE: &str = "access_control:write";
|
||||
pub const PERMISSION_DASHBOARD_READ: &str = "dashboard:read";
|
||||
pub const PERMISSION_AI_DETECTION_READ: &str = "ai_detection:read";
|
||||
pub const PERMISSION_FUSION_READ: &str = "fusion:read";
|
||||
pub const PERMISSION_TRAFFIC_MAP_READ: &str = "traffic_map:read";
|
||||
pub const PERMISSION_DROPS_READ: &str = "drops:read";
|
||||
pub const ENFORCE_MODE_MONITOR: &str = "monitor";
|
||||
pub const ENFORCE_MODE_ML_ONLY: &str = "ml_only";
|
||||
pub const ENFORCE_MODE_ENFORCE: &str = "enforce";
|
||||
|
||||
pub const KNOWN_C2_PORTS: &[u16] = &[4444, 8443, 8080, 1337, 31337];
|
||||
|
||||
@ -51,16 +51,6 @@ pub struct DetectionEvent {
|
||||
pub c2_score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowObservation {
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub packet_count: u64,
|
||||
pub flow_duration_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DetectionDiagnostic {
|
||||
pub source: DetectionSource,
|
||||
|
||||
@ -6,5 +6,5 @@ pub struct AlertPayload {
|
||||
pub threat_type: String,
|
||||
pub confidence: f32,
|
||||
pub action_description: String,
|
||||
pub timestamp: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
pub mod health;
|
||||
pub mod rate_limit_settings;
|
||||
pub mod suricata;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RateLimitSettings {
|
||||
pub packet_rate: Option<u64>,
|
||||
pub syn_rate: Option<u64>,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -64,8 +65,16 @@ impl fmt::Display for CanonicalAttackType {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CanonicalAttackType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::ALL.iter().copied().find(|c| c.as_str() == s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonical_from_str(s: &str) -> Option<CanonicalAttackType> {
|
||||
CanonicalAttackType::ALL.iter().copied().find(|c| c.as_str() == s)
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
pub fn translate(source: DetectionSource, raw_label: &str) -> CanonicalAttackType {
|
||||
|
||||
@ -53,21 +53,3 @@ traceable! {
|
||||
ModelWatcherFailed => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
traceable! {
|
||||
SuricataError {
|
||||
#[no_source]
|
||||
#[error("Suricata binary not found at '{path}'")]
|
||||
BinaryNotFound { path: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Suricata config not found at '{path}'")]
|
||||
ConfigNotFound { path: String } => tracing::Level::ERROR,
|
||||
|
||||
#[error("Failed to spawn Suricata subprocess")]
|
||||
SpawnFailed => tracing::Level::ERROR,
|
||||
|
||||
#[error("Failed to open eve.json stream at '{path}'")]
|
||||
EveOpenFailed { path: String } => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,7 +287,7 @@ impl FeatureStats {
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, feature_name: &str) -> f64 {
|
||||
pub fn get(&self, feature_name: &str) -> f64 {
|
||||
FEATURE_REGISTRY.get(feature_name).map(|g| g(self)).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::domain::detection::feature_extractor::FeatureStats;
|
||||
use crate::domain::detection::ml_detection::ClipParams;
|
||||
|
||||
static ALL_FEATURE_NAMES_OWNED: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
@ -105,8 +106,22 @@ pub struct FlowFeatures {
|
||||
}
|
||||
|
||||
impl FlowFeatures {
|
||||
pub fn reuse_extract_from_stats(&mut self, precomputed: &FeatureStats, feature_names: &[String]) {
|
||||
self.feature_num = feature_names.len();
|
||||
self.features.clear();
|
||||
self.features.reserve(self.feature_num);
|
||||
for name in feature_names {
|
||||
self.features.push(precomputed.get(name.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self, means: &[f64], stds: &[f64]) {
|
||||
for i in 0..self.feature_num {
|
||||
let bound = self
|
||||
.feature_num
|
||||
.min(means.len())
|
||||
.min(stds.len())
|
||||
.min(self.features.len());
|
||||
for i in 0..bound {
|
||||
if stds[i] > 0.0 {
|
||||
self.features[i] = (self.features[i] - means[i]) / stds[i];
|
||||
} else {
|
||||
@ -139,9 +154,16 @@ impl FlowFeatures {
|
||||
&ALL_FEATURE_NAMES_OWNED
|
||||
}
|
||||
|
||||
pub fn to_csv_record(&self) -> Vec<String> {
|
||||
let mut record: Vec<String> = self.features.iter().map(|f| f.to_string()).collect();
|
||||
record.push("BENIGN".to_string());
|
||||
record
|
||||
pub fn to_csv_line(&self) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut buf = String::with_capacity(self.feature_num * 12);
|
||||
for (i, f) in self.features.iter().enumerate() {
|
||||
if i > 0 {
|
||||
buf.push(',');
|
||||
}
|
||||
let _ = write!(buf, "{f}");
|
||||
}
|
||||
buf.push_str(",BENIGN");
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
9
net-guardia/src/domain/detection/flow_observation.rs
Normal file
9
net-guardia/src/domain/detection/flow_observation.rs
Normal file
@ -0,0 +1,9 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowObservation {
|
||||
pub src_ip: String,
|
||||
pub dst_ip: String,
|
||||
pub dst_port: u16,
|
||||
pub protocol: u8,
|
||||
pub packet_count: u64,
|
||||
pub flow_duration_us: u64,
|
||||
}
|
||||
@ -44,7 +44,6 @@ pub struct FlowData {
|
||||
pub bwd_bulk_state: BulkState,
|
||||
pub act_data_pkt_fwd: u32,
|
||||
pub is_first_packet: bool,
|
||||
pub last_inferred_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -61,7 +60,7 @@ pub struct FlowSnapshot {
|
||||
}
|
||||
|
||||
impl FlowSnapshot {
|
||||
pub fn from_flow(flow: &FlowData) -> Self {
|
||||
pub fn from_flow_data(flow: &FlowData) -> Self {
|
||||
Self {
|
||||
flow_key: flow.flow_key,
|
||||
direction: flow.direction,
|
||||
@ -87,10 +86,10 @@ impl FlowData {
|
||||
direction,
|
||||
start_time_us: first_packet.timestamp_us,
|
||||
last_time_us: first_packet.timestamp_us,
|
||||
fwd_packets: Vec::new(),
|
||||
fwd_packets: Vec::with_capacity(32),
|
||||
fwd_total_bytes: 0,
|
||||
fwd_header_bytes: 0,
|
||||
bwd_packets: Vec::new(),
|
||||
bwd_packets: Vec::with_capacity(32),
|
||||
bwd_total_bytes: 0,
|
||||
bwd_header_bytes: 0,
|
||||
fin_count: 0,
|
||||
@ -111,14 +110,13 @@ impl FlowData {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
active_periods: Vec::new(),
|
||||
idle_periods: Vec::new(),
|
||||
active_periods: Vec::with_capacity(16),
|
||||
idle_periods: Vec::with_capacity(8),
|
||||
last_packet_time: first_packet.timestamp_us,
|
||||
fwd_bulk_state: BulkState::default(),
|
||||
bwd_bulk_state: BulkState::default(),
|
||||
act_data_pkt_fwd: 0,
|
||||
is_first_packet: true,
|
||||
last_inferred_us: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,9 @@ loggable! {
|
||||
#[error("ML models loaded - {info}")]
|
||||
ModelsLoaded { info: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("ML model status summary: {summary}")]
|
||||
ModelStatusSummary { summary: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Inference configuration loaded: {features} features, {attacks} attack types")]
|
||||
ConfigLoaded { features: usize, attacks: usize } => tracing::Level::INFO,
|
||||
|
||||
@ -170,49 +173,3 @@ loggable! {
|
||||
DetectionChannelDrop { detector: String, attack_type: String, source_ip: String } => tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
|
||||
loggable! {
|
||||
SuricataLog {
|
||||
#[error("Suricata bridge disabled by config")]
|
||||
Disabled => tracing::Level::INFO,
|
||||
|
||||
#[error("Spawning Suricata: {binary} -c {config} -i {iface}")]
|
||||
Spawning { binary: String, config: String, iface: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess started (pid={pid})")]
|
||||
Started { pid: u32 } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess exited unexpectedly: {reason}. Restart in {backoff}s")]
|
||||
CrashedRestartPending { reason: String, backoff: u64 } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata subprocess stopped: {reason}")]
|
||||
Stopped { reason: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess sent SIGTERM for graceful shutdown")]
|
||||
ShutdownRequested => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata subprocess SIGTERM failed: {error}")]
|
||||
ShutdownSignalFailed { error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata subprocess SIGKILL failed after timeout: {error}")]
|
||||
ShutdownKillFailed { error: String } => tracing::Level::ERROR,
|
||||
|
||||
#[error("Suricata eve.json monitor waiting for file: {path}")]
|
||||
MonitorWaitingForFile { path: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata eve.json monitor failed to open {path}: {error}")]
|
||||
MonitorOpenFailed { path: String, error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata eve.json monitor failed to seek to end of {path}: {error}")]
|
||||
MonitorSeekFailed { path: String, error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata eve.json monitor attached to {path}")]
|
||||
MonitorAttached { path: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata eve.json rotated — reopening")]
|
||||
MonitorFileRotated => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata alert forwarded: sid={sid} {src}->{dst} {signature}")]
|
||||
AlertForwarded { sid: u32, src: String, dst: String, signature: String } => tracing::Level::DEBUG,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::feature_extractor::feature_is_known;
|
||||
use crate::domain::detection::feature_extractor::feature_registry_names;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -24,14 +24,77 @@ impl AdapterKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ModelPaths {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ModelManifest {
|
||||
pub name: String,
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelSpec>,
|
||||
#[serde(default)]
|
||||
pub pipeline: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, LabelSpec>,
|
||||
#[serde(default)]
|
||||
pub alert_rules: Vec<AlertRuleSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ModelSpec {
|
||||
pub id: String,
|
||||
pub file: String,
|
||||
#[serde(default)]
|
||||
pub input_features: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub preprocessing: Vec<PreprocessingStep>,
|
||||
#[serde(default)]
|
||||
pub outputs: Vec<OutputHeadSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PreprocessingStep {
|
||||
StandardScaler {
|
||||
sidecar: String,
|
||||
},
|
||||
MinMaxScaler {
|
||||
sidecar: String,
|
||||
},
|
||||
RobustScaler {
|
||||
sidecar: String,
|
||||
},
|
||||
LogTransform {
|
||||
#[serde(default = "default_log_offset")]
|
||||
offset: f32,
|
||||
},
|
||||
Clip {
|
||||
min: f32,
|
||||
max: f32,
|
||||
},
|
||||
Quantile {
|
||||
sidecar: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputSemantic {
|
||||
AnomalyScore,
|
||||
Binary,
|
||||
Multiclass,
|
||||
Multilabel,
|
||||
Regression,
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct OutputHeadSpec {
|
||||
pub name: String,
|
||||
pub shape: Vec<String>,
|
||||
pub semantic: OutputSemantic,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
pub threshold: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub autoencoder: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub classifier: Option<String>,
|
||||
pub min_confidence: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -43,109 +106,306 @@ pub struct LabelSpec {
|
||||
pub playbook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct Thresholds {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub anomaly: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub c2: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class_min_confidence: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ae: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alert_multiplier: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Preprocessing {
|
||||
pub scaler_sidecar: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ModelManifest {
|
||||
pub name: String,
|
||||
pub adapter: AdapterKind,
|
||||
#[serde(default)]
|
||||
pub models: ModelPaths,
|
||||
pub features: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, LabelSpec>,
|
||||
#[serde(default)]
|
||||
pub thresholds: Thresholds,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preprocessing: Option<Preprocessing>,
|
||||
pub struct AlertRuleSpec {
|
||||
pub condition: String,
|
||||
pub source_label: String,
|
||||
}
|
||||
|
||||
impl ModelManifest {
|
||||
pub fn validate(&self, path: &Path) -> Result<(), MLError> {
|
||||
if self.version != 2 {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("version must be 2 (got {})", self.version),
|
||||
));
|
||||
}
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"name is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.features.is_empty() {
|
||||
if self.models.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"features is empty".to_string(),
|
||||
"models is empty".to_string(),
|
||||
));
|
||||
}
|
||||
for f in &self.features {
|
||||
if !feature_is_known(f) {
|
||||
return Err(MLError::UnknownFeature(f.clone()));
|
||||
}
|
||||
if self.pipeline.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"pipeline is empty".to_string(),
|
||||
));
|
||||
}
|
||||
match self.adapter {
|
||||
AdapterKind::MultiTask => {
|
||||
if self.models.autoencoder.is_none() || self.models.classifier.is_none() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"multi_task adapter requires both models.autoencoder and models.classifier".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
AdapterKind::ClassifierOnly | AdapterKind::AutoencoderOnly => {
|
||||
if self.models.model.is_none() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("{} adapter requires models.model", self.adapter.as_str()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.validate_artifact_paths(path)?;
|
||||
|
||||
self.validate_models(path)?;
|
||||
self.validate_pipeline(path)?;
|
||||
self.validate_pipeline_features(path)?;
|
||||
self.validate_labels(path)?;
|
||||
self.validate_thresholds(path)?;
|
||||
self.validate_alert_rules(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_artifact_paths(&self, path: &Path) -> Result<(), MLError> {
|
||||
let mut seen_artifacts: Vec<(&str, Vec<String>)> = Vec::new();
|
||||
for (field, value) in [
|
||||
("models.model", self.models.model.as_deref()),
|
||||
("models.autoencoder", self.models.autoencoder.as_deref()),
|
||||
("models.classifier", self.models.classifier.as_deref()),
|
||||
(
|
||||
"preprocessing.scaler_sidecar",
|
||||
self.preprocessing.as_ref().map(|p| p.scaler_sidecar.as_str()),
|
||||
),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
validate_relative_artifact_path(path, field, value)?;
|
||||
validate_artifact_not_manifest_path(path, field, value)?;
|
||||
let components = normalized_artifact_components(value);
|
||||
if let Some((existing_field, _)) = seen_artifacts
|
||||
pub fn model_by_id(&self, id: &str) -> Option<&ModelSpec> {
|
||||
self.models.iter().find(|model| model.id == id)
|
||||
}
|
||||
|
||||
pub fn pipeline_models(&self) -> Result<Vec<&ModelSpec>, MLError> {
|
||||
let mut ordered = Vec::with_capacity(self.pipeline.len());
|
||||
for id in &self.pipeline {
|
||||
let model = self.model_by_id(id).ok_or_else(|| {
|
||||
MLError::ManifestInvalid(PathBuf::new(), format!("pipeline references unknown model id '{id}'"))
|
||||
})?;
|
||||
ordered.push(model);
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
pub fn primary_model(&self) -> Option<&ModelSpec> {
|
||||
self.pipeline.first().and_then(|id| self.model_by_id(id))
|
||||
}
|
||||
|
||||
pub fn classifier_model(&self) -> Option<&ModelSpec> {
|
||||
self.pipeline.get(1).and_then(|id| self.model_by_id(id))
|
||||
}
|
||||
|
||||
pub fn runtime_kind(&self) -> AdapterKind {
|
||||
match self.pipeline.len() {
|
||||
0 | 1 => {
|
||||
let outputs = self
|
||||
.primary_model()
|
||||
.map(|model| model.outputs.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
if outputs
|
||||
.iter()
|
||||
.find(|(_, existing_components)| existing_components == &components)
|
||||
.any(|output| output.semantic == OutputSemantic::AnomalyScore)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("{field} must not reuse the same artifact path as {existing_field}: {value:?}"),
|
||||
));
|
||||
AdapterKind::AutoencoderOnly
|
||||
} else {
|
||||
AdapterKind::ClassifierOnly
|
||||
}
|
||||
seen_artifacts.push((field, components));
|
||||
}
|
||||
_ => AdapterKind::MultiTask,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_feature_count(&self) -> usize {
|
||||
self.primary_model()
|
||||
.map(|model| model.input_features.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn primary_scaler_sidecar(&self) -> Option<&str> {
|
||||
self.primary_model().and_then(|model| {
|
||||
model.preprocessing.iter().find_map(|step| match step {
|
||||
PreprocessingStep::StandardScaler { sidecar }
|
||||
| PreprocessingStep::MinMaxScaler { sidecar }
|
||||
| PreprocessingStep::RobustScaler { sidecar }
|
||||
| PreprocessingStep::Quantile { sidecar } => Some(sidecar.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_models(&self, path: &Path) -> Result<(), MLError> {
|
||||
let mut seen_ids = Vec::with_capacity(self.models.len());
|
||||
let mut seen_artifacts: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
for model in &self.models {
|
||||
if model.id.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"model id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if model.file.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{}' file is empty", model.id),
|
||||
));
|
||||
}
|
||||
if seen_ids.iter().any(|id| id == &model.id) {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("duplicate model id '{}'", model.id),
|
||||
));
|
||||
}
|
||||
seen_ids.push(model.id.clone());
|
||||
|
||||
validate_relative_artifact_path(path, &format!("models[{}].file", model.id), &model.file)?;
|
||||
validate_artifact_not_manifest_path(path, &format!("models[{}].file", model.id), &model.file)?;
|
||||
let file_components = normalized_artifact_components(&model.file);
|
||||
if let Some((existing_field, _)) = seen_artifacts
|
||||
.iter()
|
||||
.find(|(_, existing_components)| existing_components == &file_components)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!(
|
||||
"model file for '{}' must not reuse the same artifact path as {}: {:?}",
|
||||
model.id, existing_field, model.file
|
||||
),
|
||||
));
|
||||
}
|
||||
seen_artifacts.push((model.id.clone(), file_components));
|
||||
|
||||
if model.outputs.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{}' outputs is empty", model.id),
|
||||
));
|
||||
}
|
||||
self.validate_preprocessing(path, &model.id, &model.preprocessing)?;
|
||||
self.validate_outputs(path, &model.id, &model.outputs)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_preprocessing(&self, path: &Path, model_id: &str, steps: &[PreprocessingStep]) -> Result<(), MLError> {
|
||||
for (idx, step) in steps.iter().enumerate() {
|
||||
match step {
|
||||
PreprocessingStep::StandardScaler { sidecar }
|
||||
| PreprocessingStep::MinMaxScaler { sidecar }
|
||||
| PreprocessingStep::RobustScaler { sidecar }
|
||||
| PreprocessingStep::Quantile { sidecar } => {
|
||||
validate_relative_artifact_path(
|
||||
path,
|
||||
&format!("models[{model_id}].preprocessing[{idx}]"),
|
||||
sidecar,
|
||||
)?;
|
||||
validate_artifact_not_manifest_path(
|
||||
path,
|
||||
&format!("models[{model_id}].preprocessing[{idx}]"),
|
||||
sidecar,
|
||||
)?;
|
||||
}
|
||||
PreprocessingStep::LogTransform { offset } => {
|
||||
if !offset.is_finite() || *offset < 0.0 {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!(
|
||||
"models[{model_id}].preprocessing[{idx}].offset must be finite and >= 0 (got {offset})"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
PreprocessingStep::Clip { min, max } => {
|
||||
if !min.is_finite() || !max.is_finite() || min > max {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!(
|
||||
"models[{model_id}].preprocessing[{idx}] clip range must be finite and ordered lower <= upper (got {min}..{max})"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_pipeline_features(&self, path: &Path) -> Result<(), MLError> {
|
||||
let mut available: HashSet<String> = feature_registry_names()
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect();
|
||||
|
||||
for model in self.pipeline_models()? {
|
||||
for feature in &model.input_features {
|
||||
if !available.contains(feature) {
|
||||
return Err(MLError::UnknownFeature(feature.clone()));
|
||||
}
|
||||
}
|
||||
for output in &model.outputs {
|
||||
available.insert(output.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if available.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"no features or pipeline outputs are available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_outputs(&self, path: &Path, model_id: &str, outputs: &[OutputHeadSpec]) -> Result<(), MLError> {
|
||||
let mut seen_names = Vec::with_capacity(outputs.len());
|
||||
for output in outputs {
|
||||
if output.name.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{model_id}' has an output with empty name"),
|
||||
));
|
||||
}
|
||||
if output.shape.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{model_id}' output '{}' shape is empty", output.name),
|
||||
));
|
||||
}
|
||||
for dim in &output.shape {
|
||||
validate_output_shape_dim(path, model_id, &output.name, dim)?;
|
||||
}
|
||||
if seen_names.iter().any(|name| name == &output.name) {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{model_id}' has duplicate output name '{}'", output.name),
|
||||
));
|
||||
}
|
||||
seen_names.push(output.name.clone());
|
||||
if let Some(value) = output.threshold
|
||||
&& !(value.is_finite() && value >= 0.0)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!(
|
||||
"model '{model_id}' output '{}' threshold must be finite and >= 0 (got {value})",
|
||||
output.name
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(value) = output.min_confidence
|
||||
&& !(value.is_finite() && (0.0..=1.0).contains(&value))
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!(
|
||||
"model '{model_id}' output '{}' min_confidence must be finite and in [0, 1] (got {value})",
|
||||
output.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_pipeline(&self, path: &Path) -> Result<(), MLError> {
|
||||
let mut seen = Vec::with_capacity(self.pipeline.len());
|
||||
for id in &self.pipeline {
|
||||
if id.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
"pipeline contains an empty model id".to_string(),
|
||||
));
|
||||
}
|
||||
if seen.iter().any(|seen_id| seen_id == id) {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("pipeline contains duplicate model id '{id}'"),
|
||||
));
|
||||
}
|
||||
if self.model_by_id(id).is_none() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("pipeline references unknown model id '{id}'"),
|
||||
));
|
||||
}
|
||||
seen.push(id.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -164,7 +424,7 @@ impl ModelManifest {
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("label '{}' has confirmations: 0 (must be ≥ 1)", spec.name),
|
||||
format!("label '{}' has confirmations: 0 (must be >= 1)", spec.name),
|
||||
));
|
||||
}
|
||||
let lower = spec.name.to_ascii_lowercase();
|
||||
@ -179,36 +439,20 @@ impl ModelManifest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_thresholds(&self, path: &Path) -> Result<(), MLError> {
|
||||
for (field, value) in [
|
||||
("thresholds.anomaly", self.thresholds.anomaly),
|
||||
("thresholds.c2", self.thresholds.c2),
|
||||
("thresholds.ae", self.thresholds.ae),
|
||||
] {
|
||||
if let Some(v) = value
|
||||
&& !(v.is_finite() && v >= 0.0)
|
||||
{
|
||||
fn validate_alert_rules(&self, path: &Path) -> Result<(), MLError> {
|
||||
for (idx, rule) in self.alert_rules.iter().enumerate() {
|
||||
if rule.condition.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("{field} must be finite and >= 0 (got {v})"),
|
||||
format!("alert_rules[{idx}].condition is empty"),
|
||||
));
|
||||
}
|
||||
if rule.source_label.trim().is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("alert_rules[{idx}].source_label is empty"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(v) = self.thresholds.class_min_confidence
|
||||
&& !(v.is_finite() && (0.0..=1.0).contains(&v))
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("thresholds.class_min_confidence must be finite and in [0, 1] (got {v})"),
|
||||
));
|
||||
}
|
||||
if let Some(m) = self.thresholds.alert_multiplier
|
||||
&& !(m.is_finite() && m > 0.0)
|
||||
{
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("thresholds.alert_multiplier must be finite and > 0 (got {m})"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -218,6 +462,33 @@ impl ModelManifest {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_offset() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn validate_output_shape_dim(path: &Path, model_id: &str, output_name: &str, dim: &str) -> Result<(), MLError> {
|
||||
let trimmed = dim.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{model_id}' output '{output_name}' has an empty shape dimension"),
|
||||
));
|
||||
}
|
||||
if trimmed.parse::<usize>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(MLError::ManifestInvalid(
|
||||
path.to_path_buf(),
|
||||
format!("model '{model_id}' output '{output_name}' has invalid shape dimension '{dim}'"),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_relative_artifact_path(manifest_path: &Path, field: &str, value: &str) -> Result<(), MLError> {
|
||||
if value.is_empty() {
|
||||
return Err(MLError::ManifestInvalid(
|
||||
@ -264,7 +535,7 @@ fn normalized_artifact_components(value: &str) -> Vec<String> {
|
||||
Path::new(value)
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
Component::Normal(value) => value.to_str().map(str::to_string),
|
||||
Component::Normal(s) => s.to_str().map(|s| s.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@ -274,252 +545,78 @@ fn normalized_artifact_components(value: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const V10_MANIFEST: &str = r#"
|
||||
name: netguardia-v10
|
||||
adapter: multi_task
|
||||
const V2_MANIFEST: &str = r#"
|
||||
name: netguardia-v2
|
||||
version: 2
|
||||
models:
|
||||
autoencoder: deep_autoencoder.onnx
|
||||
classifier: classifier.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- bwd_packets
|
||||
- id: anomaly_detector
|
||||
file: deep_autoencoder.onnx
|
||||
input_features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
preprocessing:
|
||||
- type: standard_scaler
|
||||
sidecar: inference_config.json
|
||||
- type: clip
|
||||
min: -5.0
|
||||
max: 5.0
|
||||
outputs:
|
||||
- name: ae_anomaly_score
|
||||
shape: [1]
|
||||
semantic: anomaly_score
|
||||
threshold: 0.23
|
||||
- id: classifier
|
||||
file: classifier.onnx
|
||||
input_features:
|
||||
- flow_duration
|
||||
- fwd_packets
|
||||
- ae_anomaly_score
|
||||
outputs:
|
||||
- name: anomaly
|
||||
shape: [1]
|
||||
semantic: binary
|
||||
threshold: 0.91
|
||||
- name: class_probs
|
||||
shape: [10]
|
||||
semantic: multiclass
|
||||
min_confidence: 0.4
|
||||
pipeline:
|
||||
- anomaly_detector
|
||||
- classifier
|
||||
labels:
|
||||
"0": { name: Bot }
|
||||
"0": { name: Bot, confirmations: 1 }
|
||||
"7": { name: Normal }
|
||||
thresholds:
|
||||
anomaly: 0.9
|
||||
c2: 0.85
|
||||
preprocessing:
|
||||
scaler_sidecar: inference_config.json
|
||||
alert_rules:
|
||||
- condition: "anomaly > threshold"
|
||||
source_label: anomaly
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_multitask() {
|
||||
let m: ModelManifest = serde_yaml_ng::from_str(V10_MANIFEST).expect("parse");
|
||||
assert_eq!(m.name, "netguardia-v10");
|
||||
assert_eq!(m.adapter, AdapterKind::MultiTask);
|
||||
assert_eq!(m.features.len(), 3);
|
||||
assert_eq!(m.models.autoencoder.as_deref(), Some("deep_autoencoder.onnx"));
|
||||
assert_eq!(m.models.classifier.as_deref(), Some("classifier.onnx"));
|
||||
assert_eq!(m.labels.len(), 2);
|
||||
assert_eq!(m.labels.get("0").map(|l| l.name.as_str()), Some("Bot"));
|
||||
fn parse_v2_manifest() {
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(V2_MANIFEST).expect("parse");
|
||||
assert_eq!(parsed.version, 2);
|
||||
assert_eq!(parsed.runtime_kind(), AdapterKind::MultiTask);
|
||||
assert_eq!(parsed.runtime_feature_count(), 2);
|
||||
assert_eq!(parsed.primary_scaler_sidecar(), Some("inference_config.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_feature() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- this_feature_does_not_exist
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject unknown feature");
|
||||
assert!(matches!(err, MLError::UnknownFeature { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multitask_missing_ae() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: multi_task
|
||||
models:
|
||||
classifier: c.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should require autoencoder");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_features() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features: []
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject empty features");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_confirmations() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
labels:
|
||||
"0": { name: Bot, confirmations: 0 }
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject confirmations: 0");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_label_name_ignoring_case() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
labels:
|
||||
"0": { name: Bot }
|
||||
"1": { name: BOT }
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject duplicate label names");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_numeric_label_key() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
labels:
|
||||
normal: { name: Normal }
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject non-numeric label key");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_or_parent_artifact_paths() {
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
for model_path in ["/tmp/model.onnx", "../model.onnx", "./model.onnx"] {
|
||||
let yaml = format!(
|
||||
r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: {model_path}
|
||||
features:
|
||||
- flow_duration
|
||||
"#
|
||||
);
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject unsafe artifact path");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_scaler_sidecar_path() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
preprocessing:
|
||||
scaler_sidecar: ../inference_config.json
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject unsafe sidecar path");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_artifact_path_pointing_at_manifest_file() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: manifest.yaml
|
||||
features:
|
||||
- flow_duration
|
||||
"#;
|
||||
let path = Path::new("/tmp/manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
fn reject_invalid_version() {
|
||||
let yaml = V2_MANIFEST.replace("version: 2", "version: 1");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).expect("parse");
|
||||
let err = parsed
|
||||
.validate(path)
|
||||
.expect_err("should reject manifest self-reference");
|
||||
assert!(err.to_string().contains("manifest file itself"), "got {err:?}");
|
||||
.validate(Path::new("/tmp/manifest.yaml"))
|
||||
.expect_err("should reject version");
|
||||
assert!(err.to_string().contains("version must be 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_artifact_paths() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: multi_task
|
||||
models:
|
||||
autoencoder: model.onnx
|
||||
classifier: model.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
"#;
|
||||
let path = Path::new("/tmp/manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject duplicate artifacts");
|
||||
assert!(err.to_string().contains("same artifact path"), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_alert_multiplier() {
|
||||
let yaml = r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
thresholds:
|
||||
alert_multiplier: 0
|
||||
"#;
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
let err = parsed.validate(path).expect_err("should reject alert_multiplier <= 0");
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_threshold_overrides() {
|
||||
for thresholds in ["anomaly: -0.1", "c2: .nan", "ae: -.inf", "class_min_confidence: 1.1"] {
|
||||
let yaml = format!(
|
||||
r#"
|
||||
name: bad
|
||||
adapter: classifier_only
|
||||
models:
|
||||
model: m.onnx
|
||||
features:
|
||||
- flow_duration
|
||||
thresholds:
|
||||
{thresholds}
|
||||
"#
|
||||
);
|
||||
let path = Path::new("/tmp/test-manifest.yaml");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).unwrap();
|
||||
|
||||
let err = parsed.validate(path).expect_err("should reject invalid threshold");
|
||||
|
||||
assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}");
|
||||
}
|
||||
fn reject_unknown_feature() {
|
||||
let yaml = V2_MANIFEST.replace("flow_duration", "not_a_real_feature");
|
||||
let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).expect("parse");
|
||||
let err = parsed
|
||||
.validate(Path::new("/tmp/manifest.yaml"))
|
||||
.expect_err("should reject unknown feature");
|
||||
assert!(err.to_string().contains("Unknown feature"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,13 @@ pub mod drift;
|
||||
pub mod error;
|
||||
pub mod feature_extractor;
|
||||
pub mod flow_features;
|
||||
pub mod flow_observation;
|
||||
pub mod flow_tracker;
|
||||
pub mod fusion_math;
|
||||
pub mod log;
|
||||
pub mod manifest;
|
||||
pub mod ml_detection;
|
||||
pub mod ml_inference_config;
|
||||
pub mod model_files;
|
||||
pub mod model_source;
|
||||
pub mod suricata_health;
|
||||
|
||||
@ -1,24 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub struct ModelIdentity {
|
||||
pub name: String,
|
||||
pub adapter_kind: String,
|
||||
pub loaded_at_secs: u64,
|
||||
pub features_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
#[serde(flatten)]
|
||||
pub identity: ModelIdentity,
|
||||
pub loaded_at_secs: u64,
|
||||
pub qps_recent: f32,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
pub fn new(name: String, adapter_kind: String, loaded_at_secs: u64, features_count: usize) -> Self {
|
||||
Self {
|
||||
name,
|
||||
adapter_kind,
|
||||
identity: ModelIdentity {
|
||||
name,
|
||||
adapter_kind,
|
||||
features_count,
|
||||
},
|
||||
loaded_at_secs,
|
||||
features_count,
|
||||
qps_recent: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.identity.name
|
||||
}
|
||||
|
||||
pub fn adapter_kind(&self) -> &str {
|
||||
&self.identity.adapter_kind
|
||||
}
|
||||
|
||||
pub fn features_count(&self) -> usize {
|
||||
self.identity.features_count
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -1,4 +1,98 @@
|
||||
use macros::traceable;
|
||||
use macros::{fallible, traceable};
|
||||
|
||||
fallible! {
|
||||
LoginError {
|
||||
#[no_source]
|
||||
#[error("Account locked, retry after {retry_after_secs}s")]
|
||||
Locked { retry_after_secs: u64 },
|
||||
|
||||
#[no_source]
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[no_source]
|
||||
#[error("Internal authentication error")]
|
||||
InternalError,
|
||||
}
|
||||
}
|
||||
|
||||
fallible! {
|
||||
RegisterError {
|
||||
#[no_source]
|
||||
#[error("Validation failed: {reason}")]
|
||||
Validation { reason: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Invalid role")]
|
||||
InvalidRole,
|
||||
|
||||
#[no_source]
|
||||
#[error("Insufficient permissions")]
|
||||
Forbidden,
|
||||
|
||||
#[no_source]
|
||||
#[error("Password hashing failed")]
|
||||
HashFailed,
|
||||
|
||||
#[error("User already exists: {err}")]
|
||||
Conflict,
|
||||
|
||||
#[error("Internal error: {err}")]
|
||||
Internal,
|
||||
}
|
||||
}
|
||||
|
||||
fallible! {
|
||||
UserError {
|
||||
#[no_source]
|
||||
#[error("Validation failed: {reason}")]
|
||||
Validation { reason: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Current password is incorrect")]
|
||||
Unauthorized,
|
||||
|
||||
#[no_source]
|
||||
#[error("Forbidden: {reason}")]
|
||||
Forbidden { reason: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Not found: {entity}")]
|
||||
NotFound { entity: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Password hashing failed")]
|
||||
HashFailed,
|
||||
|
||||
#[error("Conflict: {err}")]
|
||||
Conflict,
|
||||
|
||||
#[error("Internal error: {err}")]
|
||||
Internal,
|
||||
}
|
||||
}
|
||||
|
||||
fallible! {
|
||||
GroupError {
|
||||
#[no_source]
|
||||
#[error("Validation failed: {reason}")]
|
||||
Validation { reason: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Forbidden: {reason}")]
|
||||
Forbidden { reason: String },
|
||||
|
||||
#[no_source]
|
||||
#[error("Not found: {entity}")]
|
||||
NotFound { entity: String },
|
||||
|
||||
#[error("Conflict: {err}")]
|
||||
Conflict,
|
||||
|
||||
#[error("Internal error: {err}")]
|
||||
Internal,
|
||||
}
|
||||
}
|
||||
|
||||
traceable! {
|
||||
AuthError {
|
||||
|
||||
@ -75,6 +75,10 @@ traceable! {
|
||||
#[error("Invalid operator '{operator}' for SOAR condition type '{condition_type}'")]
|
||||
InvalidConditionOperator { condition_type: String, operator: String } => tracing::Level::WARN,
|
||||
|
||||
#[no_source]
|
||||
#[error("{reason}")]
|
||||
ValidationFailed { reason: String } => tracing::Level::WARN,
|
||||
|
||||
#[no_source]
|
||||
#[error("Rate-limit owner task is unavailable (channel closed)")]
|
||||
RateLimitOwnerUnavailable => tracing::Level::ERROR,
|
||||
|
||||
@ -54,6 +54,9 @@ loggable! {
|
||||
#[error("Failed to recover block for IP {ip} during startup: {error}")]
|
||||
RecoveryFailed { ip: String, error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Block enforcement gap: IP {ip} is persisted in DB but eBPF enforcement failed — block is not active until next successful recovery")]
|
||||
BlockEnforcementGap { ip: String } => tracing::Level::ERROR,
|
||||
|
||||
#[error("Successfully unblocked orphan IP {ip} on retry #{attempt}")]
|
||||
PendingUnblockRecovered { ip: String, attempt: i64 } => tracing::Level::INFO,
|
||||
|
||||
|
||||
@ -4,3 +4,4 @@ pub mod error;
|
||||
pub mod log;
|
||||
pub mod outcome;
|
||||
pub mod playbook;
|
||||
pub mod playbook_validator;
|
||||
|
||||
377
net-guardia/src/domain/response/playbook_validator.rs
Normal file
377
net-guardia/src/domain/response/playbook_validator.rs
Normal file
@ -0,0 +1,377 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::domain::common::event::DetectionSource;
|
||||
use crate::domain::response::condition::{ConditionType, is_valid_ip_pattern, is_valid_operator};
|
||||
use crate::domain::response::error::SoarError;
|
||||
use crate::domain::response::playbook::ActionType;
|
||||
use crate::interface::response::playbook_data::CreateConditionInput;
|
||||
|
||||
const RATE_LIMIT_FACTOR_MIN: f64 = 0.01;
|
||||
const RATE_LIMIT_FACTOR_MAX: f64 = 1.0;
|
||||
|
||||
pub fn validate_optional_positive_i64(field: &str, value: Option<i64>) -> Result<(), SoarError> {
|
||||
if value.is_some_and(|value| value <= 0) {
|
||||
return Err(SoarError::ValidationFailed(format!("{field} must be greater than 0")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_cooldown_secs(cooldown_secs: i64) -> Result<(), SoarError> {
|
||||
if cooldown_secs < 0 {
|
||||
return Err(SoarError::ValidationFailed(
|
||||
"cooldown_secs must be greater than or equal to 0".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_condition_input(condition: &CreateConditionInput) -> Result<(), SoarError> {
|
||||
let condition_type = condition
|
||||
.condition_type
|
||||
.parse::<ConditionType>()
|
||||
.map_err(|_| SoarError::ValidationFailed(format!("unknown condition_type: {}", condition.condition_type)))?;
|
||||
if !is_valid_operator(&condition_type, &condition.operator) {
|
||||
return Err(SoarError::ValidationFailed(format!(
|
||||
"invalid operator '{}' for condition_type '{}'",
|
||||
condition.operator, condition.condition_type
|
||||
)));
|
||||
}
|
||||
validate_condition_value(&condition_type, condition)
|
||||
}
|
||||
|
||||
fn validate_condition_value(condition_type: &ConditionType, condition: &CreateConditionInput) -> Result<(), SoarError> {
|
||||
match condition_type {
|
||||
ConditionType::Threshold | ConditionType::FusedConfidenceAbove => {
|
||||
parse_finite_f64(&condition.value, &condition.condition_type)?;
|
||||
}
|
||||
ConditionType::Frequency | ConditionType::MultiSourceMin => {
|
||||
parse_positive_usize(&condition.value, &condition.condition_type)?;
|
||||
if let Some(value2) = &condition.value2 {
|
||||
parse_positive_u64(value2, "value2")?;
|
||||
}
|
||||
}
|
||||
ConditionType::SourceCountry => {
|
||||
if condition.value.split(',').all(|part| part.trim().is_empty()) {
|
||||
return Err(SoarError::ValidationFailed(
|
||||
"source_country value must contain at least one country code".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
ConditionType::IpPattern => {
|
||||
if !is_valid_ip_pattern(&condition.value) {
|
||||
return Err(SoarError::ValidationFailed(format!(
|
||||
"ip_pattern value must be a valid CIDR: {}",
|
||||
condition.value
|
||||
)));
|
||||
}
|
||||
}
|
||||
ConditionType::RepeatOffender => {
|
||||
parse_bool_literal(&condition.value, &condition.condition_type)?;
|
||||
}
|
||||
ConditionType::SingleSourceHigh => {
|
||||
DetectionSource::from_str(&condition.value).map_err(|_| {
|
||||
SoarError::ValidationFailed(format!(
|
||||
"single_source_high value must be a valid DetectionSource: {}",
|
||||
condition.value
|
||||
))
|
||||
})?;
|
||||
if let Some(value2) = &condition.value2 {
|
||||
parse_finite_f64(value2, "value2")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_action(
|
||||
action_type: &str,
|
||||
params: Option<&serde_json::Value>,
|
||||
max_ttl_secs: u64,
|
||||
) -> Result<(), SoarError> {
|
||||
if let Some(params) = params
|
||||
&& !params.is_object()
|
||||
{
|
||||
return Err(SoarError::ValidationFailed(format!(
|
||||
"action '{}' params must be a JSON object",
|
||||
action_type
|
||||
)));
|
||||
}
|
||||
|
||||
match action_type.parse::<ActionType>() {
|
||||
Ok(ActionType::BlockIp) => {
|
||||
let ttl_secs = params.and_then(|p| p.get("ttl_secs"));
|
||||
validate_optional_positive_u64("ttl_secs", ttl_secs)?;
|
||||
validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)
|
||||
}
|
||||
Ok(ActionType::AdjustRateLimit) => {
|
||||
let ttl_secs = params.and_then(|p| p.get("ttl_secs"));
|
||||
validate_optional_positive_u64("ttl_secs", ttl_secs)?;
|
||||
validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)?;
|
||||
validate_optional_rate_limit_factor(params.and_then(|p| p.get("factor")))
|
||||
}
|
||||
Ok(ActionType::SendTelegram | ActionType::SendEmail) => Ok(()),
|
||||
Ok(ActionType::Webhook) => {
|
||||
validate_required_non_empty_string("url", params.and_then(|p| p.get("url")))?;
|
||||
validate_optional_positive_u64("timeout_secs", params.and_then(|p| p.get("timeout_secs")))
|
||||
}
|
||||
Ok(ActionType::Log) => validate_optional_string("level", params.and_then(|p| p.get("level"))),
|
||||
Err(_) => Err(SoarError::ValidationFailed(format!(
|
||||
"unknown action_type: {}",
|
||||
action_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_finite_f64(value: &str, field: &str) -> Result<f64, SoarError> {
|
||||
match value.parse::<f64>() {
|
||||
Ok(parsed) if parsed.is_finite() => Ok(parsed),
|
||||
_ => Err(SoarError::ValidationFailed(format!(
|
||||
"{field} value must be a finite number"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_usize(value: &str, field: &str) -> Result<usize, SoarError> {
|
||||
match value.parse::<usize>() {
|
||||
Ok(parsed) if parsed > 0 => Ok(parsed),
|
||||
_ => Err(SoarError::ValidationFailed(format!(
|
||||
"{field} value must be a positive integer"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_u64(value: &str, field: &str) -> Result<u64, SoarError> {
|
||||
match value.parse::<u64>() {
|
||||
Ok(parsed) if parsed > 0 => Ok(parsed),
|
||||
_ => Err(SoarError::ValidationFailed(format!(
|
||||
"{field} must be a positive integer"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool_literal(value: &str, field: &str) -> Result<bool, SoarError> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(SoarError::ValidationFailed(format!(
|
||||
"{field} value must be 'true' or 'false'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_positive_u64(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
match value.as_u64() {
|
||||
Some(value) if value > 0 => Ok(()),
|
||||
Some(_) => Err(SoarError::ValidationFailed(format!("{field} must be greater than 0"))),
|
||||
None => Err(SoarError::ValidationFailed(format!(
|
||||
"{field} must be a positive integer"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_max_u64(field: &str, value: Option<&serde_json::Value>, max: u64) -> Result<(), SoarError> {
|
||||
let Some(value) = value.and_then(|value| value.as_u64()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if value > max {
|
||||
return Err(SoarError::ValidationFailed(format!(
|
||||
"{field} must be less than or equal to {max}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_optional_rate_limit_factor(value: Option<&serde_json::Value>) -> Result<(), SoarError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
match value.as_f64() {
|
||||
Some(value) if (RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&value) => Ok(()),
|
||||
Some(_) => Err(SoarError::ValidationFailed(format!(
|
||||
"factor must be between {RATE_LIMIT_FACTOR_MIN} and {RATE_LIMIT_FACTOR_MAX}"
|
||||
))),
|
||||
None => Err(SoarError::ValidationFailed("factor must be a number".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required_non_empty_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> {
|
||||
match value.and_then(|value| value.as_str()) {
|
||||
Some(value) if !value.trim().is_empty() => Ok(()),
|
||||
Some(_) => Err(SoarError::ValidationFailed(format!("{field} must not be empty"))),
|
||||
None => Err(SoarError::ValidationFailed(format!("{field} is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
if value.is_string() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SoarError::ValidationFailed(format!("{field} must be a string")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_cooldown() {
|
||||
let err = validate_cooldown_secs(-1).unwrap_err();
|
||||
assert_eq!(err.to_string(), "cooldown_secs must be greater than or equal to 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_zero_cooldown() {
|
||||
validate_cooldown_secs(0).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_condition_count() {
|
||||
let err = validate_optional_positive_i64("condition_count", Some(0)).unwrap_err();
|
||||
assert_eq!(err.to_string(), "condition_count must be greater than 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_none_condition_count() {
|
||||
validate_optional_positive_i64("condition_count", None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_action_type() {
|
||||
let err = validate_action("typo", None, 3_600).unwrap_err();
|
||||
assert_eq!(err.to_string(), "unknown action_type: typo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_block_ip_zero_ttl() {
|
||||
let params = serde_json::json!({"ttl_secs": 0});
|
||||
let err = validate_action("block_ip", Some(¶ms), 3_600).unwrap_err();
|
||||
assert_eq!(err.to_string(), "ttl_secs must be greater than 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_block_ip_exceeding_max_ttl() {
|
||||
let params = serde_json::json!({"ttl_secs": 3_601});
|
||||
let err = validate_action("block_ip", Some(¶ms), 3_600).unwrap_err();
|
||||
assert_eq!(err.to_string(), "ttl_secs must be less than or equal to 3600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_webhook_missing_url() {
|
||||
let params = serde_json::json!({"timeout_secs": 5});
|
||||
let err = validate_action("webhook", Some(¶ms), 3_600).unwrap_err();
|
||||
assert_eq!(err.to_string(), "url is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_webhook_zero_timeout() {
|
||||
let params = serde_json::json!({"url": "https://example.test/hook", "timeout_secs": 0});
|
||||
let err = validate_action("webhook", Some(¶ms), 3_600).unwrap_err();
|
||||
assert_eq!(err.to_string(), "timeout_secs must be greater than 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_condition_type() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "typo".to_string(),
|
||||
operator: ">=".to_string(),
|
||||
value: "0.9".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("typo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_condition_operator() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "threshold".to_string(),
|
||||
operator: "in".to_string(),
|
||||
value: "0.9".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("in"));
|
||||
assert!(err.to_string().contains("threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_threshold_value() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "threshold".to_string(),
|
||||
operator: ">=".to_string(),
|
||||
value: "not-a-number".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert_eq!(err.to_string(), "threshold value must be a finite number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_frequency_value() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "frequency".to_string(),
|
||||
operator: ">=".to_string(),
|
||||
value: "0".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert_eq!(err.to_string(), "frequency value must be a positive integer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_ip_pattern() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "ip_pattern".to_string(),
|
||||
operator: "in".to_string(),
|
||||
value: "not-cidr".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert_eq!(err.to_string(), "ip_pattern value must be a valid CIDR: not-cidr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_repeat_offender_value() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "repeat_offender".to_string(),
|
||||
operator: "==".to_string(),
|
||||
value: "maybe".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert_eq!(err.to_string(), "repeat_offender value must be 'true' or 'false'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_false_repeat_offender() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "repeat_offender".to_string(),
|
||||
operator: "==".to_string(),
|
||||
value: "false".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
validate_condition_input(&input).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_single_source_high_value() {
|
||||
let input = CreateConditionInput {
|
||||
condition_type: "single_source_high".to_string(),
|
||||
operator: ">=".to_string(),
|
||||
value: "UnknownSource".to_string(),
|
||||
value2: None,
|
||||
};
|
||||
let err = validate_condition_input(&input).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"single_source_high value must be a valid DetectionSource: UnknownSource"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user