feat(primitives): 2 Rust verification cubes
- ssh-check — parse sshd_config + drop-ins, merge last-wins, lint against hardened baseline (pw-auth=no, root=prohibit-password, maxauthtries≤3, AllowUsers whitelist, no CBC ciphers, ETM MACs, no ssh-rsa host key). 4 modules: main (clap CLI) + parse + rules + check. Tests: 9 pass (hardened baseline, password-auth-yes-fails, cbc-cipher-fails, allow-users-not-in-whitelist-fails, missing-required-fails, etc.). - firewall-diff — diff intent YAML against `ufw status numbered` output. Defensive-only (never runs ufw). Stdin or --status-file input. Parses (v6) families, normalises "Anywhere"→"any". Exit 2 on any missing/ extra rule. 4 modules: main + intent + ufw + diff. Tests: 8 pass (load-minimal-intent, exact-match-clean, missing-rule-surfaced, extra-live-rule-surfaced, inactive-ufw-fails, integration). Workspace: clap 4 + serde + serde_yaml + serde_json. release opt-level=z, LTO, strip. Constructor Pattern: largest file check.rs 213 LOC (93 non- test); every function under 30 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
969e24c6c4
commit
521659bbfb
13 changed files with 1811 additions and 0 deletions
1
_primitives/_rust/.gitignore
vendored
Normal file
1
_primitives/_rust/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
608
_primitives/_rust/Cargo.lock
generated
Normal file
608
_primitives/_rust/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "firewall-diff"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-check"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
19
_primitives/_rust/Cargo.toml
Normal file
19
_primitives/_rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["ssh-check", "firewall-diff"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
18
_primitives/_rust/firewall-diff/Cargo.toml
Normal file
18
_primitives/_rust/firewall-diff/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "firewall-diff"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-diff"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
193
_primitives/_rust/firewall-diff/src/diff.rs
Normal file
193
_primitives/_rust/firewall-diff/src/diff.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Compare Intent × Live and emit a structured report.
|
||||
|
||||
use crate::intent::{Action, Intent, Rule};
|
||||
use crate::ufw::{Live, LiveRule};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub active_ok: bool,
|
||||
pub default_mismatches: Vec<String>,
|
||||
pub missing: Vec<Rule>, // in intent, not in live
|
||||
pub extra: Vec<LiveRule>, // in live, not in intent
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.active_ok
|
||||
&& self.default_mismatches.is_empty()
|
||||
&& self.missing.is_empty()
|
||||
&& self.extra.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compare(intent: &Intent, live: &Live) -> Report {
|
||||
let active_ok = live.active;
|
||||
|
||||
let mut default_mismatches = Vec::new();
|
||||
if !matches!(intent.default.incoming, Action::Deny | Action::Reject) {
|
||||
default_mismatches
|
||||
.push("intent.default.incoming must be deny/reject for production".to_string());
|
||||
}
|
||||
|
||||
// Build key sets.
|
||||
let intent_keys: HashSet<String> = intent.rules.iter().map(Rule::key).collect();
|
||||
let live_keys: HashSet<String> = live.rules.iter().map(LiveRule::key).collect();
|
||||
|
||||
let missing: Vec<Rule> = intent
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|r| !live_keys.contains(&r.key()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let extra: Vec<LiveRule> = live
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|r| !intent_keys.contains(&r.key()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Report {
|
||||
active_ok,
|
||||
default_mismatches,
|
||||
missing,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_human(r: &Report) {
|
||||
if !r.active_ok {
|
||||
println!("[FAIL] ufw is not active.");
|
||||
}
|
||||
for m in &r.default_mismatches {
|
||||
println!("[WARN] default: {m}");
|
||||
}
|
||||
for m in &r.missing {
|
||||
println!(
|
||||
"[MISS] intent rule not live: {}/{} from={} action={:?}",
|
||||
m.port, m.proto, m.from, m.action
|
||||
);
|
||||
}
|
||||
for e in &r.extra {
|
||||
println!(
|
||||
"[EXTRA] live rule not in intent: {}/{} from={} action={:?} family={:?}",
|
||||
e.port, e.proto, e.from, e.action, e.family
|
||||
);
|
||||
}
|
||||
if r.is_clean() {
|
||||
println!("firewall-diff: OK — intent ≡ live.");
|
||||
} else {
|
||||
println!(
|
||||
"firewall-diff: {} missing, {} extra, default-issues={}",
|
||||
r.missing.len(),
|
||||
r.extra.len(),
|
||||
r.default_mismatches.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::intent::{Action, Defaults, Intent, Rule};
|
||||
use crate::ufw::{self, Family, Live, LiveRule};
|
||||
|
||||
fn intent_fx() -> Intent {
|
||||
Intent {
|
||||
default: Defaults {
|
||||
incoming: Action::Deny,
|
||||
outgoing: Action::Allow,
|
||||
routed: Action::Deny,
|
||||
},
|
||||
rules: vec![
|
||||
Rule {
|
||||
port: 22,
|
||||
proto: "tcp".into(),
|
||||
action: Action::Limit,
|
||||
from: "any".into(),
|
||||
comment: "ssh".into(),
|
||||
},
|
||||
Rule {
|
||||
port: 443,
|
||||
proto: "tcp".into(),
|
||||
action: Action::Allow,
|
||||
from: "any".into(),
|
||||
comment: "".into(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn live_fx(items: &[(u16, &str, Action, &str)]) -> Live {
|
||||
Live {
|
||||
active: true,
|
||||
rules: items
|
||||
.iter()
|
||||
.map(|(p, pr, a, f)| LiveRule {
|
||||
port: *p,
|
||||
proto: (*pr).into(),
|
||||
action: a.clone(),
|
||||
from: (*f).into(),
|
||||
family: Family::V4,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_is_clean() {
|
||||
let i = intent_fx();
|
||||
let l = live_fx(&[
|
||||
(22, "tcp", Action::Limit, "any"),
|
||||
(443, "tcp", Action::Allow, "any"),
|
||||
]);
|
||||
let r = compare(&i, &l);
|
||||
assert!(r.is_clean(), "{:#?}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rule_surfaced() {
|
||||
let i = intent_fx();
|
||||
let l = live_fx(&[(22, "tcp", Action::Limit, "any")]);
|
||||
let r = compare(&i, &l);
|
||||
assert_eq!(r.missing.len(), 1);
|
||||
assert_eq!(r.missing[0].port, 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_live_rule_surfaced() {
|
||||
let i = intent_fx();
|
||||
let l = live_fx(&[
|
||||
(22, "tcp", Action::Limit, "any"),
|
||||
(443, "tcp", Action::Allow, "any"),
|
||||
(8080, "tcp", Action::Allow, "any"),
|
||||
]);
|
||||
let r = compare(&i, &l);
|
||||
assert_eq!(r.extra.len(), 1);
|
||||
assert_eq!(r.extra[0].port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_ufw_fails() {
|
||||
let i = intent_fx();
|
||||
let l = Live {
|
||||
active: false,
|
||||
rules: vec![],
|
||||
};
|
||||
let r = compare(&i, &l);
|
||||
assert!(!r.is_clean());
|
||||
assert!(!r.active_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_parse_then_diff() {
|
||||
// Mimic real `ufw status numbered` column padding (double-space gaps).
|
||||
let text = "Status: active\n\n\
|
||||
[ 1] 22/tcp LIMIT IN Anywhere\n\
|
||||
[ 2] 443/tcp ALLOW IN Anywhere\n";
|
||||
let live = ufw::parse(text).unwrap();
|
||||
let r = compare(&intent_fx(), &live);
|
||||
assert!(r.is_clean(), "{:#?}", r);
|
||||
}
|
||||
}
|
||||
111
_primitives/_rust/firewall-diff/src/intent.rs
Normal file
111
_primitives/_rust/firewall-diff/src/intent.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Intent YAML schema + loader. See `_blocks/security-firewall-ufw.md` for
|
||||
//! the reference format. Anything missing is treated as "don't care".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Action {
|
||||
Allow,
|
||||
Deny,
|
||||
Limit,
|
||||
Reject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Defaults {
|
||||
#[serde(default = "default_deny")]
|
||||
pub incoming: Action,
|
||||
#[serde(default = "default_allow")]
|
||||
pub outgoing: Action,
|
||||
#[serde(default = "default_deny")]
|
||||
pub routed: Action,
|
||||
}
|
||||
fn default_deny() -> Action {
|
||||
Action::Deny
|
||||
}
|
||||
fn default_allow() -> Action {
|
||||
Action::Allow
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Rule {
|
||||
pub port: u16,
|
||||
#[serde(default = "default_proto")]
|
||||
pub proto: String,
|
||||
pub action: Action,
|
||||
#[serde(default = "default_from")]
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
}
|
||||
fn default_proto() -> String {
|
||||
"tcp".into()
|
||||
}
|
||||
fn default_from() -> String {
|
||||
"any".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Intent {
|
||||
pub default: Defaults,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Intent, String> {
|
||||
let body = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
serde_yaml::from_str(&body).map_err(|e| format!("yaml: {e}"))
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// Canonical key used to match against a live rule: port/proto/from/action.
|
||||
pub fn key(&self) -> String {
|
||||
format!(
|
||||
"{}/{}::{}::{}",
|
||||
self.port,
|
||||
self.proto.to_ascii_lowercase(),
|
||||
self.from.to_ascii_lowercase(),
|
||||
format!("{:?}", self.action).to_ascii_lowercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn load_minimal_intent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("intent.yaml");
|
||||
let mut f = fs::File::create(&p).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"default:
|
||||
incoming: deny
|
||||
outgoing: allow
|
||||
routed: deny
|
||||
rules:
|
||||
- port: 22
|
||||
proto: tcp
|
||||
action: limit
|
||||
from: any
|
||||
comment: "ssh"
|
||||
- port: 443
|
||||
proto: tcp
|
||||
action: allow
|
||||
from: any
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
let i = load(&p).unwrap();
|
||||
assert_eq!(i.default.incoming, Action::Deny);
|
||||
assert_eq!(i.rules.len(), 2);
|
||||
assert_eq!(i.rules[0].action, Action::Limit);
|
||||
assert_eq!(i.rules[1].port, 443);
|
||||
}
|
||||
}
|
||||
101
_primitives/_rust/firewall-diff/src/main.rs
Normal file
101
_primitives/_rust/firewall-diff/src/main.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! firewall-diff — compare an intended ufw rule set (YAML) against the
|
||||
//! running firewall (parsed from `ufw status numbered` output).
|
||||
//!
|
||||
//! USAGE
|
||||
//! firewall-diff --intent firewall-intent.yaml --status-file live.txt
|
||||
//! ufw status numbered | firewall-diff --intent firewall-intent.yaml --stdin
|
||||
//! firewall-diff --intent firewall-intent.yaml --json
|
||||
//!
|
||||
//! The tool does NOT execute `ufw` itself (defensive-only). Feed it the
|
||||
//! output of `ufw status numbered` or have the skill pipe it in.
|
||||
//!
|
||||
//! EXIT
|
||||
//! 0 intent ≡ live (no diff)
|
||||
//! 1 usage / parse error
|
||||
//! 2 differences found (live deviates from intent)
|
||||
|
||||
mod diff;
|
||||
mod intent;
|
||||
mod ufw;
|
||||
|
||||
use clap::Parser;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "firewall-diff", about = "Diff intended ufw rules (YAML) vs live status.")]
|
||||
struct Cli {
|
||||
/// Path to the intent YAML file.
|
||||
#[arg(long)]
|
||||
intent: PathBuf,
|
||||
|
||||
/// Path to a file holding captured `ufw status numbered` output.
|
||||
#[arg(long, conflicts_with = "stdin")]
|
||||
status_file: Option<PathBuf>,
|
||||
|
||||
/// Read the ufw status text from stdin (use when piping from the host).
|
||||
#[arg(long)]
|
||||
stdin: bool,
|
||||
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let intent = match intent::load(&cli.intent) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("firewall-diff: intent: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let status_txt = match (&cli.status_file, cli.stdin) {
|
||||
(Some(p), false) => match fs::read_to_string(p) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("firewall-diff: read {}: {e}", p.display());
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
},
|
||||
(None, true) => {
|
||||
let mut s = String::new();
|
||||
if let Err(e) = io::stdin().read_to_string(&mut s) {
|
||||
eprintln!("firewall-diff: stdin: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
eprintln!("firewall-diff: need --status-file <path> or --stdin");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let live = match ufw::parse(&status_txt) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("firewall-diff: parse ufw status: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let report = diff::compare(&intent, &live);
|
||||
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default());
|
||||
} else {
|
||||
diff::render_human(&report);
|
||||
}
|
||||
|
||||
if report.is_clean() {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
173
_primitives/_rust/firewall-diff/src/ufw.rs
Normal file
173
_primitives/_rust/firewall-diff/src/ufw.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Parse `ufw status numbered` output.
|
||||
//!
|
||||
//! Typical shape (Ubuntu 22.04, ufw 0.36):
|
||||
//!
|
||||
//! Status: active
|
||||
//!
|
||||
//! To Action From
|
||||
//! -- ------ ----
|
||||
//! [ 1] 22/tcp LIMIT IN Anywhere
|
||||
//! [ 2] 443/tcp ALLOW IN Anywhere
|
||||
//! [ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
|
||||
//!
|
||||
//! We normalise "(v6)" to a separate family tag but key rules on port/proto
|
||||
//! only (v6 and v4 rules with the same port/proto are treated as duplicates
|
||||
//! of intent, which is usually the desired behaviour for parity checks).
|
||||
|
||||
use crate::intent::Action;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct LiveRule {
|
||||
pub port: u16,
|
||||
pub proto: String,
|
||||
pub action: Action,
|
||||
pub from: String,
|
||||
pub family: Family,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub enum Family {
|
||||
V4,
|
||||
V6,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Live {
|
||||
pub active: bool,
|
||||
pub rules: Vec<LiveRule>,
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Live, String> {
|
||||
let mut active = false;
|
||||
let mut rules = Vec::new();
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("Status:") {
|
||||
active = rest.trim().eq_ignore_ascii_case("active");
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("To") || line.starts_with("--") {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = parse_rule(line) {
|
||||
rules.push(r);
|
||||
}
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Err("could not detect an `ufw status` block (empty input)".into());
|
||||
}
|
||||
Ok(Live { active, rules })
|
||||
}
|
||||
|
||||
/// Parse one numbered rule line. Returns None if the line is not a rule.
|
||||
fn parse_rule(line: &str) -> Option<LiveRule> {
|
||||
// Strip leading "[ N]" if present.
|
||||
let body = if let Some(idx) = line.find(']') {
|
||||
line[idx + 1..].trim()
|
||||
} else {
|
||||
line
|
||||
};
|
||||
// Columns: <to> <ACTION IN|OUT|FWD> <from>
|
||||
// We split on 2+ whitespace runs which ufw pads with.
|
||||
let parts: Vec<&str> = body.split(" ").filter(|s| !s.is_empty()).map(str::trim).collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let to = parts[0];
|
||||
let action_raw = parts[1];
|
||||
let from = parts[2];
|
||||
|
||||
let (to_clean, family) = if to.contains("(v6)") {
|
||||
(to.replace("(v6)", "").trim().to_string(), Family::V6)
|
||||
} else {
|
||||
(to.to_string(), Family::V4)
|
||||
};
|
||||
|
||||
let (port, proto) = split_port_proto(&to_clean)?;
|
||||
let action = parse_action(action_raw)?;
|
||||
Some(LiveRule {
|
||||
port,
|
||||
proto,
|
||||
action,
|
||||
from: from.replace("(v6)", "").trim().to_string(),
|
||||
family,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_port_proto(tok: &str) -> Option<(u16, String)> {
|
||||
// "22/tcp" | "53" | "443/udp"
|
||||
if let Some((port_s, proto_s)) = tok.split_once('/') {
|
||||
Some((port_s.parse().ok()?, proto_s.to_ascii_lowercase()))
|
||||
} else {
|
||||
Some((tok.parse().ok()?, "tcp".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_action(raw: &str) -> Option<Action> {
|
||||
let up = raw.to_ascii_uppercase();
|
||||
if up.starts_with("ALLOW") {
|
||||
Some(Action::Allow)
|
||||
} else if up.starts_with("DENY") {
|
||||
Some(Action::Deny)
|
||||
} else if up.starts_with("LIMIT") {
|
||||
Some(Action::Limit)
|
||||
} else if up.starts_with("REJECT") {
|
||||
Some(Action::Reject)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveRule {
|
||||
pub fn key(&self) -> String {
|
||||
let from = if self.from.eq_ignore_ascii_case("Anywhere") {
|
||||
"any"
|
||||
} else {
|
||||
&self.from
|
||||
};
|
||||
format!(
|
||||
"{}/{}::{}::{}",
|
||||
self.port,
|
||||
self.proto,
|
||||
from.to_ascii_lowercase(),
|
||||
format!("{:?}", self.action).to_ascii_lowercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
Status: active
|
||||
|
||||
To Action From
|
||||
-- ------ ----
|
||||
[ 1] 22/tcp LIMIT IN Anywhere
|
||||
[ 2] 443/tcp ALLOW IN Anywhere
|
||||
[ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_active_and_rules() {
|
||||
let l = parse(SAMPLE).unwrap();
|
||||
assert!(l.active);
|
||||
assert_eq!(l.rules.len(), 3);
|
||||
assert_eq!(l.rules[0].port, 22);
|
||||
assert_eq!(l.rules[0].proto, "tcp");
|
||||
assert_eq!(l.rules[0].action, Action::Limit);
|
||||
assert_eq!(l.rules[2].family, Family::V6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_status_rejects_only_if_no_rules() {
|
||||
let l = parse("Status: inactive\n").unwrap();
|
||||
assert!(!l.active);
|
||||
assert!(l.rules.is_empty());
|
||||
}
|
||||
}
|
||||
17
_primitives/_rust/ssh-check/Cargo.toml
Normal file
17
_primitives/_rust/ssh-check/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "ssh-check"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ssh-check"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
213
_primitives/_rust/ssh-check/src/check.rs
Normal file
213
_primitives/_rust/ssh-check/src/check.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! Evaluate the hardened rule matrix against a merged sshd_config view.
|
||||
|
||||
use crate::parse::Merged;
|
||||
use crate::rules::{Expect, Rule};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum Severity {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Severity::Ok => "OK",
|
||||
Severity::Warn => "WARN",
|
||||
Severity::Fail => "FAIL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Finding {
|
||||
pub directive: String,
|
||||
pub severity: Severity,
|
||||
pub source: String,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
pub fn evaluate(merged: &Merged, rules: &[Rule]) -> Vec<Finding> {
|
||||
let mut out = Vec::with_capacity(rules.len());
|
||||
for r in rules {
|
||||
out.push(eval_rule(merged, r));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn eval_rule(merged: &Merged, r: &Rule) -> Finding {
|
||||
let occ = merged.effective.get(r.directive);
|
||||
match (occ, r.required) {
|
||||
(None, true) => Finding {
|
||||
directive: r.directive.into(),
|
||||
severity: Severity::Fail,
|
||||
source: "(missing)".into(),
|
||||
note: format!("required directive absent — {}", r.rationale),
|
||||
},
|
||||
(None, false) => Finding {
|
||||
directive: r.directive.into(),
|
||||
severity: Severity::Warn,
|
||||
source: "(missing)".into(),
|
||||
note: format!("recommended: {}", r.rationale),
|
||||
},
|
||||
(Some(o), _) => {
|
||||
let ok = value_matches(&o.value, &r.expect);
|
||||
Finding {
|
||||
directive: r.directive.into(),
|
||||
severity: if ok { Severity::Ok } else { Severity::Fail },
|
||||
source: o.source.clone(),
|
||||
note: if ok {
|
||||
"ok".into()
|
||||
} else {
|
||||
format!("value '{}' violates policy — {}", o.value, r.rationale)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn value_matches(value: &str, expect: &Expect) -> bool {
|
||||
let v = value.trim().to_ascii_lowercase();
|
||||
match expect {
|
||||
Expect::Equals(target) => v == target.to_ascii_lowercase(),
|
||||
Expect::OneOf(list) => list.iter().any(|s| v == s.to_ascii_lowercase()),
|
||||
Expect::MaxInt(max) => v.parse::<u32>().map(|n| n <= *max).unwrap_or(false),
|
||||
Expect::ContainsAll(tokens) => tokens.iter().all(|t| v.contains(&t.to_ascii_lowercase())),
|
||||
Expect::DeniesAny(tokens) => {
|
||||
let parts: Vec<&str> = v.split(',').map(str::trim).collect();
|
||||
!tokens
|
||||
.iter()
|
||||
.any(|t| parts.iter().any(|p| p == &t.to_ascii_lowercase()))
|
||||
}
|
||||
Expect::AllowedUsersSubset(allow) => {
|
||||
let parts: Vec<String> = v
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
!parts.is_empty() && parts.iter().all(|u| allow.contains(u))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parse::{Merged, Occurrence};
|
||||
use crate::rules::hardened_matrix;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn merged(pairs: &[(&str, &str)]) -> Merged {
|
||||
let mut m = Merged {
|
||||
effective: BTreeMap::new(),
|
||||
all: BTreeMap::new(),
|
||||
};
|
||||
for (k, v) in pairs {
|
||||
let occ = Occurrence {
|
||||
value: (*v).to_string(),
|
||||
source: "test:1".into(),
|
||||
};
|
||||
m.effective.insert((*k).to_string(), occ.clone());
|
||||
m.all.insert((*k).to_string(), vec![occ]);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardened_baseline_passes() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("passwordauthentication", "no"),
|
||||
("permitrootlogin", "prohibit-password"),
|
||||
("maxauthtries", "3"),
|
||||
("allowusers", "keiadmin"),
|
||||
("ciphers", "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com"),
|
||||
("macs", "hmac-sha2-512-etm@openssh.com"),
|
||||
("hostkeyalgorithms", "ssh-ed25519,rsa-sha2-512"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let fails: Vec<_> = findings.iter().filter(|f| f.severity == Severity::Fail).collect();
|
||||
assert!(fails.is_empty(), "unexpected fails: {fails:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_auth_yes_fails() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("passwordauthentication", "yes"),
|
||||
("permitrootlogin", "no"),
|
||||
("maxauthtries", "3"),
|
||||
("allowusers", "keiadmin"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let f = findings
|
||||
.iter()
|
||||
.find(|f| f.directive == "passwordauthentication")
|
||||
.unwrap();
|
||||
assert_eq!(f.severity, Severity::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_cipher_fails() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("passwordauthentication", "no"),
|
||||
("permitrootlogin", "no"),
|
||||
("maxauthtries", "3"),
|
||||
("allowusers", "keiadmin"),
|
||||
("ciphers", "aes256-cbc,chacha20-poly1305@openssh.com"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let f = findings.iter().find(|f| f.directive == "ciphers").unwrap();
|
||||
assert_eq!(f.severity, Severity::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_users_not_in_whitelist_fails() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("passwordauthentication", "no"),
|
||||
("permitrootlogin", "no"),
|
||||
("maxauthtries", "3"),
|
||||
("allowusers", "root attacker"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let f = findings.iter().find(|f| f.directive == "allowusers").unwrap();
|
||||
assert_eq!(f.severity, Severity::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_directive_fails() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("permitrootlogin", "no"),
|
||||
("maxauthtries", "3"),
|
||||
("allowusers", "keiadmin"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let f = findings
|
||||
.iter()
|
||||
.find(|f| f.directive == "passwordauthentication")
|
||||
.unwrap();
|
||||
assert_eq!(f.severity, Severity::Fail);
|
||||
assert_eq!(f.source, "(missing)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maxauthtries_too_high_fails() {
|
||||
let rules = hardened_matrix(&["keiadmin".into()]);
|
||||
let mg = merged(&[
|
||||
("passwordauthentication", "no"),
|
||||
("permitrootlogin", "no"),
|
||||
("maxauthtries", "10"),
|
||||
("allowusers", "keiadmin"),
|
||||
]);
|
||||
let findings = evaluate(&mg, &rules);
|
||||
let f = findings
|
||||
.iter()
|
||||
.find(|f| f.directive == "maxauthtries")
|
||||
.unwrap();
|
||||
assert_eq!(f.severity, Severity::Fail);
|
||||
}
|
||||
}
|
||||
102
_primitives/_rust/ssh-check/src/main.rs
Normal file
102
_primitives/_rust/ssh-check/src/main.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! ssh-check — pre-deploy sshd_config linter for KeiSeiKit.
|
||||
//!
|
||||
//! Reads /etc/ssh/sshd_config + every /etc/ssh/sshd_config.d/*.conf (or
|
||||
//! user-supplied paths), merges directives via last-wins precedence, and
|
||||
//! reports violations of the hardened-baseline rule matrix.
|
||||
//!
|
||||
//! USAGE
|
||||
//! ssh-check # default system paths
|
||||
//! ssh-check --config /etc/ssh/sshd_config --drop-in /etc/ssh/sshd_config.d
|
||||
//! ssh-check --json # JSON output for CI
|
||||
//! ssh-check --allow-user admin # extra allowed user
|
||||
//!
|
||||
//! EXIT
|
||||
//! 0 no violations
|
||||
//! 1 usage / parse error
|
||||
//! 2 violations found
|
||||
|
||||
mod check;
|
||||
mod parse;
|
||||
mod rules;
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "ssh-check",
|
||||
about = "Lint sshd_config + drop-ins against the KeiSeiKit hardened baseline."
|
||||
)]
|
||||
struct Cli {
|
||||
/// Main sshd_config file.
|
||||
#[arg(long, default_value = "/etc/ssh/sshd_config")]
|
||||
config: PathBuf,
|
||||
|
||||
/// Drop-in directory (sshd_config.d). Pass empty string to skip.
|
||||
#[arg(long, default_value = "/etc/ssh/sshd_config.d")]
|
||||
drop_in: PathBuf,
|
||||
|
||||
/// Usernames that are acceptable in AllowUsers (repeatable).
|
||||
#[arg(long = "allow-user")]
|
||||
allow_user: Vec<String>,
|
||||
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let merged = match parse::load_merged(&cli.config, &cli.drop_in) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("ssh-check: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let allow_users: Vec<String> = if cli.allow_user.is_empty() {
|
||||
vec!["keiadmin".into()]
|
||||
} else {
|
||||
cli.allow_user
|
||||
};
|
||||
let matrix = rules::hardened_matrix(&allow_users);
|
||||
let findings = check::evaluate(&merged, &matrix);
|
||||
|
||||
if cli.json {
|
||||
let out = serde_json::to_string_pretty(&findings).unwrap_or_default();
|
||||
println!("{out}");
|
||||
} else {
|
||||
render_human(&findings);
|
||||
}
|
||||
|
||||
if findings.iter().any(|f| f.severity != check::Severity::Ok) {
|
||||
ExitCode::from(2)
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fn render_human(findings: &[check::Finding]) {
|
||||
let mut bad = 0usize;
|
||||
for f in findings {
|
||||
if f.severity == check::Severity::Ok {
|
||||
continue;
|
||||
}
|
||||
bad += 1;
|
||||
println!(
|
||||
"[{sev:<5}] {directive:<28} {source} ({note})",
|
||||
sev = f.severity.label(),
|
||||
directive = f.directive,
|
||||
source = f.source,
|
||||
note = f.note
|
||||
);
|
||||
}
|
||||
if bad == 0 {
|
||||
println!("ssh-check: OK — hardened baseline satisfied.");
|
||||
} else {
|
||||
println!("ssh-check: {bad} violation(s).");
|
||||
}
|
||||
}
|
||||
127
_primitives/_rust/ssh-check/src/parse.rs
Normal file
127
_primitives/_rust/ssh-check/src/parse.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//! sshd_config parser — read main file + drop-ins, merge with last-wins
|
||||
//! precedence per OpenSSH rules (main file first, then drop-ins in
|
||||
//! filename-sort order; first occurrence of a directive wins in sshd,
|
||||
//! BUT we surface ALL occurrences to report duplicates).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A single directive occurrence (name, value, source path, line number).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Occurrence {
|
||||
pub value: String,
|
||||
pub source: String, // "<file>:<line>"
|
||||
}
|
||||
|
||||
/// Merged view: directive name (lowercased) → first-occurrence value +
|
||||
/// every occurrence for duplicate detection.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Merged {
|
||||
pub effective: BTreeMap<String, Occurrence>,
|
||||
pub all: BTreeMap<String, Vec<Occurrence>>,
|
||||
}
|
||||
|
||||
pub fn load_merged(main: &Path, drop_in: &Path) -> Result<Merged, String> {
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
if main.exists() {
|
||||
files.push(main.to_path_buf());
|
||||
} else {
|
||||
return Err(format!("main config not found: {}", main.display()));
|
||||
}
|
||||
// Drop-in dir is optional; pass empty path to skip.
|
||||
if !drop_in.as_os_str().is_empty() && drop_in.is_dir() {
|
||||
let mut dropins: Vec<PathBuf> = fs::read_dir(drop_in)
|
||||
.map_err(|e| format!("read {}: {e}", drop_in.display()))?
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|s| s == "conf").unwrap_or(false))
|
||||
.collect();
|
||||
dropins.sort();
|
||||
files.extend(dropins);
|
||||
}
|
||||
|
||||
let mut merged = Merged::default();
|
||||
for path in files {
|
||||
let body =
|
||||
fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
for (lineno, raw) in body.lines().enumerate() {
|
||||
if let Some((k, v)) = parse_line(raw) {
|
||||
let occ = Occurrence {
|
||||
value: v,
|
||||
source: format!("{}:{}", path.display(), lineno + 1),
|
||||
};
|
||||
merged
|
||||
.all
|
||||
.entry(k.clone())
|
||||
.or_default()
|
||||
.push(occ.clone());
|
||||
// First occurrence wins in OpenSSH — do NOT overwrite.
|
||||
merged.effective.entry(k).or_insert(occ);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Parse one config line. Returns (lowercased_directive, raw_value) or None
|
||||
/// for comments / blanks / Include (we don't recurse includes by design —
|
||||
/// the skill wires explicit paths).
|
||||
fn parse_line(raw: &str) -> Option<(String, String)> {
|
||||
let stripped = raw.split('#').next().unwrap_or("").trim();
|
||||
if stripped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut parts = stripped.splitn(2, char::is_whitespace);
|
||||
let name = parts.next()?.trim().to_ascii_lowercase();
|
||||
let value = parts.next().unwrap_or("").trim().to_string();
|
||||
if name == "include" || name == "match" {
|
||||
return None;
|
||||
}
|
||||
Some((name, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
let p = dir.join(name);
|
||||
fs::write(&p, body).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_directives_and_ignores_comments() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let main = write(dir.path(), "sshd_config", "# header\nPort 22\nPasswordAuthentication no\n");
|
||||
let m = load_merged(&main, Path::new("")).unwrap();
|
||||
assert_eq!(m.effective["port"].value, "22");
|
||||
assert_eq!(m.effective["passwordauthentication"].value, "no");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_in_does_not_override_main_effective_value() {
|
||||
// OpenSSH: first occurrence wins. Main is read first.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let main = write(dir.path(), "sshd_config", "Port 22\n");
|
||||
let d = dir.path().join("sshd_config.d");
|
||||
fs::create_dir(&d).unwrap();
|
||||
write(&d, "99-kei.conf", "Port 2222\n");
|
||||
let m = load_merged(&main, &d).unwrap();
|
||||
assert_eq!(m.effective["port"].value, "22");
|
||||
assert_eq!(m.all["port"].len(), 2, "both occurrences recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_and_match_are_skipped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let main = write(
|
||||
dir.path(),
|
||||
"sshd_config",
|
||||
"Include /etc/ssh/foo.d/*.conf\nMatch User root\n\tPasswordAuthentication yes\n",
|
||||
);
|
||||
let m = load_merged(&main, Path::new("")).unwrap();
|
||||
assert!(!m.effective.contains_key("include"));
|
||||
assert!(!m.effective.contains_key("match"));
|
||||
}
|
||||
}
|
||||
128
_primitives/_rust/ssh-check/src/rules.rs
Normal file
128
_primitives/_rust/ssh-check/src/rules.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Hardened SSH baseline — rule matrix. See block
|
||||
//! `_blocks/security-ssh-hardening.md` for rationale per directive.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Expect {
|
||||
/// Value must equal (case-insensitive) one of the given strings.
|
||||
OneOf(Vec<&'static str>),
|
||||
/// Value must equal the given string (case-insensitive).
|
||||
Equals(&'static str),
|
||||
/// Value must be a numeric literal ≤ given bound.
|
||||
MaxInt(u32),
|
||||
/// Value must contain ALL of the given tokens (comma-split, case-insensitive).
|
||||
ContainsAll(Vec<&'static str>),
|
||||
/// Value must NOT contain ANY of the given tokens.
|
||||
DeniesAny(Vec<&'static str>),
|
||||
/// Value must be present and non-empty; dynamic equality deferred to check.rs.
|
||||
AllowedUsersSubset(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rule {
|
||||
pub directive: &'static str,
|
||||
pub required: bool,
|
||||
pub expect: Expect,
|
||||
pub rationale: &'static str,
|
||||
}
|
||||
|
||||
pub fn hardened_matrix(allow_users: &[String]) -> Vec<Rule> {
|
||||
vec![
|
||||
Rule {
|
||||
directive: "passwordauthentication",
|
||||
required: true,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "Passwords are the #1 brute-force vector; keys only.",
|
||||
},
|
||||
Rule {
|
||||
directive: "permitrootlogin",
|
||||
required: true,
|
||||
expect: Expect::OneOf(vec!["no", "prohibit-password"]),
|
||||
rationale: "Root via key only (or not at all).",
|
||||
},
|
||||
Rule {
|
||||
directive: "permitemptypasswords",
|
||||
required: false,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "Empty passwords never.",
|
||||
},
|
||||
Rule {
|
||||
directive: "challengeresponseauthentication",
|
||||
required: false,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "Disables keyboard-interactive fallback.",
|
||||
},
|
||||
Rule {
|
||||
directive: "kbdinteractiveauthentication",
|
||||
required: false,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "OpenSSH 8.7+ directive; supersedes ChallengeResponseAuthentication.",
|
||||
},
|
||||
Rule {
|
||||
directive: "maxauthtries",
|
||||
required: true,
|
||||
expect: Expect::MaxInt(3),
|
||||
rationale: "Limits per-connection key attempts; combine with fail2ban.",
|
||||
},
|
||||
Rule {
|
||||
directive: "x11forwarding",
|
||||
required: false,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "Not needed on servers; attack surface.",
|
||||
},
|
||||
Rule {
|
||||
directive: "allowtcpforwarding",
|
||||
required: false,
|
||||
expect: Expect::OneOf(vec!["no", "local"]),
|
||||
rationale: "Blocks SSH-as-VPN; enable per Match block if needed.",
|
||||
},
|
||||
Rule {
|
||||
directive: "permittunnel",
|
||||
required: false,
|
||||
expect: Expect::Equals("no"),
|
||||
rationale: "Blocks tun(4) tunnel device.",
|
||||
},
|
||||
Rule {
|
||||
directive: "clientaliveinterval",
|
||||
required: false,
|
||||
expect: Expect::MaxInt(300),
|
||||
rationale: "Idle sessions terminated after a few minutes.",
|
||||
},
|
||||
Rule {
|
||||
directive: "loglevel",
|
||||
required: false,
|
||||
expect: Expect::OneOf(vec!["verbose", "debug1", "debug2", "debug3"]),
|
||||
rationale: "VERBOSE logs key fingerprints for audit.",
|
||||
},
|
||||
Rule {
|
||||
directive: "allowusers",
|
||||
required: true,
|
||||
expect: Expect::AllowedUsersSubset(allow_users.to_vec()),
|
||||
rationale: "Explicit admin whitelist.",
|
||||
},
|
||||
Rule {
|
||||
directive: "ciphers",
|
||||
required: false,
|
||||
expect: Expect::DeniesAny(vec![
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
"blowfish-cbc",
|
||||
"rijndael-cbc@lysator.liu.se",
|
||||
]),
|
||||
rationale: "CBC ciphers vulnerable to Terrapin / padding oracles.",
|
||||
},
|
||||
Rule {
|
||||
directive: "macs",
|
||||
required: false,
|
||||
expect: Expect::ContainsAll(vec!["etm"]),
|
||||
rationale: "ETM (Encrypt-Then-MAC) only; legacy MAC is broken.",
|
||||
},
|
||||
Rule {
|
||||
directive: "hostkeyalgorithms",
|
||||
required: false,
|
||||
expect: Expect::DeniesAny(vec!["ssh-rsa", "ssh-dss"]),
|
||||
rationale: "ssh-rsa = SHA-1 signature, deprecated. Use rsa-sha2-*.",
|
||||
},
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue