primedefender-fastapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ .venv/
22
+ venv/
23
+ ENV/
24
+ .env
25
+ .env.local
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ .pytest_cache/
30
+ .mypy_cache/
31
+ .ruff_cache/
32
+ htmlcov/
33
+ .coverage
34
+ coverage.xml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PrimeDefender contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.4
2
+ Name: primedefender-fastapi
3
+ Version: 0.1.0
4
+ Summary: PrimeDefender security middleware for FastAPI: WAF-style detection, blocking, and incident reporting to the PrimeDefender bridge.
5
+ Project-URL: Homepage, https://github.com/primedefender/primedefender-fastapi
6
+ Project-URL: Repository, https://github.com/primedefender/primedefender-fastapi
7
+ Project-URL: Issues, https://github.com/primedefender/primedefender-fastapi/issues
8
+ Author: PrimeDefender contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: fastapi,intrusion-detection,middleware,primedefender,security,sqli,waf,xss
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Security
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: fastapi>=0.100.0
27
+ Requires-Dist: httpx>=0.25.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # primedefender-fastapi
34
+
35
+ **PrimeDefender** adds a security layer to [FastAPI](https://fastapi.tiangolo.com/) applications: it inspects incoming requests for common attack patterns, optionally blocks them, and sends structured incidents to your **PrimeDefender bridge** (for example for a live monitoring map).
36
+
37
+ This package publishes to PyPI as **`primedefender-fastapi`**. The import name is **`primedefender_fastapi`**.
38
+
39
+ ## Features
40
+
41
+ | Detection | Notes |
42
+ |-----------|--------|
43
+ | SQL injection | Signature-based |
44
+ | XSS | Signature-based |
45
+ | Brute force | Configurable window on auth paths |
46
+ | Path traversal | Signature-based |
47
+ | Command injection | Signature-based |
48
+ | File inclusion | Signature-based |
49
+ | DDoS / flood | Per-IP sliding window |
50
+ | Bot activity | User-agent heuristics + rate limit |
51
+ | Scanner | UA + path probes + rate limit |
52
+ | Suspicious request | Method / query / body heuristics (observe by default) |
53
+ | Auth bypass probe | Header / query patterns (observe by default) |
54
+
55
+ Blocked requests return JSON with HTTP `403` or `429` as appropriate. Incidents are **POST**ed to the bridge (default path **`/ingest`** if your `PRIMEDEFENDER_BRIDGE_URL` has no path).
56
+
57
+ ## Requirements
58
+
59
+ - Python **3.10+**
60
+ - A running **PrimeDefender bridge** that accepts the incident JSON (see your dashboard docs).
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install primedefender-fastapi
66
+ ```
67
+
68
+ For a local editable install while developing the package:
69
+
70
+ ```bash
71
+ pip install -e ./primedefender-fastapi
72
+ ```
73
+
74
+ ## Environment variables
75
+
76
+ Set these in your process environment or load them with `python-dotenv` **before** the app imports settings (see below).
77
+
78
+ | Variable | Required | Description |
79
+ |----------|----------|-------------|
80
+ | `PRIMEDEFENDER_BRIDGE_URL` | Yes* | Bridge base URL, e.g. `http://localhost:3000` (path `/ingest` is added if missing) |
81
+ | `PRIMEDEFENDER_API_KEY` | Yes* | API key sent as `X-Api-Key` / `Authorization: Bearer` |
82
+ | `PRIMEDEFENDER_SITE_ID` | Yes* | Site identifier in payloads |
83
+ | `PRIMEDEFENDER_SITE_LAT` | Recommended | Target latitude for map “to” pin |
84
+ | `PRIMEDEFENDER_SITE_LON` | Recommended | Target longitude |
85
+ | `PRIMEDEFENDER_SITE_REGION_LABEL` | Optional | Human label, e.g. `Indonesia, Bali` → `targetLabel = "{site_id} · {label}"` |
86
+ | `PRIMEDEFENDER_PRIVATE_SOURCE_LABEL` | Optional | Label for private/loopback IPs |
87
+ | `PRIMEDEFENDER_AUTH_BYPASS_MODE` | Optional | `observe` (default) or `block` |
88
+ | `PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE` | Optional | `observe` (default) or `block` |
89
+
90
+ \*If bridge URL, API key, or site id is missing, reporting is disabled (middleware still runs detections).
91
+
92
+ See `.env.example` in this repository for tuning knobs (`PRIMEDEFENDER_BODY_CAP_BYTES`, rate limits, GeoIP TTL, etc.).
93
+
94
+ ## FastAPI usage
95
+
96
+ **Minimal** (configuration only from environment):
97
+
98
+ ```python
99
+ from fastapi import FastAPI
100
+ from primedefender_fastapi import PrimeDefenderMiddleware
101
+
102
+ app = FastAPI()
103
+ app.add_middleware(PrimeDefenderMiddleware)
104
+ ```
105
+
106
+ Load `.env` early so variables exist when settings are first read:
107
+
108
+ ```python
109
+ from pathlib import Path
110
+ from dotenv import load_dotenv
111
+
112
+ load_dotenv(Path(__file__).resolve().parent / ".env")
113
+ ```
114
+
115
+ **Optional constructor overrides** (other fields still come from the environment):
116
+
117
+ ```python
118
+ app.add_middleware(
119
+ PrimeDefenderMiddleware,
120
+ site_label="Indonesia, Bali",
121
+ auth_bypass_mode="observe",
122
+ suspicious_request_mode="block",
123
+ )
124
+ ```
125
+
126
+ **Explicit settings object** (e.g. tests or multi-tenant):
127
+
128
+ ```python
129
+ from primedefender_fastapi import PrimeDefenderMiddleware, PrimeDefenderSettings
130
+
131
+ settings = PrimeDefenderSettings.from_env()
132
+ app.add_middleware(PrimeDefenderMiddleware, settings=settings)
133
+ ```
134
+
135
+ ## Connect to the PrimeDefender bridge
136
+
137
+ 1. Run your bridge (often on port `3000` or behind HTTPS).
138
+ 2. Set `PRIMEDEFENDER_BRIDGE_URL` to that origin, e.g. `http://localhost:3000`.
139
+ 3. Ensure the bridge exposes **`POST /ingest`** (or set the full URL including path).
140
+ 4. Use a valid `PRIMEDEFENDER_API_KEY` accepted by the bridge.
141
+
142
+ Health check (typical): `GET http://localhost:3000/health`.
143
+
144
+ ## Test SQLi / XSS locally
145
+
146
+ With the API on port `8000`:
147
+
148
+ **SQL injection (query)**
149
+
150
+ ```bash
151
+ curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --"
152
+ ```
153
+
154
+ **XSS**
155
+
156
+ ```bash
157
+ curl "http://127.0.0.1:8000/?q=%3Cscript%3Ealert(1)%3C/script%3E"
158
+ ```
159
+
160
+ **Map labels (optional test headers)**
161
+
162
+ ```bash
163
+ curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --" \
164
+ -H "X-Prime-Source-Lat: 34.6937" \
165
+ -H "X-Prime-Source-Lon: 135.5023" \
166
+ -H "X-Prime-Source-Label: Japan, Osaka"
167
+ ```
168
+
169
+ ## Building and publishing
170
+
171
+ Uses **hatchling** (PEP 517):
172
+
173
+ ```bash
174
+ pip install build
175
+ python -m build
176
+ ```
177
+
178
+ Upload `dist/*` to PyPI with `twine` (use API tokens and trusted publishing in CI in production).
179
+
180
+ ## License
181
+
182
+ MIT — see `LICENSE`.
183
+
184
+ ## Repository
185
+
186
+ Placeholder links are set in `pyproject.toml` (`Homepage` / `Repository`). Replace with your real GitHub URL before publishing.
@@ -0,0 +1,154 @@
1
+ # primedefender-fastapi
2
+
3
+ **PrimeDefender** adds a security layer to [FastAPI](https://fastapi.tiangolo.com/) applications: it inspects incoming requests for common attack patterns, optionally blocks them, and sends structured incidents to your **PrimeDefender bridge** (for example for a live monitoring map).
4
+
5
+ This package publishes to PyPI as **`primedefender-fastapi`**. The import name is **`primedefender_fastapi`**.
6
+
7
+ ## Features
8
+
9
+ | Detection | Notes |
10
+ |-----------|--------|
11
+ | SQL injection | Signature-based |
12
+ | XSS | Signature-based |
13
+ | Brute force | Configurable window on auth paths |
14
+ | Path traversal | Signature-based |
15
+ | Command injection | Signature-based |
16
+ | File inclusion | Signature-based |
17
+ | DDoS / flood | Per-IP sliding window |
18
+ | Bot activity | User-agent heuristics + rate limit |
19
+ | Scanner | UA + path probes + rate limit |
20
+ | Suspicious request | Method / query / body heuristics (observe by default) |
21
+ | Auth bypass probe | Header / query patterns (observe by default) |
22
+
23
+ Blocked requests return JSON with HTTP `403` or `429` as appropriate. Incidents are **POST**ed to the bridge (default path **`/ingest`** if your `PRIMEDEFENDER_BRIDGE_URL` has no path).
24
+
25
+ ## Requirements
26
+
27
+ - Python **3.10+**
28
+ - A running **PrimeDefender bridge** that accepts the incident JSON (see your dashboard docs).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install primedefender-fastapi
34
+ ```
35
+
36
+ For a local editable install while developing the package:
37
+
38
+ ```bash
39
+ pip install -e ./primedefender-fastapi
40
+ ```
41
+
42
+ ## Environment variables
43
+
44
+ Set these in your process environment or load them with `python-dotenv` **before** the app imports settings (see below).
45
+
46
+ | Variable | Required | Description |
47
+ |----------|----------|-------------|
48
+ | `PRIMEDEFENDER_BRIDGE_URL` | Yes* | Bridge base URL, e.g. `http://localhost:3000` (path `/ingest` is added if missing) |
49
+ | `PRIMEDEFENDER_API_KEY` | Yes* | API key sent as `X-Api-Key` / `Authorization: Bearer` |
50
+ | `PRIMEDEFENDER_SITE_ID` | Yes* | Site identifier in payloads |
51
+ | `PRIMEDEFENDER_SITE_LAT` | Recommended | Target latitude for map “to” pin |
52
+ | `PRIMEDEFENDER_SITE_LON` | Recommended | Target longitude |
53
+ | `PRIMEDEFENDER_SITE_REGION_LABEL` | Optional | Human label, e.g. `Indonesia, Bali` → `targetLabel = "{site_id} · {label}"` |
54
+ | `PRIMEDEFENDER_PRIVATE_SOURCE_LABEL` | Optional | Label for private/loopback IPs |
55
+ | `PRIMEDEFENDER_AUTH_BYPASS_MODE` | Optional | `observe` (default) or `block` |
56
+ | `PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE` | Optional | `observe` (default) or `block` |
57
+
58
+ \*If bridge URL, API key, or site id is missing, reporting is disabled (middleware still runs detections).
59
+
60
+ See `.env.example` in this repository for tuning knobs (`PRIMEDEFENDER_BODY_CAP_BYTES`, rate limits, GeoIP TTL, etc.).
61
+
62
+ ## FastAPI usage
63
+
64
+ **Minimal** (configuration only from environment):
65
+
66
+ ```python
67
+ from fastapi import FastAPI
68
+ from primedefender_fastapi import PrimeDefenderMiddleware
69
+
70
+ app = FastAPI()
71
+ app.add_middleware(PrimeDefenderMiddleware)
72
+ ```
73
+
74
+ Load `.env` early so variables exist when settings are first read:
75
+
76
+ ```python
77
+ from pathlib import Path
78
+ from dotenv import load_dotenv
79
+
80
+ load_dotenv(Path(__file__).resolve().parent / ".env")
81
+ ```
82
+
83
+ **Optional constructor overrides** (other fields still come from the environment):
84
+
85
+ ```python
86
+ app.add_middleware(
87
+ PrimeDefenderMiddleware,
88
+ site_label="Indonesia, Bali",
89
+ auth_bypass_mode="observe",
90
+ suspicious_request_mode="block",
91
+ )
92
+ ```
93
+
94
+ **Explicit settings object** (e.g. tests or multi-tenant):
95
+
96
+ ```python
97
+ from primedefender_fastapi import PrimeDefenderMiddleware, PrimeDefenderSettings
98
+
99
+ settings = PrimeDefenderSettings.from_env()
100
+ app.add_middleware(PrimeDefenderMiddleware, settings=settings)
101
+ ```
102
+
103
+ ## Connect to the PrimeDefender bridge
104
+
105
+ 1. Run your bridge (often on port `3000` or behind HTTPS).
106
+ 2. Set `PRIMEDEFENDER_BRIDGE_URL` to that origin, e.g. `http://localhost:3000`.
107
+ 3. Ensure the bridge exposes **`POST /ingest`** (or set the full URL including path).
108
+ 4. Use a valid `PRIMEDEFENDER_API_KEY` accepted by the bridge.
109
+
110
+ Health check (typical): `GET http://localhost:3000/health`.
111
+
112
+ ## Test SQLi / XSS locally
113
+
114
+ With the API on port `8000`:
115
+
116
+ **SQL injection (query)**
117
+
118
+ ```bash
119
+ curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --"
120
+ ```
121
+
122
+ **XSS**
123
+
124
+ ```bash
125
+ curl "http://127.0.0.1:8000/?q=%3Cscript%3Ealert(1)%3C/script%3E"
126
+ ```
127
+
128
+ **Map labels (optional test headers)**
129
+
130
+ ```bash
131
+ curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --" \
132
+ -H "X-Prime-Source-Lat: 34.6937" \
133
+ -H "X-Prime-Source-Lon: 135.5023" \
134
+ -H "X-Prime-Source-Label: Japan, Osaka"
135
+ ```
136
+
137
+ ## Building and publishing
138
+
139
+ Uses **hatchling** (PEP 517):
140
+
141
+ ```bash
142
+ pip install build
143
+ python -m build
144
+ ```
145
+
146
+ Upload `dist/*` to PyPI with `twine` (use API tokens and trusted publishing in CI in production).
147
+
148
+ ## License
149
+
150
+ MIT — see `LICENSE`.
151
+
152
+ ## Repository
153
+
154
+ Placeholder links are set in `pyproject.toml` (`Homepage` / `Repository`). Replace with your real GitHub URL before publishing.
@@ -0,0 +1,18 @@
1
+ """
2
+ PrimeDefender FastAPI middleware: request inspection, blocking, and bridge reporting.
3
+ """
4
+
5
+ from primedefender_fastapi.config import PrimeDefenderSettings, clear_settings_cache, load_settings
6
+ from primedefender_fastapi.detectors import Detection
7
+ from primedefender_fastapi.middleware import PrimeDefenderMiddleware
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "PrimeDefenderMiddleware",
13
+ "PrimeDefenderSettings",
14
+ "Detection",
15
+ "load_settings",
16
+ "clear_settings_cache",
17
+ "__version__",
18
+ ]
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from functools import lru_cache
6
+ from typing import Any, Literal, Tuple
7
+ from urllib.parse import urlparse
8
+
9
+ Mode = Literal["observe", "block"]
10
+
11
+
12
+ def _env_str(key: str, default: str = "") -> str:
13
+ raw = os.getenv(key)
14
+ if raw is None:
15
+ return default
16
+ return raw.strip()
17
+
18
+
19
+ def _env_float(key: str, default: float) -> float:
20
+ raw = os.getenv(key)
21
+ if raw is None or raw == "":
22
+ return default
23
+ try:
24
+ return float(raw)
25
+ except ValueError:
26
+ return default
27
+
28
+
29
+ def _env_int(key: str, default: int) -> int:
30
+ raw = os.getenv(key)
31
+ if raw is None or raw == "":
32
+ return default
33
+ try:
34
+ return int(raw)
35
+ except ValueError:
36
+ return default
37
+
38
+
39
+ def _env_bool(key: str, default: bool = False) -> bool:
40
+ raw = os.getenv(key)
41
+ if raw is None or raw == "":
42
+ return default
43
+ return raw.lower() in ("1", "true", "yes", "on")
44
+
45
+
46
+ def _env_mode(key: str, default: Mode) -> Mode:
47
+ raw = (os.getenv(key) or "").strip().lower()
48
+ if raw in ("observe", "block"):
49
+ return raw # type: ignore[return-value]
50
+ return default
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PrimeDefenderSettings:
55
+ """Configuration for middleware and bridge reporting. Usually built via ``from_env()``."""
56
+
57
+ bridge_url: str
58
+ api_key: str
59
+ site_id: str
60
+ site_lat: float
61
+ site_lon: float
62
+ site_region_label: str
63
+ private_source_label: str
64
+ body_cap_bytes: int
65
+ geoip_ttl_seconds: int
66
+ geoip_timeout_seconds: float
67
+ bridge_timeout_seconds: float
68
+ flood_window_seconds: int
69
+ flood_max_requests: int
70
+ brute_force_window_seconds: int
71
+ brute_force_max_attempts: int
72
+ bot_window_seconds: int
73
+ bot_max_requests: int
74
+ scanner_window_seconds: int
75
+ scanner_max_requests: int
76
+ auth_bypass_mode: Mode
77
+ suspicious_request_mode: Mode
78
+ debug: bool
79
+
80
+ @property
81
+ def observe_only_detections(self) -> Tuple[str, ...]:
82
+ names: list[str] = []
83
+ if self.auth_bypass_mode == "observe":
84
+ names.append("auth_bypass")
85
+ if self.suspicious_request_mode == "observe":
86
+ names.append("suspicious_request")
87
+ return tuple(names)
88
+
89
+ @property
90
+ def resolved_bridge_url(self) -> str:
91
+ """POST target. If env is only origin (path empty or `/`), ``/ingest`` is appended."""
92
+ raw = (self.bridge_url or "").strip()
93
+ if not raw:
94
+ return raw
95
+ parsed = urlparse(raw)
96
+ path = parsed.path or ""
97
+ if path in ("", "/"):
98
+ return raw.rstrip("/") + "/ingest"
99
+ return raw
100
+
101
+ @property
102
+ def enabled(self) -> bool:
103
+ return bool(self.bridge_url and self.api_key and self.site_id)
104
+
105
+ @classmethod
106
+ def from_env(cls, **overrides: Any) -> PrimeDefenderSettings:
107
+ """Load from environment variables, then apply ``overrides`` (non-``None`` keys win)."""
108
+ data: dict[str, Any] = {
109
+ "bridge_url": _env_str("PRIMEDEFENDER_BRIDGE_URL"),
110
+ "api_key": _env_str("PRIMEDEFENDER_API_KEY"),
111
+ "site_id": _env_str("PRIMEDEFENDER_SITE_ID", "primestudio-api"),
112
+ "site_lat": _env_float("PRIMEDEFENDER_SITE_LAT", -8.6705),
113
+ "site_lon": _env_float("PRIMEDEFENDER_SITE_LON", 115.2126),
114
+ "site_region_label": _env_str("PRIMEDEFENDER_SITE_REGION_LABEL", "Indonesia, Bali") or "Indonesia, Bali",
115
+ "private_source_label": _env_str("PRIMEDEFENDER_PRIVATE_SOURCE_LABEL", "Local / private network")
116
+ or "Local / private network",
117
+ "body_cap_bytes": _env_int("PRIMEDEFENDER_BODY_CAP_BYTES", 16_384),
118
+ "geoip_ttl_seconds": _env_int("PRIMEDEFENDER_GEOIP_TTL_SECONDS", 3600),
119
+ "geoip_timeout_seconds": _env_float("PRIMEDEFENDER_GEOIP_TIMEOUT_SECONDS", 2.5),
120
+ "bridge_timeout_seconds": _env_float("PRIMEDEFENDER_BRIDGE_TIMEOUT_SECONDS", 3.0),
121
+ "flood_window_seconds": _env_int("PRIMEDEFENDER_FLOOD_WINDOW_SECONDS", 10),
122
+ "flood_max_requests": _env_int("PRIMEDEFENDER_FLOOD_MAX_REQUESTS", 60),
123
+ "brute_force_window_seconds": _env_int("PRIMEDEFENDER_BRUTE_WINDOW_SECONDS", 300),
124
+ "brute_force_max_attempts": _env_int("PRIMEDEFENDER_BRUTE_MAX_ATTEMPTS", 12),
125
+ "bot_window_seconds": _env_int("PRIMEDEFENDER_BOT_WINDOW_SECONDS", 60),
126
+ "bot_max_requests": _env_int("PRIMEDEFENDER_BOT_MAX_REQUESTS", 30),
127
+ "scanner_window_seconds": _env_int("PRIMEDEFENDER_SCANNER_WINDOW_SECONDS", 300),
128
+ "scanner_max_requests": _env_int("PRIMEDEFENDER_SCANNER_MAX_REQUESTS", 12),
129
+ "auth_bypass_mode": _env_mode("PRIMEDEFENDER_AUTH_BYPASS_MODE", "observe"),
130
+ "suspicious_request_mode": _env_mode("PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE", "observe"),
131
+ "debug": _env_bool("PRIMEDEFENDER_DEBUG"),
132
+ }
133
+ alias_map = {
134
+ "site_label": "site_region_label",
135
+ }
136
+ for key, value in overrides.items():
137
+ if value is None:
138
+ continue
139
+ target = alias_map.get(key, key)
140
+ if target not in data:
141
+ continue
142
+ if target in ("auth_bypass_mode", "suspicious_request_mode"):
143
+ v = str(value).lower()
144
+ if v in ("observe", "block"):
145
+ data[target] = v
146
+ continue
147
+ data[target] = value
148
+ return cls(**data) # type: ignore[arg-type]
149
+
150
+
151
+ @lru_cache(maxsize=1)
152
+ def load_settings() -> PrimeDefenderSettings:
153
+ """Cached settings from environment (call after ``load_dotenv()`` in the host app)."""
154
+ return PrimeDefenderSettings.from_env()
155
+
156
+
157
+ def clear_settings_cache() -> None:
158
+ """Mainly for tests."""
159
+ load_settings.cache_clear()
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, List, Optional, Pattern
6
+
7
+ from primedefender_fastapi.config import PrimeDefenderSettings
8
+ from primedefender_fastapi.rate_limit import SlidingWindowLimiter
9
+
10
+
11
+ @dataclass
12
+ class Detection:
13
+ name: str
14
+ category: str
15
+ severity: str
16
+ blocked: bool
17
+ action: str
18
+ status_code: int
19
+ detail: str
20
+
21
+
22
+ SQLI_PATTERNS = (
23
+ r"(?i)\bunion\b.{0,20}\bselect\b",
24
+ r"(?i)\bselect\b.{0,20}\bfrom\b",
25
+ r"(?i)\binformation_schema\b",
26
+ r"(?i)\bor\b\s+1=1",
27
+ r"(?i)'\s*or\s*'1'='1",
28
+ r"(?i)\bsleep\s*\(",
29
+ r"(?i)\bbenchmark\s*\(",
30
+ r"(?i)\bdrop\s+table\b",
31
+ r"(?i)\bwaitfor\s+delay\b",
32
+ )
33
+ XSS_PATTERNS = (
34
+ r"(?i)<script\b",
35
+ r"(?i)javascript:",
36
+ r"(?i)onerror\s*=",
37
+ r"(?i)onload\s*=",
38
+ r"(?i)<svg\b",
39
+ r"(?i)<img\b",
40
+ r"(?i)document\.cookie",
41
+ r"(?i)alert\s*\(",
42
+ )
43
+ PATH_TRAVERSAL_PATTERNS = (
44
+ r"\.\./",
45
+ r"\.\.\\",
46
+ r"%2e%2e%2f",
47
+ r"%252e%252e%252f",
48
+ r"/etc/passwd",
49
+ r"win\.ini",
50
+ r"/proc/self/environ",
51
+ )
52
+ COMMAND_INJECTION_PATTERNS = (
53
+ r"(?i)(?:;|\|\||&&)\s*(?:curl|wget|bash|sh|powershell|cmd\.exe|nc)\b",
54
+ r"`[^`]+`",
55
+ r"\$\([^)]+\)",
56
+ r"(?i)\b(?:cmd\.exe|/bin/sh|powershell)\b",
57
+ )
58
+ FILE_INCLUSION_PATTERNS = (
59
+ r"(?i)\b(?:php|file|zip|data|expect)://",
60
+ r"(?i)\b(?:web-inf|phpmyadmin|wp-config\.php|\.git/config)\b",
61
+ r"(?i)\b(?:include|require)(_once)?\b",
62
+ )
63
+ SCANNER_UA_MARKERS = (
64
+ "sqlmap",
65
+ "nikto",
66
+ "nmap",
67
+ "nessus",
68
+ "dirbuster",
69
+ "gobuster",
70
+ "feroxbuster",
71
+ "wafw00f",
72
+ "masscan",
73
+ "nuclei",
74
+ "acunetix",
75
+ "burp",
76
+ "zaproxy",
77
+ )
78
+ BOT_UA_MARKERS = (
79
+ "python-requests",
80
+ "curl/",
81
+ "wget/",
82
+ "aiohttp",
83
+ "scrapy",
84
+ "httpclient",
85
+ "okhttp",
86
+ "libwww",
87
+ "urllib",
88
+ "bot",
89
+ "crawler",
90
+ "spider",
91
+ )
92
+ SCANNER_PATH_MARKERS = (
93
+ "/.env",
94
+ "/.git",
95
+ "/wp-admin",
96
+ "/wp-login.php",
97
+ "/phpmyadmin",
98
+ "/cgi-bin",
99
+ "/actuator",
100
+ "/vendor/phpunit",
101
+ "/boaform",
102
+ )
103
+ AUTH_BYPASS_MARKERS = (
104
+ "x-original-url",
105
+ "x-rewrite-url",
106
+ "x-forwarded-host",
107
+ "x-host",
108
+ "x-http-method-override",
109
+ )
110
+ AUTH_PATHS = (
111
+ "/auth/login",
112
+ "/auth/register",
113
+ "/auth/forgot-password",
114
+ "/auth/reset-password",
115
+ "/auth/reset-password-with-code",
116
+ "/auth/verify-reset-code",
117
+ )
118
+
119
+
120
+ def compile_pattern_buckets() -> Dict[str, List[Pattern[str]]]:
121
+ return {
122
+ "sqli": [re.compile(p) for p in SQLI_PATTERNS],
123
+ "xss": [re.compile(p) for p in XSS_PATTERNS],
124
+ "path_traversal": [re.compile(p, re.IGNORECASE) for p in PATH_TRAVERSAL_PATTERNS],
125
+ "command_injection": [re.compile(p) for p in COMMAND_INJECTION_PATTERNS],
126
+ "file_inclusion": [re.compile(p) for p in FILE_INCLUSION_PATTERNS],
127
+ }
128
+
129
+
130
+ class RequestInspector:
131
+ """Stateful rate limits + signature detection for one app instance."""
132
+
133
+ def __init__(self, settings: PrimeDefenderSettings) -> None:
134
+ self.settings = settings
135
+ self.flood_limiter = SlidingWindowLimiter()
136
+ self.brute_force_limiter = SlidingWindowLimiter()
137
+ self.bot_limiter = SlidingWindowLimiter()
138
+ self.scanner_limiter = SlidingWindowLimiter()
139
+ self._compiled = compile_pattern_buckets()
140
+
141
+ def inspect(self, meta: Dict[str, Any]) -> Optional[Detection]:
142
+ ip = meta["client_ip"]
143
+ method = meta["method"]
144
+ path = meta["decoded_path"].lower()
145
+ query = meta["decoded_query"].lower()
146
+ combined = meta["combined"]
147
+ ua = meta["user_agent"].lower()
148
+ headers = meta["headers"]
149
+
150
+ flood_count = self.flood_limiter.hit(ip, self.settings.flood_window_seconds)
151
+ if flood_count > self.settings.flood_max_requests:
152
+ return self._decision("ddos", "Application flood limit exceeded.", blocked=True)
153
+
154
+ if method == "POST" and path in AUTH_PATHS:
155
+ brute_count = self.brute_force_limiter.hit(
156
+ f"{ip}:{path}", self.settings.brute_force_window_seconds
157
+ )
158
+ if brute_count > self.settings.brute_force_max_attempts:
159
+ return self._decision("brute_force_login", "Brute force login pattern detected.", blocked=True)
160
+
161
+ if any(marker in ua for marker in SCANNER_UA_MARKERS) or any(
162
+ marker in path for marker in SCANNER_PATH_MARKERS
163
+ ):
164
+ scanner_count = self.scanner_limiter.hit(ip, self.settings.scanner_window_seconds)
165
+ if scanner_count >= self.settings.scanner_max_requests:
166
+ return self._decision("scanner_activity", "Scanner activity detected.", blocked=True)
167
+
168
+ if any(marker in ua for marker in BOT_UA_MARKERS):
169
+ bot_count = self.bot_limiter.hit(ip, self.settings.bot_window_seconds)
170
+ if bot_count > self.settings.bot_max_requests:
171
+ return self._decision("bot_activity", "Automated bot activity detected.", blocked=True)
172
+
173
+ if self._matches("path_traversal", combined):
174
+ return self._decision("path_traversal", "Path traversal signature matched.", blocked=True)
175
+ if self._matches("file_inclusion", combined):
176
+ return self._decision("file_inclusion", "File inclusion signature matched.", blocked=True)
177
+ if self._matches("command_injection", combined):
178
+ return self._decision("command_injection", "Command injection signature matched.", blocked=True)
179
+ if self._matches("sqli", combined):
180
+ return self._decision("sqli", "SQL injection signature matched.", blocked=True)
181
+ if self._matches("xss", combined):
182
+ return self._decision("xss", "XSS signature matched.", blocked=True)
183
+
184
+ if (
185
+ any(header in headers for header in AUTH_BYPASS_MARKERS)
186
+ or "admin=true" in query
187
+ or "role=admin" in query
188
+ ):
189
+ return self._decision("auth_bypass", "Authorization bypass probe observed.", blocked=False)
190
+
191
+ suspicious_method = method in {"TRACE", "CONNECT"}
192
+ suspicious_query = len(meta["query"]) > 2048 or "%25" in meta["query"]
193
+ suspicious_body = meta["body_size"] > self.settings.body_cap_bytes
194
+ if suspicious_method or suspicious_query or suspicious_body or not ua:
195
+ return self._decision("suspicious_request", "Suspicious request observed.", blocked=False)
196
+
197
+ return None
198
+
199
+ def _decision(self, name: str, detail: str, blocked: bool) -> Detection:
200
+ observe_only = name in self.settings.observe_only_detections
201
+ effective_blocked = blocked and not observe_only
202
+ action = "blocked" if effective_blocked else "observed"
203
+
204
+ if name == "ddos":
205
+ return Detection(name, "ddos", "critical", effective_blocked, action, 429, detail)
206
+ if name in {"scanner_activity", "brute_force_login"}:
207
+ return Detection(name, "intrusion", "high", effective_blocked, action, 429, detail)
208
+ if name == "bot_activity":
209
+ return Detection(name, "botnet", "medium", effective_blocked, action, 429, detail)
210
+ if name == "command_injection":
211
+ return Detection(name, "malware", "critical", effective_blocked, action, 403, detail)
212
+ if name in {"sqli", "xss", "path_traversal", "file_inclusion"}:
213
+ return Detection(name, "intrusion", "high", effective_blocked, action, 403, detail)
214
+ if name == "auth_bypass":
215
+ return Detection(name, "intrusion", "medium", effective_blocked, action, 403, detail)
216
+ return Detection(name, "unknown", "low", effective_blocked, action, 403, detail)
217
+
218
+ def _matches(self, bucket: str, content: str) -> bool:
219
+ return any(pattern.search(content) for pattern in self._compiled[bucket])
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import time
5
+ from typing import Dict, Optional, Tuple
6
+
7
+ import httpx
8
+
9
+
10
+ def is_private_ip(ip: str) -> bool:
11
+ try:
12
+ return ipaddress.ip_address(ip).is_private
13
+ except ValueError:
14
+ return True
15
+
16
+
17
+ def format_ip_location_label(
18
+ country: Optional[str],
19
+ region_name: Optional[str],
20
+ city: Optional[str],
21
+ ) -> Optional[str]:
22
+ """Human-readable place name, e.g. 'Japan, Osaka' or 'United States, Virginia'."""
23
+ if not country:
24
+ return None
25
+ if country == "United States":
26
+ second = region_name or city
27
+ else:
28
+ second = city or region_name
29
+ if second:
30
+ return f"{country}, {second}"
31
+ return country
32
+
33
+
34
+ class GeoIPCache:
35
+ def __init__(self, ttl_seconds: int, timeout_seconds: float) -> None:
36
+ self.ttl_seconds = ttl_seconds
37
+ self.timeout_seconds = timeout_seconds
38
+ self._cache: Dict[str, Tuple[float, Tuple[Optional[float], Optional[float], Optional[str]]]] = {}
39
+
40
+ async def get(self, ip: str) -> Tuple[Optional[float], Optional[float], Optional[str]]:
41
+ if not ip or is_private_ip(ip):
42
+ return (None, None, None)
43
+
44
+ now = time.time()
45
+ cached = self._cache.get(ip)
46
+ if cached and cached[0] > now:
47
+ return cached[1]
48
+
49
+ lat: Optional[float] = None
50
+ lon: Optional[float] = None
51
+ label: Optional[str] = None
52
+ url = f"http://ip-api.com/json/{ip}?fields=status,country,regionName,city,lat,lon"
53
+
54
+ try:
55
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
56
+ response = await client.get(url)
57
+ response.raise_for_status()
58
+ data = response.json()
59
+ if data.get("status") == "success":
60
+ lat = data.get("lat")
61
+ lon = data.get("lon")
62
+ label = format_ip_location_label(
63
+ data.get("country"),
64
+ data.get("regionName"),
65
+ data.get("city"),
66
+ )
67
+ except Exception:
68
+ pass
69
+
70
+ triple = (lat, lon, label)
71
+ self._cache[ip] = (now + self.ttl_seconds, triple)
72
+ return triple
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Dict, Literal, Optional
5
+
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.requests import Request
9
+ from starlette.types import ASGIApp
10
+ from urllib.parse import unquote_plus
11
+
12
+ from primedefender_fastapi.config import PrimeDefenderSettings, load_settings
13
+ from primedefender_fastapi.geo import GeoIPCache
14
+ from primedefender_fastapi.reporter import report_incident
15
+ from primedefender_fastapi.detectors import RequestInspector
16
+
17
+ Mode = Literal["observe", "block"]
18
+
19
+
20
+ class PrimeDefenderMiddleware(BaseHTTPMiddleware):
21
+ """
22
+ Security inspection + optional blocking + PrimeDefender bridge reporting.
23
+
24
+ Usage::
25
+
26
+ app.add_middleware(PrimeDefenderMiddleware)
27
+
28
+ With optional overrides (still reads other options from environment)::
29
+
30
+ app.add_middleware(
31
+ PrimeDefenderMiddleware,
32
+ site_label=\"Indonesia, Bali\",
33
+ auth_bypass_mode=\"observe\",
34
+ suspicious_request_mode=\"block\",
35
+ )
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ app: ASGIApp,
41
+ *,
42
+ settings: Optional[PrimeDefenderSettings] = None,
43
+ site_label: Optional[str] = None,
44
+ auth_bypass_mode: Optional[Mode] = None,
45
+ suspicious_request_mode: Optional[Mode] = None,
46
+ ) -> None:
47
+ super().__init__(app)
48
+ if settings is not None:
49
+ self.settings = settings
50
+ else:
51
+ overrides: Dict[str, Any] = {}
52
+ if site_label is not None:
53
+ overrides["site_region_label"] = site_label
54
+ if auth_bypass_mode is not None:
55
+ overrides["auth_bypass_mode"] = auth_bypass_mode
56
+ if suspicious_request_mode is not None:
57
+ overrides["suspicious_request_mode"] = suspicious_request_mode
58
+ self.settings = (
59
+ PrimeDefenderSettings.from_env(**overrides) if overrides else load_settings()
60
+ )
61
+ self.geoip = GeoIPCache(
62
+ ttl_seconds=self.settings.geoip_ttl_seconds,
63
+ timeout_seconds=self.settings.geoip_timeout_seconds,
64
+ )
65
+ self._inspector = RequestInspector(self.settings)
66
+
67
+ async def dispatch(self, request: Request, call_next):
68
+ if request.method.upper() == "OPTIONS":
69
+ return await call_next(request)
70
+
71
+ raw_body = await request.body()
72
+ inspected_body = raw_body[: self.settings.body_cap_bytes]
73
+ body_text = inspected_body.decode("utf-8", errors="ignore")
74
+ request = self._clone_request(request, raw_body)
75
+
76
+ client_ip = self._extract_client_ip(request)
77
+ metadata = self._build_metadata(request, client_ip, body_text, len(raw_body))
78
+ detection = self._inspector.inspect(metadata)
79
+
80
+ if detection:
81
+ if detection.blocked:
82
+ await report_incident(self.settings, self.geoip, detection, metadata)
83
+ return JSONResponse(
84
+ status_code=detection.status_code,
85
+ content={
86
+ "detail": "Request blocked by PrimeDefender security middleware.",
87
+ "detection": detection.name,
88
+ "action": detection.action,
89
+ },
90
+ )
91
+ asyncio.create_task(report_incident(self.settings, self.geoip, detection, metadata))
92
+
93
+ return await call_next(request)
94
+
95
+ def _clone_request(self, request: Request, body: bytes) -> Request:
96
+ async def receive() -> Dict[str, Any]:
97
+ return {"type": "http.request", "body": body, "more_body": False}
98
+
99
+ return Request(request.scope, receive)
100
+
101
+ def _build_metadata(
102
+ self, request: Request, client_ip: str, body_text: str, body_size: int
103
+ ) -> Dict[str, Any]:
104
+ headers = {k.lower(): v for k, v in request.headers.items()}
105
+ method = request.method.upper()
106
+ path = request.url.path
107
+ query = request.url.query or ""
108
+ decoded_query = unquote_plus(query)
109
+ decoded_path = unquote_plus(path)
110
+ user_agent = headers.get("user-agent", "")
111
+ combined = "\n".join(
112
+ [
113
+ decoded_path,
114
+ decoded_query,
115
+ body_text,
116
+ " ".join(f"{k}:{v}" for k, v in headers.items()),
117
+ ]
118
+ )
119
+ return {
120
+ "method": method,
121
+ "path": path,
122
+ "decoded_path": decoded_path,
123
+ "query": query,
124
+ "decoded_query": decoded_query,
125
+ "headers": headers,
126
+ "user_agent": user_agent,
127
+ "body_text": body_text,
128
+ "body_size": body_size,
129
+ "client_ip": client_ip,
130
+ "combined": combined,
131
+ }
132
+
133
+ def _extract_client_ip(self, request: Request) -> str:
134
+ hdr = request.headers
135
+ for name in ("cf-connecting-ip", "x-real-ip"):
136
+ value = hdr.get(name)
137
+ if value:
138
+ return value.strip()
139
+
140
+ forwarded_for = hdr.get("x-forwarded-for")
141
+ if forwarded_for:
142
+ first = forwarded_for.split(",")[0].strip()
143
+ if first:
144
+ return first
145
+
146
+ if request.client and request.client.host:
147
+ return request.client.host
148
+ return "unknown"
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections import defaultdict, deque
5
+ from typing import Deque, Dict
6
+
7
+
8
+ class SlidingWindowLimiter:
9
+ def __init__(self) -> None:
10
+ self._events: Dict[str, Deque[float]] = defaultdict(deque)
11
+
12
+ def hit(self, key: str, window_seconds: int) -> int:
13
+ now = time.time()
14
+ bucket = self._events[key]
15
+ cutoff = now - window_seconds
16
+ while bucket and bucket[0] < cutoff:
17
+ bucket.popleft()
18
+ bucket.append(now)
19
+ return len(bucket)
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+ import httpx
8
+
9
+ from primedefender_fastapi.config import PrimeDefenderSettings
10
+ from primedefender_fastapi.detectors import Detection
11
+ from primedefender_fastapi.geo import GeoIPCache, is_private_ip
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def parse_header_float(raw: Optional[str]) -> Optional[float]:
17
+ if raw is None or not str(raw).strip():
18
+ return None
19
+ try:
20
+ return float(str(raw).strip())
21
+ except ValueError:
22
+ return None
23
+
24
+
25
+ def prime_header_overrides(meta: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], str]:
26
+ h = meta["headers"]
27
+ lat = parse_header_float(h.get("x-prime-source-lat"))
28
+ lon = parse_header_float(h.get("x-prime-source-lon"))
29
+ label = (h.get("x-prime-source-label") or "").strip()
30
+ return lat, lon, label
31
+
32
+
33
+ async def report_incident(
34
+ settings: PrimeDefenderSettings,
35
+ geo: GeoIPCache,
36
+ detection: Detection,
37
+ meta: Dict[str, Any],
38
+ ) -> None:
39
+ if not settings.enabled:
40
+ logger.debug("PrimeDefender: skip bridge (set PRIMEDEFENDER_BRIDGE_URL, API_KEY, SITE_ID)")
41
+ return
42
+
43
+ url = settings.resolved_bridge_url
44
+ if settings.debug:
45
+ logger.debug("PrimeDefender: POST %s detection=%s", url, detection.name)
46
+
47
+ client_ip = meta["client_ip"]
48
+ oh_lat, oh_lon, oh_label = prime_header_overrides(meta)
49
+ has_coord_override = oh_lat is not None and oh_lon is not None
50
+ has_label_override = bool(oh_label)
51
+
52
+ geo_lat: Optional[float] = None
53
+ geo_lon: Optional[float] = None
54
+ geo_label: Optional[str] = None
55
+ if not has_coord_override:
56
+ geo_lat, geo_lon, geo_label = await geo.get(client_ip)
57
+
58
+ if has_coord_override:
59
+ attacker_lat, attacker_lon = oh_lat, oh_lon
60
+ else:
61
+ attacker_lat, attacker_lon = geo_lat, geo_lon
62
+
63
+ if attacker_lat is None or attacker_lon is None:
64
+ attacker_lat = 0.0
65
+ attacker_lon = 0.0
66
+
67
+ if has_label_override:
68
+ source_label = oh_label
69
+ elif has_coord_override and not has_label_override:
70
+ source_label = f"{float(attacker_lat):.3f}, {float(attacker_lon):.3f}"
71
+ elif is_private_ip(client_ip):
72
+ source_label = settings.private_source_label
73
+ elif geo_label:
74
+ source_label = geo_label
75
+ else:
76
+ source_label = f"Unknown location ({client_ip})"
77
+
78
+ site_region = settings.site_region_label
79
+ target_label = f"{settings.site_id} · {site_region}"
80
+
81
+ payload: Dict[str, Any] = {
82
+ "from": {
83
+ "lat": float(attacker_lat),
84
+ "lon": float(attacker_lon),
85
+ },
86
+ "to": {
87
+ "lat": float(settings.site_lat),
88
+ "lon": float(settings.site_lon),
89
+ },
90
+ "category": detection.category,
91
+ "severity": detection.severity,
92
+ "sourceLabel": source_label,
93
+ "targetLabel": target_label,
94
+ "siteId": settings.site_id,
95
+ "createdAt": int(time.time() * 1000),
96
+ "blocked": detection.blocked,
97
+ "action": detection.action,
98
+ "path": meta["path"],
99
+ "method": meta["method"],
100
+ "attackerIp": client_ip,
101
+ "userAgent": meta["user_agent"],
102
+ "detection": detection.name,
103
+ }
104
+ if detection.category == "ddos":
105
+ payload["ddos"] = {"vector": "application"}
106
+
107
+ headers = {
108
+ "Content-Type": "application/json",
109
+ "X-Api-Key": settings.api_key,
110
+ "Authorization": f"Bearer {settings.api_key}",
111
+ }
112
+
113
+ try:
114
+ async with httpx.AsyncClient(timeout=settings.bridge_timeout_seconds) as client:
115
+ response = await client.post(url, json=payload, headers=headers)
116
+ except httpx.HTTPError as exc:
117
+ logger.warning(
118
+ "PrimeDefender: bridge HTTP error posting to %s: %s",
119
+ url,
120
+ exc,
121
+ exc_info=settings.debug,
122
+ )
123
+ return
124
+ except Exception as exc:
125
+ logger.warning(
126
+ "PrimeDefender: bridge unexpected error posting to %s: %s",
127
+ url,
128
+ exc,
129
+ exc_info=True,
130
+ )
131
+ return
132
+
133
+ log_msg = (
134
+ "PrimeDefender: bridge status=%s from=%s to=%s sourceLabel=%r targetLabel=%r detection=%s blocked=%s"
135
+ % (
136
+ response.status_code,
137
+ payload["from"],
138
+ payload["to"],
139
+ source_label,
140
+ target_label,
141
+ detection.name,
142
+ detection.blocked,
143
+ )
144
+ )
145
+ if response.is_success:
146
+ logger.info(log_msg)
147
+ else:
148
+ logger.warning("%s url=%s body=%r", log_msg, url, (response.text or "")[:800])
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "primedefender-fastapi"
7
+ version = "0.1.0"
8
+ description = "PrimeDefender security middleware for FastAPI: WAF-style detection, blocking, and incident reporting to the PrimeDefender bridge."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "PrimeDefender contributors" }]
13
+ keywords = [
14
+ "fastapi",
15
+ "security",
16
+ "middleware",
17
+ "waf",
18
+ "primedefender",
19
+ "sqli",
20
+ "xss",
21
+ "intrusion-detection",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Framework :: FastAPI",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Topic :: Internet :: WWW/HTTP",
35
+ "Topic :: Security",
36
+ "Typing :: Typed",
37
+ ]
38
+ dependencies = [
39
+ "fastapi>=0.100.0",
40
+ "httpx>=0.25.0",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ dev = [
45
+ "pytest>=7.0.0",
46
+ "ruff>=0.1.0",
47
+ ]
48
+
49
+ [project.urls]
50
+ Homepage = "https://github.com/primedefender/primedefender-fastapi"
51
+ Repository = "https://github.com/primedefender/primedefender-fastapi"
52
+ Issues = "https://github.com/primedefender/primedefender-fastapi/issues"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["primedefender_fastapi"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ include = [
59
+ "/primedefender_fastapi",
60
+ "/README.md",
61
+ "/LICENSE",
62
+ ]