24 Commits
Author SHA1 Message Date
Pierre Dubouilh 1299a41b6a move to probelist
the probelist allows to finely allow / dissalow certain paths.
2026-09-03 18:40:17 +02:00
Pierre Dubouilh 27e78fe349 tighter checks for ipset install 2026-08-28 00:34:23 +02:00
Pierre Dubouilh 2df9519224 rename json to caddy
it was misleading
2026-08-28 00:34:23 +02:00
Pierre Dubouilh e8437d33fb tighten CLF 2026-08-28 00:34:23 +02:00
Pierre Dubouilh e63e926ccf drop sshd support 2026-08-28 00:34:23 +02:00
Pierre Dubouilh ac17c11158 multiple config per target 2026-08-28 00:34:23 +02:00
Pierre Dubouilh 43392ace03 fixup readme & bump rust release 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 9d0bc470cc use clap to avoid CLI settings conflicts 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 956d56716e bump serde & rework status parsing 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 9d677a13df gracefully introduce ipsets 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 38a374858e tighten clf regex 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 02f5bea4c0 fixup CLI flags 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 1b9385e415 whitelist => blacklist 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 491efb2adb release from CI 2025-02-09 18:46:33 +01:00
Pierre Dubouilh 833627587c Merge pull request #5 from pldubouilh/pld/optim
cleanup & optimisation
2025-02-09 17:57:40 +01:00
Pierre Dubouilh 4decd89e44 ci 2025-02-07 17:21:58 +01:00
Pierre Dubouilh 4cb7fc694a customizable http statuses 2025-02-07 13:36:21 +01:00
Pierre Dubouilh 676bc30af8 run iptables/ipset directly 2025-02-01 12:15:20 +02:00
Pierre Dubouilh 1dca57a191 readme 2025-02-01 12:15:20 +02:00
Pierre Dubouilh e0bea7e7a8 pin rust toolchain 2025-02-01 12:15:20 +02:00
Pierre Dubouilh d58ac0faee build & release on gh 2025-02-01 12:15:20 +02:00
Pierre Dubouilh 61d78a7454 json & generic parser support
also added verbose flag and configurable HTTP statuses
2025-02-01 12:15:20 +02:00
Pierre Dubouilh 9763c1dc7d add grace period 2025-02-01 12:15:20 +02:00
Pierre Dubouilh 5b9e81af5f cleanup & optimisation 2022-08-21 20:15:36 +02:00
18 changed files with 1637 additions and 646 deletions
+24
View File
@@ -0,0 +1,24 @@
name: always
on: [push]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Format
run: rustup component add rustfmt clippy
- name: Run CI
run: make ci
- name: Install Cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build all artifacts
run: make build-all
+35
View File
@@ -0,0 +1,35 @@
name: deploy
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Format
run: rustup component add rustfmt clippy
- name: Run tests
run: make ci
- name: Install Cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build all artifacts
run: make build-all
- name: "Release gh release latest"
uses: ncipollo/release-action@v1
with:
tag: latest
name: Latest
allowUpdates: true
artifacts: "builds/*"
bodyFile: "builds/buildout"
token: ${{ secrets.GITHUB_TOKEN }}
-20
View File
@@ -1,20 +0,0 @@
name: Rust
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run CI
run: make ci
+2
View File
@@ -1 +1,3 @@
/target
builds/**
probelist.json
Generated
+347 -296
View File
@@ -12,60 +12,66 @@ dependencies = [
]
[[package]]
name = "ansi_term"
version = "0.11.0"
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"winapi",
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "anyhow"
version = "1.0.44"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
checksum = "1485d4d2cc45e7b201ee3767015c96faa5904387c9d87c6efdd0fb511f12d305"
[[package]]
name = "autocfg"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "bindgen"
version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72a978d268b1d70b0e963217e60fdabd9523a941457a6c42a7315d15c7e89e5"
dependencies = [
"bitflags",
"cexpr",
"cfg-if 0.1.10",
"clang-sys",
"clap",
"env_logger",
"lazy_static",
"lazycell",
"log",
"peeking_take_while",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"which",
]
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
@@ -75,43 +81,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "blockfast"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"clap",
"ipset-sys",
"lazy_static",
"linemux",
"regex",
"serde_json",
"tokio",
]
[[package]]
name = "bytes"
version = "1.1.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
[[package]]
name = "cc"
version = "1.0.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11"
[[package]]
name = "cexpr"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db"
[[package]]
name = "cfg-if"
@@ -120,116 +105,113 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clang-sys"
version = "0.29.3"
name = "clap"
version = "4.5.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe6837df1d5cba2397b835c8530f51723267e16abbf83892e9e5af4f0e5dd10a"
checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796"
dependencies = [
"glob",
"libc",
"libloading",
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap"
version = "2.33.3"
name = "clap_builder"
version = "4.5.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7"
dependencies = [
"ansi_term",
"atty",
"bitflags",
"anstream",
"anstyle",
"clap_lex",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "clap_derive"
version = "4.5.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.96",
]
[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "crossbeam-channel"
version = "0.5.1"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4"
checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521"
dependencies = [
"cfg-if 1.0.0",
"cfg-if",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.5"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db"
checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc"
dependencies = [
"cfg-if 1.0.0",
"lazy_static",
]
[[package]]
name = "env_logger"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
"cfg-if",
"once_cell",
]
[[package]]
name = "filetime"
version = "0.2.15"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98"
checksum = "e94a7bbaa59354bc20dd75b67f23e2797b4490e9d6928203fb105c79e448c86c"
dependencies = [
"cfg-if 1.0.0",
"cfg-if",
"libc",
"redox_syscall",
"winapi",
]
[[package]]
name = "fsevent-sys"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e564d24da983c053beff1bb7178e237501206840a3e6bf4e267b9e8ae734a"
dependencies = [
"libc",
"windows-sys 0.36.1",
]
[[package]]
name = "futures-core"
version = "0.3.17"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d"
checksum = "d2acedae88d38235936c3922476b10fced7b2b68136f5e3c03c2d5be348a1115"
[[package]]
name = "futures-task"
version = "0.3.17"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99"
checksum = "842fc63b931f4056a24d59de13fb1272134ce261816e063e634ad0c15cdc5306"
[[package]]
name = "futures-util"
version = "0.3.17"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481"
checksum = "f0828a5471e340229c11c77ca80017937ce3c58cb788a17e5f1c2d5c485a9577"
dependencies = [
"autocfg",
"futures-core",
"futures-task",
"pin-project-lite 0.2.7",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "glob"
version = "0.3.0"
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
@@ -240,20 +222,11 @@ dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f"
dependencies = [
"quick-error",
]
[[package]]
name = "inotify"
version = "0.9.4"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d88ed757e516714cd8736e65b84ed901f72458512111871f20c1d377abdfbf5e"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags",
"inotify-sys",
@@ -270,20 +243,22 @@ dependencies = [
]
[[package]]
name = "ipset-sys"
version = "0.1.2"
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99f246b363e81a3c4b1d0ba74d675555774a6dd420dd136a83fddd0aeec20b14"
dependencies = [
"anyhow",
"bindgen",
]
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.0.4"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "058a107a784f8be94c7d35c1300f4facced2e93d2fbe5b1452b44e905ddca4a9"
checksum = "4d6112e8f37b59803ac47a42d14f1f3a59bbf72fc6857ffc5be455e28a691f8e"
dependencies = [
"kqueue-sys",
"libc",
@@ -305,97 +280,60 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.103"
version = "0.2.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6"
[[package]]
name = "libloading"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b111a074963af1d37a139918ac6d49ad1d0d5e47f72fd55388619691a7d753"
dependencies = [
"cc",
"winapi",
]
checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5"
[[package]]
name = "linemux"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faffd44046d5dcc8b31e76840fa2a9e975b3b6e6ad2db576cf71dca8c27945b9"
checksum = "51157eba73f3dae3b17ae3ea5b29a8ad0346bdff3881e9a00646b827db066a83"
dependencies = [
"futures-util",
"notify",
"pin-project-lite 0.1.12",
"pin-project-lite",
"tokio",
]
[[package]]
name = "log"
version = "0.4.14"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
"cfg-if 1.0.0",
"cfg-if",
]
[[package]]
name = "memchr"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "mio"
version = "0.7.13"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16"
checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
dependencies = [
"libc",
"log",
"miow",
"ntapi",
"winapi",
]
[[package]]
name = "miow"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
dependencies = [
"winapi",
]
[[package]]
name = "nom"
version = "5.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
dependencies = [
"memchr",
"version_check",
"wasi",
"windows-sys 0.36.1",
]
[[package]]
name = "notify"
version = "5.0.0-pre.13"
version = "5.0.0-pre.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245d358380e2352c2d020e8ee62baac09b3420f1f6c012a31326cfced4ad487d"
checksum = "530f6314d6904508082f4ea424a0275cf62d341e118b313663f266429cb19693"
dependencies = [
"bitflags",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
@@ -404,15 +342,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "ntapi"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44"
dependencies = [
"winapi",
]
[[package]]
name = "num_cpus"
version = "1.13.1"
@@ -424,22 +353,16 @@ dependencies = [
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
name = "once_cell"
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "pin-project-lite"
version = "0.1.12"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777"
[[package]]
name = "pin-project-lite"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
[[package]]
name = "pin-utils"
@@ -449,42 +372,36 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro2"
version = "1.0.36"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
dependencies = [
"unicode-xid",
"unicode-ident",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.14"
version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d"
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.2.10"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.5.4"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
dependencies = [
"aho-corasick",
"memchr",
@@ -493,15 +410,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.25"
version = "0.6.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
[[package]]
name = "same-file"
@@ -513,100 +424,121 @@ dependencies = [
]
[[package]]
name = "shlex"
version = "0.1.1"
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.4"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c307a32c1c5c437f38c7fd45d753050587732ba8628319fbdf12a7e289ccc590"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "strsim"
version = "0.8.0"
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "syn"
version = "1.0.84"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
"syn 2.0.96",
]
[[package]]
name = "termcolor"
version = "1.1.3"
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"winapi-util",
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "textwrap"
version = "0.11.0"
name = "slab"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef"
dependencies = [
"unicode-width",
"autocfg",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.12.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2c2416fdedca8443ae44b4527de1ea633af61d8f7169ffa6e72c5b53d24efcc"
checksum = "7a8325f63a7d4774dd041e363b2409ed1c5cbbd0f867795e661df066b2b0a581"
dependencies = [
"autocfg",
"bytes",
"memchr",
"num_cpus",
"pin-project-lite 0.2.7",
"once_cell",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 1.0.99",
]
[[package]]
name = "unicode-width"
version = "0.1.9"
name = "unicode-ident"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf"
[[package]]
name = "unicode-xid"
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "walkdir"
@@ -620,13 +552,10 @@ dependencies = [
]
[[package]]
name = "which"
version = "3.1.1"
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724"
dependencies = [
"libc",
]
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "winapi"
@@ -658,3 +587,125 @@ name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2"
dependencies = [
"windows_aarch64_msvc 0.36.1",
"windows_i686_gnu 0.36.1",
"windows_i686_msvc 0.36.1",
"windows_x86_64_gnu 0.36.1",
"windows_x86_64_msvc 0.36.1",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "blockfast"
version = "0.1.0"
version = "0.2.0"
authors = ["Pierre Dubouilh <pldubouilh@gmail.com>"]
edition = "2018"
@@ -11,6 +11,6 @@ linemux = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
lazy_static = "1.4.0"
regex = "1.5.4"
clap = "2.33.3"
anyhow = "1.0.44"
ipset-sys = "0.1.2"
serde_json = "1"
clap = { version = "4.5.27", features = ["derive"] }
+50 -16
View File
@@ -1,10 +1,38 @@
build::
cargo build
cargo clippy
cargo fmt
BLOCKFAST_VERS := $(shell git show -s --format=%cd --date=format:%Y-%m-%d HEAD) / $(shell git rev-parse --short HEAD)
export BLOCKFAST_VERS
run:: build
sudo target/debug/blockfast -s=/tmp/sshdtest -c=/tmp/clftest
build::
@echo $(BLOCKFAST_VERS)
cargo build
cargo clippy --all
cargo fmt --all
run::
touch /tmp/clftest
touch /tmp/caddytest
touch /tmp/generictest
cargo build
sudo target/debug/blockfast -v --clf-logpath=/tmp/clftest --caddy-logpath=/tmp/caddytest --generic-logpath=/tmp/generictest --generic-ip='from ([0-9a-fA-F:.]+) port' --generic-positive='Failed password'
ci:: test
cargo fmt --all -- --check
cargo clippy -- -D warnings
build-all::
mkdir -p builds
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target aarch64-unknown-linux-musl
cross build --release --target armv7-unknown-linux-musleabihf
cp target/x86_64-unknown-linux-musl/release/blockfast builds/blockfast-x86_64-linux
cp target/aarch64-unknown-linux-musl/release/blockfast builds/blockfast-aarch64-linux
cp target/armv7-unknown-linux-musleabihf/release/blockfast builds/blockfast-arm7-linux
chmod +x builds/blockfast-*
echo '```' > builds/buildout
echo $(BLOCKFAST_VERS) >> builds/buildout
rustc --version >> builds/buildout
sha256sum builds/* >> builds/buildout
echo '```' >> builds/buildout
cat builds/buildout
watch::
ls src/*.rs | entr -rc -- make run
@@ -12,18 +40,24 @@ watch::
test::
cargo test
watch-test::
ls src/*.rs | entr -rc -- make test
release::
cargo build --target x86_64-unknown-linux-musl --release
ci:: test
cargo fmt --all -- --check
cargo clippy -- -D warnings
hit-generic::
echo "Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2" >> /tmp/generictest
hit-sshd-bad::
echo "Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication failure; logname= u =0 tty=ssh ruser= rhost=5.101.107.191" >> /tmp/sshdtest
ok-generic::
echo "Sep 26 06:25:19 livecompute sshd[23246]: Successful login for root from 179.124.36.195 port 41883 ssh2" >> /tmp/generictest
hit-clf::
echo "1.124.36.195 - p [25/Sep/2021:13:49:56 +0200] \"GET /.env HTTP/2.0\" 200 923" >> /tmp/clftest
ok-clf::
echo "2.124.36.195 - p [25/Sep/2021:13:49:56 +0200] \"POST /some/rpc HTTP/2.0\" 200 23012" >> /tmp/clftest
hit-caddy::
echo "{\"request\":{\"remote_ip\":\"1.124.36.19\",\"uri\":\"/.env\"}, \"status\": 200}" >> /tmp/caddytest
ok-caddy::
echo "{\"request\":{\"remote_ip\":\"2.124.36.19\",\"uri\":\"/\"}, \"status\": 200}" >> /tmp/caddytest
hit-clftest-bad::
echo "8.8.8.8 - p [25/Sep/2021:13:49:56 +0200] \"POST /some/rpc HTTP/2.0\" 200 923" >> /tmp/clftest
+73 -9
View File
@@ -1,13 +1,77 @@
# blockfast
block ssh and http scanners fast
Block internets scanners fast 🍶
features:
- generic SSH log parser
- generic Common Log Format parser (apache logs, caddy logs, etc...)
- sane defaults
- fast ip ban with `ipset`
- libmusl static release builds, no libc dependency
- lighter alternative to fail2ban
Features:
- Built-in scanner detection, built for well-known probe paths (`/.env`, `/phpinfo.php`, etc...)
- Extends built-in scanners using your own logs. See probelist.example.json
- Supports logs from Common Log Format (apache, nginx logs, etc...)
- Caddy JSON log parser
- Generic log parser (regexp)
- Sane defaults
- Fast ip ban with `ipset`
- Static release builds, no libc dependency
- Lighter alternative to fail2ban
Todo: more granular CLI args to filter HTTP Status codes (e.g. 5 401 leads to a block, but 30 404 before a block) ?
## example
```txt
$ ./blockfast --caddy-logpath=/caddy/logs
1737927469 - starting with caddy parsing at "/tmp/caddytest"
1737927469 - jail setup, allowance 5, time 21600s
1737927477 - caddy logged offence for 9.124.36.195
1737927478 - caddy logged offence for 9.124.36.195
1737927479 - caddy logged offence for 9.124.36.195
1737927479 - caddy logged offence for 9.124.36.195
1737927480 - caddy logged offence for 9.124.36.195
1737927480 - caddy jailtime for 9.124.36.195
```
## build
see `Makefile`
## usage
```txt
$ target/debug/blockfast
Blockfast - block internets scanners fast 🍶
Author: pierre dubouilh <pldubouilh@gmail.com>
Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset.
It supports logs in Common-Log-Format (Apache, nginx, etc..), Caddy JSON and a generic logs parser.
Example:
# block invalid http statuses from caddy
./blockfast --caddy-logpath=/caddy/logs
# generic log parser example with a log text to flag, and a regex to parse the offending IP.
./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port'
Usage: blockfast [OPTIONS]
Options:
--jailtime <JAILTIME>
jail time (seconds) [default: 21600]
--allowance <ALLOWANCE>
how many offences allowed (max 255) [default: 5]
-v, --verbose
log all offences
--clf-logpath <CLF_LOGPATH>
path of Common-Log-Format logfile (Apache, nginx, etc..), can be repeated
--caddy-logpath <CADDY_LOGPATH>
path of Caddy JSON logfile, can be repeated
--probelist <PROBELIST>
path of a probelist JSON file, replaces the built-in probe list (see README)
--generic-logpath <GENERIC_LOGPATH>
generic parser log file path, can be repeated
--generic-ip <GENERIC_IP>
generic parser ip regex
--generic-positive <GENERIC_POSITIVE>
generic parser positive - if a logline contains this, it is considered bad, the rest is good
--generic-negative <GENERIC_NEGATIVE>
generic parser negative - if a logline contains this, it is considered good, the rest is bad
--invalid-http-statuses <INVALID_HTTP_STATUSES>
also flag these http statuses (for CLF and Caddy logs), on top of the built-in scanner-path detection. Coma separated list, accepts ranges with XX, e.g. "403,5xx".
-h, --help
Print help
-V, --version
Print version
```
+108
View File
@@ -0,0 +1,108 @@
{
"comment": "blockfast probelist - TEMPLATE. This file documents the format and how to generate a real one from server logs. blockfast loads it with --probelist=<file>; it then REPLACES the built-in probe list entirely, so a generated file must include generic scanner rules too, not only setup-specific ones. All `comment` fields (and this whole header) are ignored by the loader; entries containing only a `comment` are section separators. Everything else must follow the format below.",
"format": [
"each probe entry: { path, match?, status?, allowance?, comment? }",
"path (required): the string to match against the request URI, matched case-insensitively",
"match (optional, default 'contains'): 'contains' matches anywhere in the full URI including the query string; 'prefix' and 'exact' match against the query-stripped path only",
"status (optional): restricts the probe to these response statuses, as a comma-separated string with trailing-x ranges, e.g. '401' or '403,5xx' or '40x'. Without it the probe matches ANY status - important, since SPAs with catch-all routes answer 200 to probe paths",
"allowance (optional, 1-255): overrides blockfast's global --allowance for this probe, i.e. how many offences an IP may accumulate (within the jailtime window, shared across all probes) before being banned. Use a HIGHER value for endpoints legitimate users occasionally trip, a LOW value is implicit for pure probe paths"
],
"howto_generate": [
"You are generating a ban-rule file for blockfast, a fail2ban-like daemon: it tails web server logs (Common Log Format or Caddy JSON), counts offences per client IP, and firewalls IPs that exceed their allowance. A wrong rule bans legitimate users, so precision beats recall.",
"1. Group the log lines by client IP. Classify each IP: legitimate clients use real app routes and mostly get 2xx/3xx; scanners enumerate paths (/.env, /phpinfo.php, /wp-*, credential/config filenames) that the app never serves.",
"2. Paths requested ONLY by scanners and NEVER by legitimate clients become unconditional probes (no status filter). Do not rely on the response status for these: a catch-all route may answer 200.",
"3. Endpoints that legitimate users DO touch, but that indicate abuse in volume (login endpoints, session checks answering 401, method-probing answering 405), become narrow rules: match 'prefix' or 'exact' + a status filter + a generous allowance. Never add such an endpoint without a status filter.",
"4. NEVER add: paths served by the app (assets, API routes), /favicon.ico, /robots.txt, /.well-known (ACME!), or anything a browser or well-behaved bot requests on its own. When unsure, leave it out.",
"5. Prefer 'exact' for short or generic names ('/env', '/i.php') - as substrings they would match legitimate paths ('/i.php' is inside '/api.php'). Use 'contains' for names that are unambiguous anywhere in a URI ('/.env', 'phpinfo').",
"6. Keep the generic scanner families in the output (see sections below), then append the setup-specific rules derived from the logs, each with a comment explaining the evidence.",
"7. Sanity-check the finished list by replaying the logs against it: no IP classified as a legitimate client may match any rule."
],
"probes": [
{ "comment": "--- setup-specific rules, derived from the logs (EXAMPLES - replace with real findings) ---" },
{ "path": "/api/auth", "match": "prefix", "status": "401", "allowance": 10, "comment": "example: the SPA answers 401 on session checks when logged out - normal a few times, credential stuffing in bulk. prefix + status + generous allowance" },
{ "path": "/", "match": "exact", "status": "405", "comment": "example: scanners POST to the root fishing for handlers; browsers never trigger 405 there" },
{ "path": "/backup.tar.gz", "match": "exact", "comment": "example: artifact hunted by scanners in these logs, never linked by the app" },
{ "comment": "--- generic: secrets & dotfiles ---" },
{ "path": "/.env", "comment": "also matches /.env.bak, /backend/.env, ..." },
{ "path": "%2eenv", "comment": "url-encoded .env" },
{ "path": "/.git", "comment": "/.git/config, /.git/HEAD, /.gitconfig" },
{ "path": "/.svn" },
{ "path": "/.hg/" },
{ "path": "/.aws" },
{ "path": "/.ssh" },
{ "path": "/.docker" },
{ "comment": "--- generic: cloud credentials & config dumps ---" },
{ "path": "credentials.json" },
{ "path": "-key.json", "comment": "/gcp-key.json, /firebase-key.json, ..." },
{ "path": "/keyfile.json" },
{ "path": "/sa.json" },
{ "path": "service-account.json" },
{ "path": "firebase-adminsdk.json" },
{ "path": "gcp-sa.json" },
{ "path": "/docker-compose.yml" },
{ "path": "/appsettings.json" },
{ "path": "application.yml" },
{ "path": "parameters.yml" },
{ "path": "/web.config" },
{ "path": "/settings.py" },
{ "path": "/wp-config.php", "comment": "never served, only probed - even on real wordpress sites" },
{ "comment": "--- generic: php probes ---" },
{ "path": "phpinfo" },
{ "path": "phpmyadmin" },
{ "path": "adminer.php" },
{ "path": ".php.bak" },
{ "path": ".php.old" },
{ "path": ".php.save" },
{ "path": ".php~" },
{ "path": "eval-stdin.php", "comment": "phpunit RCE" },
{ "path": "/vendor/phpunit" },
{ "path": "/test.php", "match": "exact" },
{ "path": "/info.php", "match": "exact" },
{ "path": "/pinfo.php", "match": "exact" },
{ "path": "/pi.php", "match": "exact" },
{ "path": "/i.php", "match": "exact" },
{ "path": "/p.php", "match": "exact" },
{ "path": "/php.php", "match": "exact" },
{ "path": "/debug.php", "match": "exact" },
{ "path": "/database.php", "match": "exact" },
{ "path": "/config.php", "match": "exact" },
{ "path": "/shell.php", "match": "exact" },
{ "path": "/upload.php", "match": "exact" },
{ "comment": "--- generic: fingerprinting & framework debug endpoints ---" },
{ "path": "wlwmanifest.xml", "comment": "wordpress fingerprinting, legit wp traffic never touches it" },
{ "path": "/actuator/env", "comment": "spring boot - keep narrow, bare /actuator would catch legit health checks" },
{ "path": "/_profiler", "comment": "symfony" },
{ "path": "/_ignition", "comment": "laravel RCE" },
{ "path": "/_environment", "comment": "cakephp" },
{ "path": "laravel.log" },
{ "path": "/env", "match": "exact" },
{ "comment": "--- generic: server status, traversal, IoT/router botnets ---" },
{ "path": "server-status" },
{ "path": "server-info" },
{ "path": "/../" },
{ "path": "%2e%2e" },
{ "path": "/etc/passwd" },
{ "path": "/cgi-bin/" },
{ "path": "/boaform" },
{ "path": "/hnap1" },
{ "path": "/gponform" },
{ "comment": "--- aggressive extras: correct for many setups, but NOT in blockfast's built-in default because they match legit traffic on some stacks. Include them only when the logs show the stack does not use them ---" },
{ "path": "/wp-login.php", "comment": "legit logins on real wordpress sites - only include if not hosting wordpress" },
{ "path": "/xmlrpc.php", "comment": "used by jetpack & wordpress mobile apps" },
{ "path": "rest_route=", "comment": "wordpress REST access with permalinks disabled" },
{ "path": "/autodiscover/", "comment": "outlook clients innocently probe this on any domain" },
{ "path": "/manager/html", "comment": "tomcat console - legit for tomcat admins" },
{ "path": "/solr/admin" },
{ "path": "/geoserver/web" },
{ "path": "/telescope/requests", "comment": "laravel telescope - legit for its admins" }
]
}
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "1.95.0"
+102
View File
@@ -0,0 +1,102 @@
use crate::probes::ProbeList;
use crate::utils::ParsingStatus;
use anyhow::*;
use std::{net::IpAddr, str::FromStr};
pub fn parse(line: &str, probelist: &ProbeList, invalid_statuses: &[u32]) -> Result<ParsingStatus> {
let json: serde_json::Value = serde_json::from_str(line)?;
let remote_ip = json
.get("request")
.and_then(|r| r.get("remote_ip"))
.and_then(|r| r.as_str())
.and_then(|r| IpAddr::from_str(r).ok())
.ok_or_else(|| anyhow!("cant parse json line - remote_ip"))?;
let uri = json
.get("request")
.and_then(|r| r.get("uri"))
.and_then(|r| r.as_str())
.ok_or_else(|| anyhow!("cant parse json line - uri"))?;
let status = json
.get("status")
.and_then(|r| r.as_u64())
.ok_or_else(|| anyhow!("cant parse json line - status"))? as u32;
if let Some(probe) = probelist.check(uri, status) {
return Ok(ParsingStatus::BadEntry(remote_ip, probe.allowance));
}
let is_bad_status = invalid_statuses.iter().any(|s| s == &status);
if is_bad_status {
return Ok(ParsingStatus::BadEntry(remote_ip, None));
}
Ok(ParsingStatus::OkEntry)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive() {
let vectors = [
r#"{"level":"info","ts":1738064403.2176833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"46884","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"127.0.0.1:8009","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["en-US,en;q=0.5"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Connection":["keep-alive"],"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Site":["cross-site"],"Priority":["u=0, i"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.002135063,"size":35133,"status":429,"resp_headers":{"Vary":["Accept, Accept-Encoding"],"Last-Modified":["Tue, 28 Jan 2025 12:40:02 GMT"],"Content-Type":["text/html; charset=utf-8"],"Server":["Caddy"]}}"#,
r#"{"level":"info","ts":1738064403.2176833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"46884","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"127.0.0.1:8009","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["en-US,en;q=0.5"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Connection":["keep-alive"],"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Site":["cross-site"],"Priority":["u=0, i"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.002135063,"size":35133,"status":401,"resp_headers":{"Vary":["Accept, Accept-Encoding"],"Last-Modified":["Tue, 28 Jan 2025 12:40:02 GMT"],"Content-Type":["text/html; charset=utf-8"],"Server":["Caddy"]}}"#,
];
vectors.iter().for_each(|e| {
let ret = parse(*e, &ProbeList::builtin(), &vec![429, 401]).unwrap();
match ret {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn negative() {
let vectors = [
r#"{"level":"info","ts":1738064403.2176833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"46884","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"127.0.0.1:8009","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["en-US,en;q=0.5"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Connection":["keep-alive"],"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Site":["cross-site"],"Priority":["u=0, i"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.002135063,"size":35133,"status":200,"resp_headers":{"Vary":["Accept, Accept-Encoding"],"Last-Modified":["Tue, 28 Jan 2025 12:40:02 GMT"],"Content-Type":["text/html; charset=utf-8"],"Server":["Caddy"]}}"#,
r#"{"level":"info","ts":1738064403.2176833,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"46884","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"127.0.0.1:8009","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["en-US,en;q=0.5"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Connection":["keep-alive"],"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Site":["cross-site"],"Priority":["u=0, i"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"]}},"bytes_read":0,"user_id":"","duration":0.002135063,"size":35133,"status":404,"resp_headers":{"Vary":["Accept, Accept-Encoding"],"Last-Modified":["Tue, 28 Jan 2025 12:40:02 GMT"],"Content-Type":["text/html; charset=utf-8"],"Server":["Caddy"]}}"#,
];
vectors.iter().for_each(|e| {
let ret = parse(*e, &ProbeList::builtin(), &vec![429, 401]).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn probe_uri() {
// a probe path is an offence even with a 200 status and no status list
let bad = r#"{"request":{"remote_ip":"1.2.3.4","uri":"/.env"},"status":200}"#;
match parse(bad, &ProbeList::builtin(), &[]).unwrap() {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
let ok = r#"{"request":{"remote_ip":"1.2.3.4","uri":"/api/auth/me"},"status":200}"#;
match parse(ok, &ProbeList::builtin(), &[]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn malformed() {
let vectors = [
r#"{"level":"info","ts":1738064403.2176833,"logger":"http.log.access.log0","msg":"handled request","requeto":"HTTP/1 x86_64; rv:133.0)"],"Server":["Caddy"]}}"#,
];
vectors.iter().for_each(|e| {
let ret = parse(*e, &ProbeList::builtin(), &vec![429, 401]);
assert!(ret.is_err());
})
}
}
+100 -19
View File
@@ -1,30 +1,51 @@
use crate::probes::ProbeList;
use crate::utils::ParsingStatus;
use anyhow::Result;
use anyhow::*;
use lazy_static::lazy_static;
use std::net::IpAddr;
use regex::Regex;
use std::{net::IpAddr, str::FromStr};
// TODO: allow user-provided list
// TODO: match different error-levels (10 404, but only 5 401, etc...)
lazy_static! {
static ref BAD_STATUSES: [u32; 2] = [401, 429];
// anchored on the left side of the line: host, ident, authuser, [date],
// the quoted request (honouring backslash escapes, so a quote injected in
// the URL cannot shift the match), then the status right after it. this
// covers plain CLF and the combined format - the trailing attacker
// controlled "referer" "user-agent" fields are never scanned
static ref RE_CLF: Regex =
Regex::new(r#"^(\S+)\s+\S+\s+\S+\s+\[[^\]]*\]\s+"((?:[^"\\]|\\.)*)"\s+(\d{3})(?:\s|$)"#)
.unwrap();
}
pub fn parse(line: &str) -> Result<ParsingStatus> {
// TODO: Use a proper parser ?
let elts: Vec<&str> = line.split_whitespace().collect();
pub fn parse(line: &str, probelist: &ProbeList, invalid_statuses: &[u32]) -> Result<ParsingStatus> {
let caps = RE_CLF
.captures(line)
.ok_or_else(|| anyhow!("cant parse clf line"))?;
let ip_str = elts[0];
let ip = ip_str.parse::<IpAddr>()?;
let ip = caps
.get(1)
.and_then(|g| IpAddr::from_str(g.as_str()).ok())
.ok_or_else(|| anyhow!("cant parse clf line - ip"))?;
let http_code_str = elts[elts.len() - 2] as &str;
let http_code = http_code_str.parse::<u32>()?;
let status = caps
.get(3)
.and_then(|g| g.as_str().parse::<u32>().ok())
.ok_or_else(|| anyhow!("cant parse clf line - status"))?;
for status in BAD_STATUSES.iter() {
if *status == http_code {
return Ok(ParsingStatus::BadEntry(ip));
// the uri is the second token of the request field ("GET /uri HTTP/1.1")
let uri = caps
.get(2)
.and_then(|g| g.as_str().split_whitespace().nth(1));
if let Some(u) = uri {
if let Some(probe) = probelist.check(u, status) {
return Ok(ParsingStatus::BadEntry(ip, probe.allowance));
}
}
let is_bad_status = invalid_statuses.iter().any(|s| s == &status);
if is_bad_status {
return Ok(ParsingStatus::BadEntry(ip, None));
}
Ok(ParsingStatus::OkEntry)
}
@@ -40,9 +61,9 @@ mod tests {
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
let ret = parse(*e, &ProbeList::builtin(), &vec![401, 429]).unwrap();
match ret {
ParsingStatus::BadEntry(_) => {}
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
})
@@ -56,7 +77,7 @@ mod tests {
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
let ret = parse(*e, &ProbeList::builtin(), &vec![401, 429]).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
@@ -64,6 +85,66 @@ mod tests {
})
}
#[test]
fn combined_format() {
// combined log format appends "referer" "user-agent" - the old parser
// read digits out of the user-agent as the status and silently missed these
let bad = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /admin HTTP/1.1" 401 923 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0""#;
match parse(bad, &ProbeList::builtin(), &vec![401, 429]).unwrap() {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
let ok = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET / HTTP/1.1" 200 923 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0""#;
match parse(ok, &ProbeList::builtin(), &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn probe_uri() {
// a probe path is an offence even with a 200 status and no status list
let bad = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /.env HTTP/1.1" 200 923"#;
match parse(bad, &ProbeList::builtin(), &[]).unwrap() {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
let ok = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /index.html HTTP/1.1" 200 923"#;
match parse(ok, &ProbeList::builtin(), &[]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn quote_injection() {
// servers escape quotes in the logged request - an escaped `\" 401 `
// inside the URL must not be mistaken for the end of the request field
let ok = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /x?a=\" 401 - HTTP/1.1" 200 923"#;
match parse(ok, &ProbeList::builtin(), &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn bodyless() {
// CLF uses `-` for absent body bytes, both branches must still parse
let bad = "8.8.8.8 - p [25/Sep/2021:13:49:56 +0200] \"GET / HTTP/2.0\" 401 -";
match parse(bad, &ProbeList::builtin(), &vec![401, 429]).unwrap() {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
let ok = "8.8.8.8 - p [25/Sep/2021:13:49:56 +0200] \"GET / HTTP/2.0\" 304 -";
match parse(ok, &ProbeList::builtin(), &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn malformed() {
let vectors = [
@@ -72,7 +153,7 @@ mod tests {
];
vectors.iter().for_each(|e| {
let ret = parse(*e);
let ret = parse(*e, &ProbeList::builtin(), &vec![429, 401]);
assert!(ret.is_err());
})
}
+83
View File
@@ -0,0 +1,83 @@
use crate::utils::ParsingStatus;
use anyhow::*;
use regex::Regex;
use std::{net::IpAddr, str::FromStr};
#[allow(clippy::bind_instead_of_map)]
pub fn parse(
line: &str,
ip: Option<&Regex>,
positive: Option<&String>,
negative: Option<&String>,
) -> Result<ParsingStatus> {
if let Some(ne) = negative {
if line.contains(ne) {
return Ok(ParsingStatus::OkEntry);
}
}
if let Some(po) = positive {
if !line.contains(po) {
return Ok(ParsingStatus::OkEntry);
}
}
let ip = ip.unwrap().captures(line);
let ip = ip
.and_then(|c| c.get(1))
.and_then(|g| Some(g.as_str()))
.and_then(|e| IpAddr::from_str(e).ok())
.ok_or_else(|| anyhow!("cant parse clf line - ip"))?;
Ok(ParsingStatus::BadEntry(ip, None))
}
#[cfg(test)]
mod tests {
use super::*;
const FAILED: &str =
"Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2";
const SUCCESS: &str =
"Sep 26 06:25:19 livecompute sshd[23246]: Successful login for root from 179.124.36.195 port 41883 ssh2";
// generic log positive regex - what's that's flagged by this is considered bad, the rest is good
#[test]
fn positive() {
let positive = "Failed password".to_string();
let ip = Regex::new(r"from ([0-9a-fA-F:.]+) port").unwrap();
let ret = parse(FAILED, Some(&ip), Some(&positive), None).unwrap();
match ret {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
let ret = parse(SUCCESS, Some(&ip), Some(&positive), None).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
// generic log negative regex - what's that's flagged by this is considered good, the rest is bad
#[test]
fn negative() {
let negative = "Successful login".to_string();
let ip = Regex::new(r"from ([0-9a-fA-F:.]+) port").unwrap();
let ret = parse(SUCCESS, Some(&ip), None, Some(&negative)).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
let ret = parse(FAILED, Some(&ip), None, Some(&negative)).unwrap();
match ret {
ParsingStatus::BadEntry(..) => {}
_ => panic!("bad parsing"),
}
}
}
+68 -51
View File
@@ -1,92 +1,109 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::process::Command;
use std::result::Result::Ok;
use std::sync::Mutex;
use anyhow::*;
use ipset_sys::IpsetSys;
use crate::utils::JailStatus;
use crate::utils::{get_epoch, log};
pub struct Jail {
jailtime: u32,
name: String,
allowance: u8,
remand: Mutex<HashMap<IpAddr, u8>>,
ipset: IpsetSys,
jailtime: u32,
remand: Mutex<HashMap<IpAddr, (u8, u64)>>, // ip -> (hits, timestamp)
}
const JAIL_NAME: &str = "blockfast_jail";
fn exec(program: &str, cmd: &str, err: &str) -> Result<(), Error> {
let sentence_sl: Vec<&str> = cmd.split_whitespace().collect();
let out = Command::new(program).args(sentence_sl).output()?;
let sc = out.status.code();
ensure!(sc == Some(0), "err exec {}, {:?}\n{}", cmd, out, err);
Ok(())
}
const ERR_MSG: &str =
"error using ipset/iptables, maybe it's not installed, this program isn't running as root ?";
fn check_installed(program: &str) -> Result<()> {
match Command::new(program).arg("--version").output() {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!("`{}` not found, please install it first", program)
}
Err(e) => Err(e).context(format!("cant execute `{}`", program)),
}
}
fn exec_ok(program: &str, cmd: &str) -> Result<bool> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let out = Command::new(program).args(parts).output()?;
Ok(out.status.code() == Some(0))
}
impl Jail {
pub fn new(allowance: u8, jailtime: u32) -> Result<Jail> {
let init0 = format!(
"iptables -I INPUT 1 -m set -j DROP --match-set {} src",
JAIL_NAME
);
let init1 = format!(
"iptables -I FORWARD 1 -m set -j DROP --match-set {} src",
JAIL_NAME
);
const ERR_MSG: &str =
"error using ipset/iptables, maybe this program isn't running as root ?";
check_installed("ipset")?;
check_installed("iptables")?;
let n = format!("blockfast_jail_{}", jailtime);
let args1: Vec<&str> = init0.split_whitespace().collect();
let args2: Vec<&str> = init1.split_whitespace().collect();
// create
let cmd = format!("create -exist {} hash:ip timeout {}", n, jailtime);
exec("ipset", &cmd, ERR_MSG)?;
// init ipset
let mut ipset = IpsetSys::init()?;
let init_cmd = format!("create {} hash:ip timeout 0", JAIL_NAME);
let _ = ipset.run(&init_cmd);
// setup input
let out = Command::new("sudo").args(args1).output()?;
if out.status.code() != Some(0) {
eprintln!("{:?}", out);
bail!(ERR_MSG);
// install drop rule on INPUT and FORWARD, but only if not already present
let rule_spec = format!("-m set -j DROP --match-set {} src", n);
for chain in &["INPUT", "FORWARD"] {
let check = format!("-C {} {}", chain, rule_spec);
if !exec_ok("iptables", &check)? {
let install = format!("-I {} 1 {}", chain, rule_spec);
exec("iptables", &install, ERR_MSG)?;
}
// setup fwd
let out = Command::new("sudo").args(args2).output()?;
if out.status.code() != Some(0) {
eprintln!("{:?}", out);
bail!(ERR_MSG);
}
log!("jail setup, allowance {}, time {}s", allowance, jailtime);
Ok(Jail {
name: n,
allowance,
jailtime,
ipset,
remand: Mutex::new(HashMap::new()),
})
}
pub fn incr(&mut self, ip: IpAddr) -> Result<JailStatus> {
// allowance_override comes from a matched probe, else the global setting applies
pub fn sentence(&self, ip: IpAddr, allowance_override: Option<u8>) -> Result<bool> {
let now = get_epoch();
let allowance = allowance_override.unwrap_or(self.allowance);
let should_ban = {
let mut locked_map = self.remand.lock().map_err(|_| anyhow!("cant lock"))?;
// TODO: set time of last offence, and add grace
let hits = *locked_map.entry(ip).and_modify(|e| *e += 1).or_insert(1);
if hits < self.allowance {
let (hits, _ts) = *locked_map
.entry(ip)
.and_modify(|(hits, ts)| {
if now > *ts + self.jailtime as u64 {
// reset if we have a hit, but past the defined jailtime
*ts = now;
*hits = 1;
} else {
*hits += 1; // bump
}
})
.or_insert((1, now));
if hits < allowance {
false
} else {
locked_map.remove_entry(&ip); // preserve space
locked_map.remove_entry(&ip);
true
}
};
if should_ban {
let sentence = format!(
"add {} {} timeout {}",
JAIL_NAME,
ip.to_string(),
self.jailtime
);
self.ipset.run(&sentence)?;
Ok(JailStatus::Jailed(ip))
} else {
Ok(JailStatus::Remand)
let cmd = format!("add -exist {} {}", self.name, ip);
exec("ipset", &cmd, "")?;
return Ok(true);
}
Ok(false)
}
}
+96 -72
View File
@@ -1,99 +1,123 @@
use anyhow::*;
use linemux::MuxedLines;
use std::result::Result::Ok;
use anyhow::*;
use clap::Parser;
use linemux::{Line, MuxedLines};
mod caddy;
mod clf;
mod sshd;
mod generic;
mod probes;
mod utils;
mod jail;
use crate::jail::Jail;
use crate::utils::*;
fn judge(
path_sshd: &str,
path_clf: &str,
payload: &str,
path: &str,
jail: &mut Jail,
) -> Result<Judgment> {
let do_sshd = !path_sshd.is_empty();
let do_clf = !path_clf.is_empty();
let mut target = "";
let ret_parse = if do_sshd && path.ends_with(path_sshd) {
target = "sshd";
sshd::parse(payload)
} else if do_clf && path.ends_with(path_clf) {
target = "clf ";
clf::parse(payload)
} else {
Err(anyhow!("cant locate file !"))
};
let ip = match ret_parse? {
ParsingStatus::OkEntry => return Ok(Judgment::Good),
ParsingStatus::BadEntry(ip) => ip,
};
match jail.incr(ip)? {
JailStatus::Remand => Ok(Judgment::Remand),
JailStatus::Jailed(ip) => Ok(Judgment::Bad(target, ip)),
}
}
async fn run() -> Result<()> {
let args = utils::cli().get_matches();
let mut lines = MuxedLines::new()?;
let args = utils::Args::parse();
let mut ml = MuxedLines::new()?;
// jail
let jailtime_str = args.value_of("jailtime").unwrap_or("");
let jailtime = jailtime_str.parse().context("parsing jailtime")?;
// HTTP statuses - opt-in, on top of the probe detection
let invalid_statuses_parsed = match &args.invalid_http_statuses {
Some(s) => parse_statuses(s)?,
None => vec![],
};
let invalid_statuses_ref: &[u32] = invalid_statuses_parsed.as_ref();
let allowance_str = args.value_of("allowance").unwrap_or("");
let allowance = allowance_str.parse().context("parsing allowance")?;
// probes - loaded from --probelist if given, built-in list otherwise
let probelist = match args.probelist.as_ref() {
Some(p) => {
let pl = probes::ProbeList::load(p)?;
log!("loaded {} probes from {:?}", pl.len(), p);
pl
}
None => probes::ProbeList::builtin(),
};
let probelist = &probelist;
let mut jail = Jail::new(allowance, jailtime)?;
eprintln!(
"+ jail setup, offences allowed: {}, jailtime {}s",
allowance, jailtime
);
// sshd
let path_sshd = args.value_of("sshd_logpath").unwrap_or("");
if !path_sshd.is_empty() {
lines.add_file(path_sshd).await?;
eprintln!("+ starting with sshd parsing at {}", path_sshd);
// generic parser
let generic_paths = &args.generic_logpath;
let generic_ip_re = args.generic_ip.as_ref();
let generic_positive = args.generic_positive.as_ref();
let generic_negative = args.generic_negative.as_ref();
for p in generic_paths {
ml.add_file(&p).await?;
log!("starting with generic parsing at {:?}", &p);
}
// common log format
let path_clf = args.value_of("clf_logpath").unwrap_or("");
if !path_clf.is_empty() {
lines.add_file(path_clf).await?;
eprintln!("+ starting with clf parsing at {}", path_clf);
let clf_logpaths = &args.clf_logpath;
for p in clf_logpaths {
ml.add_file(&p).await?;
log!("starting with clf parsing at {:?}", &p);
}
while let Ok(Some(line)) = lines.next_line().await {
// caddy json
let caddy_logpaths = &args.caddy_logpath;
for p in caddy_logpaths {
ml.add_file(&p).await?;
log!("starting with caddy parsing at {:?}", &p);
}
if caddy_logpaths.is_empty() && clf_logpaths.is_empty() && generic_paths.is_empty() {
bail!("no log files to parse, see --help");
}
// jail
let jail = Jail::new(args.allowance, args.jailtime)?;
let assess_line = |line: Line| {
let payload = line.line();
let path = line.source().display().to_string();
let path_buf = Some(line.source().to_path_buf());
let path = path_buf.as_ref();
match judge(path_sshd, path_clf, payload, &path, &mut jail) {
Err(err) => eprintln!("! ERR {:?} - file {}", err, path),
Ok(Judgment::Good) => {}
Ok(Judgment::Remand) => {}
Ok(Judgment::Bad(target, ip)) => {
eprintln!("~ too many infraction, {} jailtime for: {}", target, ip)
}
let (target, ret) = if path.is_some_and(|p| clf_logpaths.contains(p)) {
("clf", clf::parse(payload, probelist, invalid_statuses_ref)?)
} else if path.is_some_and(|p| caddy_logpaths.contains(p)) {
(
"caddy",
caddy::parse(payload, probelist, invalid_statuses_ref)?,
)
} else if path.is_some_and(|p| generic_paths.contains(p)) {
(
"generic",
generic::parse(payload, generic_ip_re, generic_positive, generic_negative)?,
)
} else {
bail!("file {:?} unknown ?", path)
};
if let ParsingStatus::BadEntry(ip, allowance) = ret {
if args.verbose {
log!("{} logged offence for {}", target, ip);
}
let banned = jail.sentence(ip, allowance)?;
if banned {
log!("{} jailtime for {}", target, ip);
}
}
Ok(())
};
loop {
match ml.next_line().await {
Ok(Some(line)) => {
if let Err(e) = assess_line(line) {
log!("ERR: {:?}", e);
}
}
Ok(None) => {
log!("log stream ended, exiting");
return Ok(());
}
Err(e) => return Err(e.into()),
}
}
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let ret = run().await;
let _ = ret.map_err(|e| eprintln!("! ERROR {:?}", e));
eprintln!("\n");
let _ = utils::cli().print_help();
Ok(())
async fn main() -> Result<()> {
run().await
}
+351
View File
@@ -0,0 +1,351 @@
// scanner/vulnerability-probe detection for the clf and caddy parsers.
//
// probes come either from the built-in list below (distilled from real caddy
// logs + well-known probe paths), or from a probelist JSON file (--probelist),
// meant to be generated per-setup, e.g. by feeding logs to an AI system:
//
// {
// "probes": [
// { "path": "/.env", "match": "contains" },
// { "path": "/test.php", "match": "exact" },
// { "path": "/api/auth", "match": "prefix", "status": "401", "allowance": 10 }
// ]
// }
//
// `match` is exact | prefix | contains (default contains). exact and prefix
// apply to the query-stripped path, contains to the whole uri - all lowercased.
// `status` restricts the probe to those response statuses (same syntax as
// --invalid-http-statuses, e.g. "401,4xx"); without it any status matches.
// `allowance` overrides the global --allowance for this probe. a `comment`
// field is allowed anywhere and ignored.
use anyhow::*;
use std::path::Path;
use std::result::Result::Ok;
use crate::utils::parse_statuses;
// built-in probes, matched anywhere in the lowercased uri (query included).
// deliberately conservative: only paths that no legitimate client of ANY
// common stack ever requests. more aggressive, setup-specific rules (e.g.
// wp-login.php, /actuator, hosted admin consoles) belong in a --probelist file.
const PROBE_PARTS: &[&str] = &[
// secrets & dotfiles
"/.env", // /.env, /.env.bak, /backend/.env, ...
"%2eenv", // url-encoded .env probes
"/.git", // /.git/config, /.git/HEAD, /.gitconfig
"/.svn",
"/.hg/",
"/.aws", // /.aws/credentials
"/.ssh",
"/.docker",
// cloud credentials & config dumps
"credentials.json", // /google-credentials.json, /application_default_credentials.json
"-key.json", // /gcp-key.json, /firebase-key.json
"/keyfile.json",
"/sa.json",
"service-account.json",
"firebase-adminsdk.json",
"gcp-sa.json",
"/docker-compose.yml",
"/appsettings.json",
"application.yml",
"parameters.yml",
"/web.config",
"/settings.py",
"/wp-config.php", // never served, only probed - even on real wordpress sites
// php probes
"phpinfo", // /phpinfo.php, /admin/phpinfo.php, /?phpinfo=1
"phpmyadmin",
"adminer.php",
".php.bak",
".php.old",
".php.save",
".php~",
"eval-stdin.php", // phpunit RCE
"/vendor/phpunit",
// wordpress fingerprinting (legit wordpress traffic never touches this)
"wlwmanifest.xml",
// framework debug/env endpoints
"/actuator/env", // spring boot (bare /actuator would catch legit health checks)
"/_profiler", // symfony
"/_ignition", // laravel RCE
"/_environment", // cakephp
"laravel.log",
// server status
"server-status",
"server-info",
// traversal & IoT/router botnets
"/../",
"%2e%2e",
"/etc/passwd",
"/cgi-bin/",
"/boaform",
"/hnap1",
"/gponform",
];
// built-in exact matches on the query-stripped path: names too short or
// generic to be safe as substrings (/i.php would match /api.php, etc..)
const PROBE_EXACT: &[&str] = &[
"/test.php",
"/info.php",
"/pinfo.php",
"/pi.php",
"/i.php",
"/p.php",
"/php.php",
"/debug.php",
"/database.php",
"/config.php",
"/shell.php",
"/upload.php",
"/env",
];
pub enum MatchKind {
Exact,
Prefix,
Contains,
}
pub struct Probe {
pub path: String,
pub kind: MatchKind,
pub statuses: Option<Vec<u32>>,
pub allowance: Option<u8>,
}
pub struct ProbeList {
probes: Vec<Probe>,
}
impl ProbeList {
pub fn builtin() -> ProbeList {
let mut probes: Vec<Probe> = vec![];
for p in PROBE_PARTS {
probes.push(Probe {
path: p.to_string(),
kind: MatchKind::Contains,
statuses: None,
allowance: None,
});
}
for p in PROBE_EXACT {
probes.push(Probe {
path: p.to_string(),
kind: MatchKind::Exact,
statuses: None,
allowance: None,
});
}
ProbeList { probes }
}
pub fn load(path: &Path) -> Result<ProbeList> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("cant read probelist {:?}", path))?;
ProbeList::from_json(&raw).with_context(|| format!("cant parse probelist {:?}", path))
}
fn from_json(raw: &str) -> Result<ProbeList> {
let json: serde_json::Value = serde_json::from_str(raw)?;
let arr = json
.get("probes")
.and_then(|p| p.as_array())
.ok_or_else(|| anyhow!("missing `probes` array"))?;
let mut probes = vec![];
for (i, p) in arr.iter().enumerate() {
// entries carrying only a comment are section separators, skip them
if p.get("path").is_none() && p.get("comment").is_some() {
continue;
}
let path = p
.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("probe #{}: missing `path`", i))?
.to_ascii_lowercase();
let kind = match p
.get("match")
.and_then(|v| v.as_str())
.unwrap_or("contains")
{
"exact" => MatchKind::Exact,
"prefix" => MatchKind::Prefix,
"contains" => MatchKind::Contains,
other => bail!("probe #{}: unknown match kind `{}`", i, other),
};
let statuses = match p.get("status") {
None => None,
Some(v) => {
let s = v
.as_str()
.ok_or_else(|| anyhow!("probe #{}: `status` must be a string", i))?;
Some(parse_statuses(s).with_context(|| format!("probe #{}", i))?)
}
};
let allowance = match p.get("allowance") {
None => None,
Some(v) => {
let a = v
.as_u64()
.filter(|a| (1..=255).contains(a))
.ok_or_else(|| anyhow!("probe #{}: `allowance` must be 1-255", i))?;
Some(a as u8)
}
};
probes.push(Probe {
path,
kind,
statuses,
allowance,
});
}
Ok(ProbeList { probes })
}
pub fn len(&self) -> usize {
self.probes.len()
}
// returns the first probe matching this uri + response status
pub fn check(&self, uri: &str, status: u32) -> Option<&Probe> {
let uri = uri.to_ascii_lowercase();
let path = uri.split(['?', '#']).next().unwrap_or(&uri);
self.probes.iter().find(|p| {
let path_hit = match p.kind {
MatchKind::Exact => path == p.path,
MatchKind::Prefix => path.starts_with(&p.path),
MatchKind::Contains => uri.contains(&p.path),
};
path_hit && p.statuses.as_ref().is_none_or(|s| s.contains(&status))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_positive() {
// all straight out of real scanner traffic
let vectors = [
"/.env",
"/.env.backup",
"/config/.env",
"/%2E%2E%2f%2Eenv",
"/.git/config",
"/.aws/credentials",
"/phpinfo.php",
"/admin/phpinfo.php",
"/?phpinfo=1",
"/wp-includes/wlwmanifest.xml",
"/blog/wp-includes/wlwmanifest.xml",
"/wp-config.php.bak",
"/config.php",
"/google-credentials.json",
"/gcp-key.json",
"/docker-compose.yml",
"/_profiler/phpinfo",
"/_ignition/health-check",
"/storage/logs/laravel.log",
"/actuator/env",
"/webroot/index.php/_environment",
"/test.php",
"/i.php",
"/env",
"/phpMyAdmin/index.php",
"/cgi-bin/luci",
"/../.env",
"/server-status.php",
];
let pl = ProbeList::builtin();
vectors.iter().for_each(|e| {
assert!(pl.check(e, 200).is_some(), "should flag {}", e);
})
}
#[test]
fn builtin_negative() {
// real user traffic from the same logs
let vectors = [
"/",
"/db",
"/ui/",
"/ui/static/js/utils.js",
"/api/auth",
"/api/users/1",
"/api/channels/5/messages?after=1620&limit=200",
"/app.js",
"/config.json",
"/favicon.ico",
"/robots.txt",
"/.well-known/acme-challenge/token123",
"/api.php",
"/information",
"/environment-report",
// legit traffic on stacks the tight default must not break:
"/wp-login.php", // wordpress logins
"/xmlrpc.php", // wordpress apps/jetpack
"/?rest_route=/wp/v2/posts", // wordpress REST
"/actuator/health", // spring boot health checks
"/autodiscover/autodiscover.xml", // outlook probes any domain
"/solr/admin/ping", // hosted consoles
"/composer.json",
];
let pl = ProbeList::builtin();
vectors.iter().for_each(|e| {
assert!(pl.check(e, 200).is_none(), "should not flag {}", e);
})
}
#[test]
fn probelist_file() {
let raw = r#"{
"comment": "generated probelist",
"probes": [
{ "comment": "--- section separator, ignored ---" },
{ "path": "/.env" },
{ "path": "/test.php", "match": "exact" },
{ "path": "/api/auth", "match": "prefix", "status": "401,4xx", "allowance": 10, "comment": "stuffers" }
]
}"#;
let pl = ProbeList::from_json(raw).unwrap();
assert_eq!(pl.len(), 3);
// contains, any status
assert!(pl.check("/backend/.env", 200).is_some());
// exact, query-stripped
assert!(pl.check("/test.php?x=1", 200).is_some());
assert!(pl.check("/xtest.php", 200).is_none());
// prefix + status filter + allowance override
let hit = pl.check("/api/auth/me", 401).unwrap();
assert_eq!(hit.allowance, Some(10));
assert!(pl.check("/api/auth/me", 200).is_none());
assert!(pl.check("/api/authless", 401).is_some()); // prefix is a plain str prefix
}
#[test]
fn probelist_rejects_garbage() {
assert!(ProbeList::from_json("{}").is_err());
assert!(ProbeList::from_json(r#"{"probes": [{}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": ""}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": "/x", "match": "regex"}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": "/x", "status": 401}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": "/x", "status": "9xx"}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": "/x", "allowance": 300}]}"#).is_err());
assert!(ProbeList::from_json(r#"{"probes": [{"path": "/x", "allowance": 0}]}"#).is_err());
}
}
-104
View File
@@ -1,104 +0,0 @@
use anyhow::*;
use lazy_static::lazy_static;
use regex::Regex;
use std::net::IpAddr;
use std::str::FromStr;
use crate::utils::ParsingStatus;
struct Rule {
matcher: String,
extractor: Regex,
}
lazy_static! {
static ref SSHD_BAD: [Rule; 3] = [
Rule {
matcher: "Failed password".to_string(),
extractor: Regex::new(r"(from.)(.*)(.port)").unwrap(),
},
Rule {
matcher: "Invalid user ".to_string(),
extractor: Regex::new(r"(from.)(.*)").unwrap(),
},
Rule {
matcher: "authentication failure".to_string(),
extractor: Regex::new(r"(rhost=)(.*)").unwrap()
},
];
}
pub fn parse(line: &str) -> Result<ParsingStatus> {
let hits = SSHD_BAD.iter().find_map(|rule| {
if line.contains(&rule.matcher) {
rule.extractor.captures(line)
} else {
None
}
});
if hits.is_none() {
return Ok(ParsingStatus::OkEntry);
}
let ip = hits
.and_then(|c| c.get(2))
.and_then(|m| IpAddr::from_str(m.as_str()).ok());
match ip {
Some(ip) => Ok(ParsingStatus::BadEntry(ip)),
None => Err(anyhow!("cant parse sshd entry")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2",
"Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication failure; logname= u =0 tty=ssh ruser= rhost=5.101.107.190",
"Sep 26 06:25:32 livecompute sshd[23254]: Invalid user neal from 35.184.211.144"
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
match ret {
ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn negative() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: successful login 179.124.36.195 port 41883 ssh2",
"Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication total success; logname= u =0 tty=ssh ruser= rhost=5.101.107.190",
"Sep 26 06:25:32 livecompute sshd[23254]: very good user neal from 35.184.211.144"
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn malformed() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195.232 port 41883 ssh2",
"Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication failure; logname= u =0 tty=ssh ruser= rhost=",
];
vectors.iter().for_each(|e| {
let ret = parse(*e);
assert!(ret.is_err());
})
}
}
+190 -53
View File
@@ -1,61 +1,198 @@
use clap::{App, Arg};
use std::net::IpAddr;
use anyhow::{anyhow, bail, Context, Result};
use clap::{ArgGroup, Parser};
use regex::Regex;
use std::{
net::IpAddr,
path::{Path, PathBuf},
};
#[derive(Debug)]
pub enum ParsingStatus {
OkEntry,
BadEntry(IpAddr),
}
pub enum Judgment {
Good,
Remand,
Bad(&'static str, IpAddr),
// offending ip, plus an optional per-probe allowance override
BadEntry(IpAddr, Option<u8>),
}
pub enum JailStatus {
Remand,
Jailed(IpAddr),
pub fn get_epoch() -> u64 {
let e = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
e.map(|e| e.as_secs()).unwrap_or(0)
}
pub fn cli() -> App<'static, 'static> {
App::new("ban internets scanner fast 🍶")
.version("v0.0.1")
.author("pierre dubouilh <pldubouilh@gmail.com>")
// .arg(Arg::with_name("prune")
// .short("prune")
// .help("prune current logfiles to prefill banlist")
// .default_value("false")
// .takes_value(true))
.arg(
Arg::with_name("jailtime")
.short("j")
.help("jail time (seconds)")
.default_value("3600")
.takes_value(true),
)
.arg(
Arg::with_name("allowance")
.short("a")
.help("how many offences allowed (max 255")
.default_value("5")
.takes_value(true),
)
.arg(
Arg::with_name("sshd_logpath")
.short("sshd_logpath")
.help("path of sshd logfile (disable with empty path)")
.default_value("/var/log/auth.log")
.takes_value(true),
)
.arg(
Arg::with_name("clf_logpath")
.short("clf_logpath")
.help("path of Common-Log-Format (Apache, etc..) logfile")
.default_value("")
.takes_value(true),
)
// .arg(Arg::with_name("clf_bad_http_codes")
// .short("cb")
// .help("bad CLF http codes")
// .default_value("{401, 429}")
// .takes_value(true))
macro_rules! log{
($first:expr) => {
let ts = crate::utils::get_epoch();
eprintln!("{} - {}", ts, $first);
};
($first:expr, $($others:expr),+) => {
let ts = crate::utils::get_epoch();
let formatted = format!($first, $($others), *);
eprintln!("{} - {}", ts, formatted);
};
}
pub fn resolve_path(a: &str) -> Result<PathBuf> {
let p = Path::new(a);
if !p.exists() {
return Err(anyhow!("path {:?} does not exist", p));
}
let p = std::fs::canonicalize(p)?;
Ok(p)
}
pub fn parse_regex(a: &str) -> Result<Regex> {
let r: Regex = Regex::new(a).context("invalid regexp for generic parser")?;
Ok(r)
}
/// Parse a comma-separated list of HTTP statuses with trailing-`x` wildcards.
///
/// Accepted forms per token: `NNN` (literal), `NNx` (10-wide range), `Nxx` (100-wide range).
/// All expanded codes must fall in the valid HTTP range 100..=599.
pub fn parse_statuses(a: &str) -> Result<Vec<u32>> {
if a.trim().is_empty() {
bail!("invalid_http_statuses: empty input");
}
let mut statuses = vec![];
for raw in a.split(',') {
let s = raw.trim();
let xs = s.chars().rev().take_while(|c| *c == 'x').count();
let digits = &s[..s.len() - xs];
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
bail!("invalid http status `{}`", s);
}
let base: u32 = digits.parse().context("invalid status")?;
let span = 10u32.pow(xs as u32);
let from = base * span;
for code in from..(from + span) {
if !(100..=599).contains(&code) {
bail!("status {} out of valid HTTP range (100..=599)", code);
}
statuses.push(code);
}
}
Ok(statuses)
}
pub(crate) use log;
#[derive(Parser, Debug)]
#[command(
name = "Blockfast",
author = "pierre dubouilh <pldubouilh@gmail.com>",
arg_required_else_help = true,
version = option_env!("BLOCKFAST_VERS").unwrap_or("unknown"),
long_about = None,
about = "
Blockfast - block internets scanners fast 🍶
Author: pierre dubouilh <pldubouilh@gmail.com>
Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset.
It supports logs in Common-Log-Format (Apache, nginx, etc..), Caddy JSON and a generic logs parser.
Example:
# block invalid http statuses from caddy
./blockfast --caddy-logpath=/caddy/logs
# generic log parser example with a log text to flag, and a regex to parse the offending IP.
./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port'",
verbatim_doc_comment,
group(ArgGroup::new("generic_match").args(["generic_positive", "generic_negative"])),
)]
pub struct Args {
/// jail time (seconds)
#[clap(long, default_value = "21600")]
pub jailtime: u32,
/// how many offences allowed (max 255)
#[clap(long, default_value = "5")]
pub allowance: u8,
/// log all offences
#[clap(short, long)]
pub verbose: bool,
/// path of Common-Log-Format logfile (Apache, nginx, etc..), can be repeated
#[clap(long, value_parser = resolve_path)]
pub clf_logpath: Vec<PathBuf>,
/// path of Caddy JSON logfile, can be repeated
#[clap(long, value_parser = resolve_path)]
pub caddy_logpath: Vec<PathBuf>,
/// path of a probelist JSON file, replaces the built-in probe list (see README)
#[clap(long, value_parser = resolve_path)]
pub probelist: Option<PathBuf>,
/// generic parser log file path, can be repeated
#[clap(long, value_parser = resolve_path, requires_all = ["generic_ip", "generic_match"])]
pub generic_logpath: Vec<PathBuf>,
/// generic parser ip regex
#[clap(long, value_parser = parse_regex, requires = "generic_logpath")]
pub generic_ip: Option<Regex>,
/// generic parser positive - if a logline contains this, it is considered bad, the rest is good
#[clap(long, requires = "generic_logpath")]
pub generic_positive: Option<String>,
/// generic parser negative - if a logline contains this, it is considered good, the rest is bad
#[clap(long, requires = "generic_logpath")]
pub generic_negative: Option<String>,
/// also flag these http statuses (for CLF and Caddy logs), on top of the built-in
/// scanner-path detection. Coma separated list, accepts ranges with XX, e.g. "403,5xx".
/// Careful: many apps serve 4xx statuses to legitimate clients.
#[clap(long)]
pub invalid_http_statuses: Option<String>,
}
#[cfg(test)]
mod tests {
use super::parse_statuses;
#[test]
fn literal() {
assert_eq!(parse_statuses("401").unwrap(), vec![401]);
assert_eq!(parse_statuses("401,404,429").unwrap(), vec![401, 404, 429]);
}
#[test]
fn ten_range() {
assert_eq!(
parse_statuses("40x").unwrap(),
(400..410).collect::<Vec<_>>()
);
}
#[test]
fn hundred_range() {
assert_eq!(
parse_statuses("4xx").unwrap(),
(400..500).collect::<Vec<_>>()
);
}
#[test]
fn mixed() {
let got = parse_statuses("401, 40x, 5xx").unwrap();
assert_eq!(got.len(), 1 + 10 + 100);
assert_eq!(got[0], 401);
assert!(got.contains(&500));
assert!(got.contains(&599));
}
#[test]
fn rejects_out_of_range() {
// 5x → 50..60, none of which are valid HTTP
assert!(parse_statuses("5x").is_err());
}
#[test]
fn rejects_garbage() {
assert!(parse_statuses("").is_err());
assert!(parse_statuses("xxx").is_err());
assert!(parse_statuses("1x2").is_err());
assert!(parse_statuses("abc").is_err());
assert!(parse_statuses("401,,402").is_err());
}
}