netward 0.4.1__py3-none-any.whl
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.
- netward/__init__.py +113 -0
- netward/__main__.py +55 -0
- netward/bootstrap.py +89 -0
- netward/capture.py +385 -0
- netward/classify.py +175 -0
- netward/cli.py +131 -0
- netward/data/vendor_patterns.json +372 -0
- netward/mesh.py +104 -0
- netward/mirror.py +226 -0
- netward/operator_layer.py +208 -0
- netward/regex_policy.py +155 -0
- netward/schema.py +382 -0
- netward/storage.py +532 -0
- netward/test_bootstrap.py +140 -0
- netward/test_buyer_hygiene.py +271 -0
- netward/test_capture.py +270 -0
- netward/test_classify.py +277 -0
- netward/test_integration.py +481 -0
- netward/test_mirror.py +186 -0
- netward/test_operator.py +259 -0
- netward/test_regex_policy.py +177 -0
- netward/test_schema.py +219 -0
- netward/test_storage.py +353 -0
- netward/test_vendor_patterns.py +318 -0
- netward-0.4.1.dist-info/METADATA +488 -0
- netward-0.4.1.dist-info/RECORD +31 -0
- netward-0.4.1.dist-info/WHEEL +5 -0
- netward-0.4.1.dist-info/entry_points.txt +3 -0
- netward-0.4.1.dist-info/licenses/LICENSE +202 -0
- netward-0.4.1.dist-info/licenses/NOTICE +14 -0
- netward-0.4.1.dist-info/top_level.txt +1 -0
netward/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Net Ward — Passive Deception Layer for HTTP/Network Services
|
|
3
|
+
|
|
4
|
+
Standalone product for the general DDoS-defense market. Sold to
|
|
5
|
+
operators who get hit by botnet probing / credential stuffing / scrape
|
|
6
|
+
attacks but can't afford enterprise DDoS protection (Cloudflare
|
|
7
|
+
Enterprise, Akamai). Anti-virus doesn't cover this attack surface;
|
|
8
|
+
Net Ward fills that gap.
|
|
9
|
+
|
|
10
|
+
Customer market: ANY operator running an HTTP/network service with
|
|
11
|
+
inbound traffic. Hospitals, indie SaaS, regional ISPs, federal
|
|
12
|
+
contractors, defense-adjacent sites, public-interest projects, niche
|
|
13
|
+
aviation operators, ANY web property under recurring DDoS or scrape
|
|
14
|
+
pressure. The mesh treats every node identically — there is no
|
|
15
|
+
privileged customer relationship.
|
|
16
|
+
|
|
17
|
+
Philosophy: confuse, don't combat.
|
|
18
|
+
- Don't harm bots — they're conscripts following orders
|
|
19
|
+
- Mirror plausible-looking responses so bots think they succeeded
|
|
20
|
+
- Bots' own deduplication logic deprioritizes the target → they move on
|
|
21
|
+
- No active counter-attack, no legal liability, no escalation
|
|
22
|
+
|
|
23
|
+
Operating principle: ALL NODES ARE CONTINUOUSLY PREPARED.
|
|
24
|
+
|
|
25
|
+
There is no sacrificial node in Net Ward. Every node maintains its
|
|
26
|
+
mirror layer at all times. Whoever gets hit first is just whoever the
|
|
27
|
+
attacker happens to target first — they defend SUCCESSFULLY because
|
|
28
|
+
they've been prepared all along. They are not absorbing harm so
|
|
29
|
+
others can prepare; the entire mesh is already prepared.
|
|
30
|
+
|
|
31
|
+
What the first-hit node DOES contribute is REAL-TIME INTEL:
|
|
32
|
+
- Reports where it was hit (Pattern + Source published to mesh)
|
|
33
|
+
- Continues relaying as the attack progresses (sustained, not one-shot)
|
|
34
|
+
- Tracks mutations as bots iterate variants
|
|
35
|
+
- Refreshes confidence on existing patterns as they keep firing
|
|
36
|
+
|
|
37
|
+
Other nodes receive the relay and:
|
|
38
|
+
- Refresh local Pattern confidence
|
|
39
|
+
- Update Source reputation for the attacker's IPs/ASNs
|
|
40
|
+
- Pre-arm against the specific mutation chain in case the attacker
|
|
41
|
+
pivots to them next
|
|
42
|
+
|
|
43
|
+
Network effect: more nodes = smarter mesh = better deception for all.
|
|
44
|
+
Anti-fragile: the harder attackers hit, the more pattern data the
|
|
45
|
+
mesh collects, the better the deception gets for every node.
|
|
46
|
+
Mutation-aware: when bots iterate variants seeking vulnerabilities,
|
|
47
|
+
the mesh tracks the mutation chain so every node stays one step ahead.
|
|
48
|
+
|
|
49
|
+
Architecture: distributed mesh with deception mirror layer.
|
|
50
|
+
|
|
51
|
+
Product positioning:
|
|
52
|
+
|
|
53
|
+
Net Ward sits BESIDE existing security tools, not replacing them.
|
|
54
|
+
- CDN/WAF (Cloudflare, Akamai) handles broad edge filtering
|
|
55
|
+
- Antivirus / EDR handles infected endpoints
|
|
56
|
+
- Net Ward handles ADAPTIVE ABUSE PRESSURE: bot floods, suspicious
|
|
57
|
+
repeat patterns, credential stuffing, scrape attacks, and
|
|
58
|
+
operator-visible deflection
|
|
59
|
+
|
|
60
|
+
Antivirus DOES NOT cover this attack surface. AV protects hosts from
|
|
61
|
+
malware; it doesn't preserve availability when traffic or abuse floods
|
|
62
|
+
the edge. Net Ward fills the gap as a unique add-on for electronic /
|
|
63
|
+
bot warfare protection that saves host operators meaningful downtime
|
|
64
|
+
and remediation cost.
|
|
65
|
+
|
|
66
|
+
Buyer pain (real, validated): small orgs, hospitals, regional
|
|
67
|
+
infrastructure, indie platforms, niche data services often can't
|
|
68
|
+
afford Cloudflare-Enterprise-style support OR don't want opaque
|
|
69
|
+
black-box mitigation. A clear, local/operator-controlled layer has
|
|
70
|
+
genuine market room.
|
|
71
|
+
|
|
72
|
+
CORE PRODUCT REQUIREMENT: FAIL SAFE AND FAIL CLEAN.
|
|
73
|
+
|
|
74
|
+
Net Ward must protect availability without becoming another source of
|
|
75
|
+
risk to the host system. This is not a "nice to have" — it's a
|
|
76
|
+
gating requirement for v1 release.
|
|
77
|
+
|
|
78
|
+
Design principles (encoded in every layer):
|
|
79
|
+
|
|
80
|
+
- NO kernel hooks, NO packet drivers, NO invasive system changes
|
|
81
|
+
- Run as user-space reverse proxy / sidecar / edge service ONLY
|
|
82
|
+
- Config lives in its own directory; never modifies host config
|
|
83
|
+
- Logs are append-only and bounded (no unbounded disk growth)
|
|
84
|
+
- State/cache can be deleted without harming the host application
|
|
85
|
+
- Crash behavior: operator chooses fail-open vs fail-closed explicitly
|
|
86
|
+
(default: fail-open — don't take the host's service down with us)
|
|
87
|
+
- Uninstall = remove the service folder/config, NOT repair the OS
|
|
88
|
+
- NO retaliatory traffic, NO client harm, NO "hack back"
|
|
89
|
+
- Rate limiting and deflection must be REVERSIBLE and EXPLAINABLE
|
|
90
|
+
(operator can roll back any rule, can read why each action fired)
|
|
91
|
+
|
|
92
|
+
Setup target (cheap, easy, accessible):
|
|
93
|
+
|
|
94
|
+
- Single binary OR pure-Python package install
|
|
95
|
+
- ONE config file (validated, with clear error messages on bad config)
|
|
96
|
+
- Docker option for ops teams that prefer containers
|
|
97
|
+
- Works in front of an existing app with MINIMAL routing changes
|
|
98
|
+
- Clear dashboard / CLI: traffic state, active rules, top sources,
|
|
99
|
+
current action being taken — no operator surprise
|
|
100
|
+
|
|
101
|
+
THE PRODUCT PROMISE:
|
|
102
|
+
Protect availability without becoming another source of risk.
|
|
103
|
+
|
|
104
|
+
Scope: HTTP/network services with INBOUND traffic.
|
|
105
|
+
NOT for broadcast feed protection — public one-way feeds without
|
|
106
|
+
authentication or upstream firewall control are a separate problem
|
|
107
|
+
domain that mirror/honeypot countermeasures cannot address.
|
|
108
|
+
|
|
109
|
+
Naming convention:
|
|
110
|
+
- Display name: "Net Ward" (operator-facing brand)
|
|
111
|
+
- Python package: `netward` (lowercase one-word import path)
|
|
112
|
+
- Architecture term: "mirror layer" / "deception mirror" (internal)
|
|
113
|
+
"""
|
netward/__main__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Net Ward -- proxy daemon entrypoint
|
|
3
|
+
|
|
4
|
+
python -m netward --config /etc/netward/config.json
|
|
5
|
+
|
|
6
|
+
Loads operator config, seeds vendor patterns on first run, then
|
|
7
|
+
binds the capture loop and runs until interrupted.
|
|
8
|
+
Management commands (install-patterns, list-patterns, etc.) live in
|
|
9
|
+
netward/cli.py and are not part of this entrypoint.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from netward.operator_layer import load_config, validate_storage_permissions
|
|
16
|
+
from netward.capture import start_capture_loop
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="netward",
|
|
22
|
+
description="Net Ward passive deception proxy",
|
|
23
|
+
epilog="For pattern management: python netward/cli.py --help",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--config",
|
|
27
|
+
required=True,
|
|
28
|
+
metavar="PATH",
|
|
29
|
+
help="Operator config JSON (see example_config.json for a template)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--allow-permissive-db",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Allow startup even when the Net Ward DB path is world-writable.",
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
config = load_config(args.config)
|
|
40
|
+
validate_storage_permissions(
|
|
41
|
+
config,
|
|
42
|
+
allow_permissive_db=args.allow_permissive_db,
|
|
43
|
+
)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
print(f"Config error: {exc}", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
asyncio.run(start_capture_loop(config))
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
pass # clean exit on Ctrl-C / SIGINT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
netward/bootstrap.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Net Ward -- Bootstrap
|
|
3
|
+
|
|
4
|
+
Seed storage with the built-in vendor pattern set on first run.
|
|
5
|
+
Called automatically by start_capture_loop before the proxy binds.
|
|
6
|
+
|
|
7
|
+
install_vendor_patterns(storage, force=False) -> tuple[int, int]
|
|
8
|
+
Reads netward/data/vendor_patterns.json, upserts MirrorResponses
|
|
9
|
+
then Patterns. Idempotent: second call is a no-op unless force=True.
|
|
10
|
+
Returns (patterns_installed, mirrors_installed).
|
|
11
|
+
|
|
12
|
+
Raises BootstrapError on malformed JSON — never touches storage in
|
|
13
|
+
that case, so storage is always left in a consistent state.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
_VENDOR_DATA_PATH = Path(__file__).parent / "data" / "vendor_patterns.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BootstrapError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_vendor_patterns(
|
|
30
|
+
storage,
|
|
31
|
+
force: bool = False,
|
|
32
|
+
_data_path: Optional[Path] = None,
|
|
33
|
+
) -> tuple[int, int]:
|
|
34
|
+
"""
|
|
35
|
+
Seed vendor patterns + mirror responses into storage.
|
|
36
|
+
|
|
37
|
+
force=False -- no-op if any vendor-origin patterns already exist.
|
|
38
|
+
force=True -- re-upserts regardless of existing state.
|
|
39
|
+
_data_path -- override for tests; production always uses the package default.
|
|
40
|
+
|
|
41
|
+
Returns (patterns_installed, mirrors_installed).
|
|
42
|
+
"""
|
|
43
|
+
# Idempotency guard
|
|
44
|
+
if not force:
|
|
45
|
+
try:
|
|
46
|
+
existing = storage.patterns_active()
|
|
47
|
+
if any(p.get("origin") == "vendor" for p in existing):
|
|
48
|
+
return (0, 0)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass # storage errors fall through to a fresh install attempt
|
|
51
|
+
|
|
52
|
+
# Validate JSON before touching storage
|
|
53
|
+
data_path = _data_path or _VENDOR_DATA_PATH
|
|
54
|
+
try:
|
|
55
|
+
raw = Path(data_path).read_text(encoding="utf-8")
|
|
56
|
+
data = json.loads(raw)
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
raise BootstrapError(f"vendor_patterns.json is invalid JSON: {exc}") from exc
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
raise BootstrapError(f"cannot read vendor_patterns.json: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
mirrors = data.get("mirror_responses", [])
|
|
63
|
+
patterns = data.get("patterns", [])
|
|
64
|
+
if not isinstance(mirrors, list) or not isinstance(patterns, list):
|
|
65
|
+
raise BootstrapError(
|
|
66
|
+
"vendor_patterns.json must have 'mirror_responses' and 'patterns' arrays"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
now = time.time()
|
|
70
|
+
|
|
71
|
+
# Upsert mirrors first — patterns reference them by id
|
|
72
|
+
mirrors_installed = 0
|
|
73
|
+
for mr in mirrors:
|
|
74
|
+
mr = dict(mr)
|
|
75
|
+
if not mr.get("created_at"):
|
|
76
|
+
mr["created_at"] = now
|
|
77
|
+
storage.mirror_response_upsert(mr)
|
|
78
|
+
mirrors_installed += 1
|
|
79
|
+
|
|
80
|
+
# Upsert patterns
|
|
81
|
+
patterns_installed = 0
|
|
82
|
+
for pat in patterns:
|
|
83
|
+
pat = dict(pat)
|
|
84
|
+
if not pat.get("created_at"):
|
|
85
|
+
pat["created_at"] = now
|
|
86
|
+
storage.patterns_upsert(pat)
|
|
87
|
+
patterns_installed += 1
|
|
88
|
+
|
|
89
|
+
return (patterns_installed, mirrors_installed)
|
netward/capture.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Net Ward -- Capture Layer
|
|
3
|
+
|
|
4
|
+
aiohttp reverse-proxy entry point. All inbound HTTP traffic passes through
|
|
5
|
+
here: build a Probe, classify it, then route -- mirror response (probe/flood)
|
|
6
|
+
or pass-through to upstream (unknown/legit).
|
|
7
|
+
|
|
8
|
+
Design rule (from __init__.py): FAIL SAFE, FAIL OPEN.
|
|
9
|
+
If classification or mirroring raises, the request is forwarded to upstream
|
|
10
|
+
rather than dropped. Net Ward must never take the host application down.
|
|
11
|
+
|
|
12
|
+
Public interface:
|
|
13
|
+
start_capture_loop(config, storage=None) -> None
|
|
14
|
+
Bind to config["listen_address"], proxy to config["upstream_target"].
|
|
15
|
+
Runs until cancelled (asyncio.CancelledError).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections import deque
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import aiohttp
|
|
26
|
+
from aiohttp import web
|
|
27
|
+
|
|
28
|
+
from netward.schema import OperatorConfig, Probe, RequestMetadata, Source
|
|
29
|
+
from netward import classify as _classify_mod
|
|
30
|
+
from netward import mirror as _mirror_mod
|
|
31
|
+
|
|
32
|
+
# Body capture cap per schema (RequestMetadata.body_snippet)
|
|
33
|
+
_BODY_SNIPPET_MAX = 4096
|
|
34
|
+
|
|
35
|
+
# Must stay in sync with classify.FLOOD_WINDOW_SECS (both are 10.0 s, v0.4.1)
|
|
36
|
+
_RATE_WINDOW_SECS: float = 10.0
|
|
37
|
+
|
|
38
|
+
# Per-source rate windows (source_id -> bounded deque of request timestamps).
|
|
39
|
+
# maxlen=2000: headroom above the 1000-hit flood threshold to avoid evicting
|
|
40
|
+
# in-window timestamps before they age out naturally.
|
|
41
|
+
# Dict is capped at _RATE_WINDOW_MAX_SOURCES; oldest quarter evicted when full.
|
|
42
|
+
_RATE_WINDOW_MAX_SOURCES = 10_000
|
|
43
|
+
_rate_windows: dict[str, deque] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_rate_window(source_id: str) -> deque:
|
|
47
|
+
if source_id not in _rate_windows:
|
|
48
|
+
if len(_rate_windows) >= _RATE_WINDOW_MAX_SOURCES:
|
|
49
|
+
evict_n = _RATE_WINDOW_MAX_SOURCES // 4
|
|
50
|
+
for k in list(_rate_windows.keys())[:evict_n]:
|
|
51
|
+
del _rate_windows[k]
|
|
52
|
+
_rate_windows[source_id] = deque(maxlen=2000)
|
|
53
|
+
return _rate_windows[source_id]
|
|
54
|
+
|
|
55
|
+
# Hop-by-hop headers stripped before forwarding upstream
|
|
56
|
+
_HOP_BY_HOP = frozenset({
|
|
57
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
58
|
+
"te", "trailers", "transfer-encoding", "upgrade",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
_PATTERN_CACHE_TTL = 30.0
|
|
62
|
+
|
|
63
|
+
# Default mirror variant set (v0.4.1 fingerprint hardening).
|
|
64
|
+
# A single fixed JSON shape was detectable under load (stress-state signature).
|
|
65
|
+
# Variant selection is deterministic per (source_id, path) so a given source
|
|
66
|
+
# sees a consistent shape per path — prevents second-probe shape divergence.
|
|
67
|
+
_DEFAULT_MIRROR: dict = {
|
|
68
|
+
"id": "_netward_default",
|
|
69
|
+
"matches_pattern_id": "",
|
|
70
|
+
"intensity": "minimal",
|
|
71
|
+
"http_status": 200,
|
|
72
|
+
"headers": {"Content-Type": "application/json"},
|
|
73
|
+
"body_template": '{"status":"ok","id":"{{uuid}}","timestamp":"{{timestamp}}"}',
|
|
74
|
+
"body_template_vars": {"uuid": "uuid", "timestamp": "timestamp"},
|
|
75
|
+
"description": "Default minimal mirror (fallback; prefer _select_default_mirror)",
|
|
76
|
+
"created_at": 0.0,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_DEFAULT_MIRROR_VARIANTS: list[dict] = [
|
|
80
|
+
{
|
|
81
|
+
"id": "_netward_default_0",
|
|
82
|
+
"matches_pattern_id": "",
|
|
83
|
+
"intensity": "minimal",
|
|
84
|
+
"http_status": 200,
|
|
85
|
+
"headers": {"Content-Type": "application/json"},
|
|
86
|
+
"body_template": '{"status":"ok","id":"{{uuid}}","timestamp":"{{timestamp}}"}',
|
|
87
|
+
"body_template_vars": {"uuid": "uuid", "timestamp": "timestamp"},
|
|
88
|
+
"description": "Default variant 0 — JSON ok",
|
|
89
|
+
"created_at": 0.0,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "_netward_default_1",
|
|
93
|
+
"matches_pattern_id": "",
|
|
94
|
+
"intensity": "minimal",
|
|
95
|
+
"http_status": 429,
|
|
96
|
+
"headers": {"Content-Type": "application/json", "Retry-After": "30"},
|
|
97
|
+
"body_template": '{"error":"rate limited","retry_after":30}',
|
|
98
|
+
"body_template_vars": {},
|
|
99
|
+
"description": "Default variant 1 — rate limited JSON",
|
|
100
|
+
"created_at": 0.0,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "_netward_default_2",
|
|
104
|
+
"matches_pattern_id": "",
|
|
105
|
+
"intensity": "minimal",
|
|
106
|
+
"http_status": 503,
|
|
107
|
+
"headers": {"Content-Type": "text/html; charset=utf-8"},
|
|
108
|
+
"body_template": "<html><body><h1>Service Unavailable</h1><p>Try again later.</p></body></html>",
|
|
109
|
+
"body_template_vars": {},
|
|
110
|
+
"description": "Default variant 2 — HTML 503",
|
|
111
|
+
"created_at": 0.0,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"id": "_netward_default_3",
|
|
115
|
+
"matches_pattern_id": "",
|
|
116
|
+
"intensity": "minimal",
|
|
117
|
+
"http_status": 200,
|
|
118
|
+
"headers": {"Content-Type": "application/json"},
|
|
119
|
+
"body_template": '{"data":null,"next":null}',
|
|
120
|
+
"body_template_vars": {},
|
|
121
|
+
"description": "Default variant 3 — empty result JSON",
|
|
122
|
+
"created_at": 0.0,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"id": "_netward_default_4",
|
|
126
|
+
"matches_pattern_id": "",
|
|
127
|
+
"intensity": "minimal",
|
|
128
|
+
"http_status": 200,
|
|
129
|
+
"headers": {"Content-Type": "text/plain; charset=utf-8"},
|
|
130
|
+
"body_template": "OK",
|
|
131
|
+
"body_template_vars": {},
|
|
132
|
+
"description": "Default variant 4 — plain OK",
|
|
133
|
+
"created_at": 0.0,
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _select_default_mirror(source_id: str, path: str) -> dict:
|
|
139
|
+
"""Deterministic variant per (source_id, path) to prevent shape divergence."""
|
|
140
|
+
idx = abs(hash(source_id + path)) % len(_DEFAULT_MIRROR_VARIANTS)
|
|
141
|
+
return _DEFAULT_MIRROR_VARIANTS[idx]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _read_body_snippet(request: web.Request) -> Optional[str]:
|
|
145
|
+
try:
|
|
146
|
+
raw = await request.read()
|
|
147
|
+
if not raw:
|
|
148
|
+
return None
|
|
149
|
+
return raw[:_BODY_SNIPPET_MAX].decode("utf-8", errors="replace")
|
|
150
|
+
except Exception:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _build_probe(request: web.Request, source_id: str) -> Probe:
|
|
155
|
+
body = await _read_body_snippet(request)
|
|
156
|
+
req_meta: RequestMetadata = {
|
|
157
|
+
"method": request.method,
|
|
158
|
+
"path": request.path,
|
|
159
|
+
"headers": dict(request.headers),
|
|
160
|
+
"query_string": request.query_string or None,
|
|
161
|
+
"body_snippet": body,
|
|
162
|
+
"body_size": len(body.encode("utf-8", errors="replace")) if body else 0,
|
|
163
|
+
"user_agent": request.headers.get("User-Agent"),
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
"id": str(uuid.uuid4()),
|
|
167
|
+
"timestamp": time.time(),
|
|
168
|
+
"source_id": source_id,
|
|
169
|
+
"pattern_id": None,
|
|
170
|
+
"classification": "unknown",
|
|
171
|
+
"request": req_meta,
|
|
172
|
+
"response_id": None,
|
|
173
|
+
"mirror_fired": False,
|
|
174
|
+
"upstream_passed": False,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _forward_upstream(
|
|
179
|
+
request: web.Request,
|
|
180
|
+
upstream: str,
|
|
181
|
+
session: aiohttp.ClientSession,
|
|
182
|
+
) -> Optional[web.Response]:
|
|
183
|
+
"""Forward to upstream. Return None when upstream is unavailable."""
|
|
184
|
+
url = upstream.rstrip("/") + request.path
|
|
185
|
+
if request.query_string:
|
|
186
|
+
url += "?" + request.query_string
|
|
187
|
+
body = await request.read()
|
|
188
|
+
fwd_headers = {
|
|
189
|
+
k: v for k, v in request.headers.items()
|
|
190
|
+
if k.lower() not in _HOP_BY_HOP and k.lower() != "host"
|
|
191
|
+
}
|
|
192
|
+
try:
|
|
193
|
+
async with session.request(
|
|
194
|
+
method=request.method,
|
|
195
|
+
url=url,
|
|
196
|
+
headers=fwd_headers,
|
|
197
|
+
data=body,
|
|
198
|
+
allow_redirects=False,
|
|
199
|
+
) as resp:
|
|
200
|
+
content = await resp.read()
|
|
201
|
+
resp_headers = {
|
|
202
|
+
k: v for k, v in resp.headers.items()
|
|
203
|
+
if k.lower() not in _HOP_BY_HOP | {"content-encoding"}
|
|
204
|
+
}
|
|
205
|
+
return web.Response(status=resp.status, headers=resp_headers, body=content)
|
|
206
|
+
except Exception:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _mirror_response_from_probe(probe: Probe, mirror: dict) -> web.Response:
|
|
211
|
+
http_resp = _mirror_mod.fire_mirror(probe, mirror)
|
|
212
|
+
return web.Response(
|
|
213
|
+
status=http_resp["status"],
|
|
214
|
+
headers=http_resp["headers"],
|
|
215
|
+
text=http_resp["body"],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _fire_and_forget(storage, probe: Probe) -> None:
|
|
220
|
+
"""Schedule probe logging without blocking the response path."""
|
|
221
|
+
try:
|
|
222
|
+
loop = asyncio.get_running_loop()
|
|
223
|
+
loop.create_task(asyncio.to_thread(storage.probes_log, probe))
|
|
224
|
+
except RuntimeError:
|
|
225
|
+
pass # no running loop (e.g., during sync tests)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _make_handler(config: OperatorConfig, storage, session: aiohttp.ClientSession):
|
|
231
|
+
upstream = config["upstream_target"]
|
|
232
|
+
|
|
233
|
+
# Per-handler pattern cache (closure) so each handler instance starts fresh
|
|
234
|
+
# and tests don't bleed stale cached patterns into each other.
|
|
235
|
+
_pcache: list = []
|
|
236
|
+
_pcache_at: float = 0.0
|
|
237
|
+
|
|
238
|
+
def _get_patterns() -> list:
|
|
239
|
+
nonlocal _pcache, _pcache_at
|
|
240
|
+
if time.time() - _pcache_at > _PATTERN_CACHE_TTL:
|
|
241
|
+
try:
|
|
242
|
+
_pcache = storage.patterns_active()
|
|
243
|
+
_pcache_at = time.time()
|
|
244
|
+
except Exception:
|
|
245
|
+
pass # fail-open: return stale cache rather than crashing request path
|
|
246
|
+
return _pcache
|
|
247
|
+
|
|
248
|
+
async def handle(request: web.Request) -> web.Response:
|
|
249
|
+
ip = request.remote or "0.0.0.0"
|
|
250
|
+
now = time.time()
|
|
251
|
+
|
|
252
|
+
# Source lookup or create
|
|
253
|
+
source: Source = storage.sources_lookup(ip) or {
|
|
254
|
+
"id": str(uuid.uuid4()),
|
|
255
|
+
"ip_address": ip,
|
|
256
|
+
"reputation": "neutral",
|
|
257
|
+
"first_seen": now,
|
|
258
|
+
"last_seen": now,
|
|
259
|
+
"probe_count": 0,
|
|
260
|
+
"legit_count": 0,
|
|
261
|
+
"notes": [],
|
|
262
|
+
}
|
|
263
|
+
source_id: str = source["id"]
|
|
264
|
+
|
|
265
|
+
# Rate window update (in-memory, bounded + evicting)
|
|
266
|
+
window = _get_rate_window(source_id)
|
|
267
|
+
window.append(now)
|
|
268
|
+
rate_window = list(window)
|
|
269
|
+
|
|
270
|
+
probe = await _build_probe(request, source_id)
|
|
271
|
+
|
|
272
|
+
# Classify -- fail-open on any unhandled exception
|
|
273
|
+
patterns = _get_patterns()
|
|
274
|
+
try:
|
|
275
|
+
result = _classify_mod.classify(
|
|
276
|
+
probe, {"patterns": patterns, "source": source, "rate_window": rate_window}
|
|
277
|
+
)
|
|
278
|
+
except Exception:
|
|
279
|
+
probe["classification"] = "unknown"
|
|
280
|
+
upstream_response = await _forward_upstream(request, upstream, session)
|
|
281
|
+
if upstream_response is None:
|
|
282
|
+
probe["mirror_fired"] = True
|
|
283
|
+
probe["upstream_passed"] = False
|
|
284
|
+
response = _mirror_response_from_probe(
|
|
285
|
+
probe, _select_default_mirror(source_id, request.path)
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
probe["upstream_passed"] = True
|
|
289
|
+
response = upstream_response
|
|
290
|
+
_fire_and_forget(storage, probe)
|
|
291
|
+
return response
|
|
292
|
+
|
|
293
|
+
updated = result["probe"]
|
|
294
|
+
classification = updated.get("classification", "unknown")
|
|
295
|
+
|
|
296
|
+
# Source counters + reputation.
|
|
297
|
+
# Key on fire_mirror, not classification: a "flood"-classified request
|
|
298
|
+
# that didn't match a pattern still passes to upstream — that's legit
|
|
299
|
+
# traffic, not a probe. Only mirror-firing events are probe_count.
|
|
300
|
+
if result["fire_mirror"]:
|
|
301
|
+
source["probe_count"] = source.get("probe_count", 0) + 1
|
|
302
|
+
else:
|
|
303
|
+
source["legit_count"] = source.get("legit_count", 0) + 1
|
|
304
|
+
source["last_seen"] = now
|
|
305
|
+
source = _classify_mod.update_source_reputation(source)
|
|
306
|
+
try:
|
|
307
|
+
storage.sources_upsert(source)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass # fail-open: stale source state is acceptable
|
|
310
|
+
|
|
311
|
+
# Route
|
|
312
|
+
_default_mr = _select_default_mirror(source_id, request.path)
|
|
313
|
+
if result["fire_mirror"]:
|
|
314
|
+
mr_id = result.get("mirror_response_id")
|
|
315
|
+
mr = (storage.mirror_response_lookup(mr_id) if mr_id else None) or _default_mr
|
|
316
|
+
try:
|
|
317
|
+
response = _mirror_response_from_probe(updated, mr)
|
|
318
|
+
except Exception:
|
|
319
|
+
# Mirror failed -- fail-open, pass upstream
|
|
320
|
+
upstream_response = await _forward_upstream(request, upstream, session)
|
|
321
|
+
if upstream_response is None:
|
|
322
|
+
updated["mirror_fired"] = True
|
|
323
|
+
updated["upstream_passed"] = False
|
|
324
|
+
response = _mirror_response_from_probe(updated, _default_mr)
|
|
325
|
+
else:
|
|
326
|
+
updated["mirror_fired"] = False
|
|
327
|
+
updated["upstream_passed"] = True
|
|
328
|
+
response = upstream_response
|
|
329
|
+
else:
|
|
330
|
+
upstream_response = await _forward_upstream(request, upstream, session)
|
|
331
|
+
if upstream_response is None:
|
|
332
|
+
updated["mirror_fired"] = True
|
|
333
|
+
updated["upstream_passed"] = False
|
|
334
|
+
response = _mirror_response_from_probe(updated, _default_mr)
|
|
335
|
+
else:
|
|
336
|
+
updated["upstream_passed"] = True
|
|
337
|
+
response = upstream_response
|
|
338
|
+
|
|
339
|
+
_fire_and_forget(storage, updated)
|
|
340
|
+
return response
|
|
341
|
+
|
|
342
|
+
return handle
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def start_capture_loop(config: OperatorConfig, storage=None) -> None:
|
|
346
|
+
"""
|
|
347
|
+
Bind to config["listen_address"] and begin proxying to
|
|
348
|
+
config["upstream_target"]. Runs until cancelled.
|
|
349
|
+
Fail-open: crashing this process does not harm the upstream service.
|
|
350
|
+
"""
|
|
351
|
+
if storage is None:
|
|
352
|
+
from netward.storage import Storage
|
|
353
|
+
storage = Storage(config.get("storage_path", "netward.db"))
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
from netward import bootstrap as _bootstrap
|
|
357
|
+
_bootstrap.install_vendor_patterns(storage)
|
|
358
|
+
except Exception:
|
|
359
|
+
pass # fail-open: never block startup on seed failure
|
|
360
|
+
|
|
361
|
+
connector = aiohttp.TCPConnector()
|
|
362
|
+
session = aiohttp.ClientSession(connector=connector)
|
|
363
|
+
_proxy_handler = _make_handler(config, storage, session)
|
|
364
|
+
|
|
365
|
+
@web.middleware
|
|
366
|
+
async def catch_all(request: web.Request, handler) -> web.Response:
|
|
367
|
+
return await _proxy_handler(request)
|
|
368
|
+
|
|
369
|
+
app = web.Application(middlewares=[catch_all])
|
|
370
|
+
|
|
371
|
+
runner = web.AppRunner(app)
|
|
372
|
+
await runner.setup()
|
|
373
|
+
|
|
374
|
+
listen = config.get("listen_address", "0.0.0.0:8080")
|
|
375
|
+
host, _, port_str = listen.rpartition(":")
|
|
376
|
+
if not host:
|
|
377
|
+
host = "0.0.0.0"
|
|
378
|
+
site = web.TCPSite(runner, host, int(port_str))
|
|
379
|
+
await site.start()
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
await asyncio.Event().wait()
|
|
383
|
+
finally:
|
|
384
|
+
await session.close()
|
|
385
|
+
await runner.cleanup()
|