File size: 8,006 Bytes
5d97275 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """Policy: the operator's contract with the layer. JSON, versioned, validated, with safe defaults (report only).
Example (policy.json):
{
"mode": "report", # report | enforce
"alpha": 0.001, # false-alarm budget per benign (source, minute)
"actions": {"portscan": "block", "bruteforce": "block", "dos": "rate_limit", "ddos": "rate_limit",
"webattack": "block", "lateral": "block", "bot": "challenge", "recon": "monitor", "*": "monitor"},
"min_action_score": 60, # 0-100 event score below which only 'monitor' is taken
"block_ttl_s": 1800, "rate_limit_ttl_s": 600, "challenge_ttl_s": 900,
"allow": ["10.0.0.0/8", "192.168.0.0/16"], # never acted upon (still reported)
"posture": {"allowed_ports": [22, 80, 443], "close_unexpected": false, "deny_public_services": ["redis", "mongodb", "elasticsearch", "memcached", "docker"]},
"report": {"every_s": 300, "llm": false}
}
"""
from __future__ import annotations
import ipaddress
import json
import os
from dibaguard.shield.database import DB_PORTS
ACTIONS = ("monitor", "rate_limit", "challenge", "block")
DEFAULT = {
"version": 1, "mode": "report", "alpha": 1e-3,
"actions": {"portscan": "block", "bruteforce": "block", "dos": "rate_limit", "ddos": "rate_limit", "webattack": "block", "lateral": "block",
"sqli": "block", "xss": "block", "cmdi": "block", "rce": "block", "traversal": "block", "lfi": "block",
"nosqli": "block", "ssti": "block", "ssrf": "block", "xxe": "block",
"bot": "challenge", "scan": "challenge", "recon": "monitor", "attack_generic": "monitor",
"db_rce": "block", "db_privilege": "block", "db_destructive": "block", "db_injection": "block", "db_exfil": "rate_limit",
"db_bruteforce": "block", "db_recon": "monitor", "db_new_client": "monitor", "db_unbounded": "monitor", "*": "monitor"},
"min_action_score": 60, "block_ttl_s": 1800, "rate_limit_ttl_s": 600, "challenge_ttl_s": 900,
"allow": ["127.0.0.0/8", "::1/128"],
"posture": {"allowed_ports": [22, 80, 443], "close_unexpected": False,
"deny_public_services": ["redis", "mongodb", "elasticsearch", "memcached", "docker", "mysql", "postgres", "rdp", "smb", "vnc",
"mssql", "oracle", "cassandra", "cassandra-internode", "cassandra-jmx", "couchdb", "neo4j", "clickhouse",
"cockroach", "influxdb", "kafka", "zookeeper", "etcd", "rethinkdb", "arangodb"]},
# databases: the layer reads their query / audit logs (shield/database.py). An application server the database
# knows is never blocked at the network level unless block_app_clients is set: that would take the app down -
# its alarm carries the remediation to run on the database, and the web source behind it is acted on instead.
"database": {"block_app_clients": False, "correlate_window_s": 10, "auth_fail_floor": 8, "exempt_shapes": [], "learn_new_clients": False},
"report": {"every_s": 300, "llm": False},
# search-engine crawlers recognised by user-agent AND their published address ranges: watched, never acted on as an
# address (each request is still judged - a payload in a crawled link is refused inline). Refresh the ranges from
# the engines' published lists (developers.google.com/search/apis/ipranges/googlebot.json, Bing's bingbot.json)
"verified_crawlers": [{"name": "googlebot", "ua": "googlebot", "cidrs": ["66.249.64.0/19"]},
{"name": "bingbot", "ua": "bingbot", "cidrs": ["157.55.39.0/24", "207.46.13.0/24", "40.77.167.0/24"]}],
# a client that is not a browser (API integrations, crawlers) cannot pass a JavaScript challenge: a behavioural
# challenge on it becomes this action instead (a challenge would cut it off)
"machine_challenge": "rate_limit",
}
KNOWN_SERVICES = {6379: "redis", 27017: "mongodb", 9200: "elasticsearch", 11211: "memcached", 2375: "docker", 2376: "docker",
3306: "mysql", 5432: "postgres", 3389: "rdp", 445: "smb", 139: "smb", 5900: "vnc", 23: "telnet", 21: "ftp",
25: "smtp", 53: "dns", 22: "ssh", 80: "http", 443: "https", 8080: "http-alt", 8443: "https-alt", 9090: "prometheus",
5601: "kibana", 15672: "rabbitmq", 8545: "eth-rpc", 8546: "eth-ws", 30303: "eth-p2p", 26656: "cosmos-p2p",
26657: "cosmos-rpc", 8333: "bitcoin-p2p", 8332: "bitcoin-rpc"}
KNOWN_SERVICES.update({p: eng for p, (eng, _) in DB_PORTS.items() if p not in KNOWN_SERVICES})
class Policy:
def __init__(self, data: dict | None = None):
self.d = json.loads(json.dumps(DEFAULT))
for k, v in (data or {}).items():
if isinstance(v, dict) and isinstance(self.d.get(k), dict):
self.d[k].update(v)
else:
self.d[k] = v
self.validate()
self._allow = [ipaddress.ip_network(a, strict=False) for a in self.d["allow"]]
self._crawlers = [(str(c["ua"]).lower(), [ipaddress.ip_network(x, strict=False) for x in c.get("cidrs", [])])
for c in self.d.get("verified_crawlers", [])]
@classmethod
def load(cls, path: str | None) -> "Policy":
if path and os.path.exists(path):
with open(path, encoding="utf-8") as f:
return cls(json.load(f))
return cls()
def validate(self) -> None:
d = self.d
if d["mode"] not in ("report", "enforce"):
raise ValueError(f"policy.mode must be report|enforce, got {d['mode']!r}")
if not 0 < float(d["alpha"]) < 0.5:
raise ValueError("policy.alpha must be in (0, 0.5)")
for fam, act in d["actions"].items():
if act not in ACTIONS:
raise ValueError(f"policy.actions[{fam!r}] must be one of {ACTIONS}, got {act!r}")
for k in ("block_ttl_s", "rate_limit_ttl_s", "challenge_ttl_s", "min_action_score"):
if float(d[k]) < 0:
raise ValueError(f"policy.{k} must be >= 0")
for a in d["allow"]:
ipaddress.ip_network(a, strict=False)
for c in d.get("verified_crawlers", []):
if not c.get("ua") or not c.get("cidrs"):
raise ValueError("policy.verified_crawlers entries need 'ua' and 'cidrs' (both must match)")
for x in c["cidrs"]:
ipaddress.ip_network(x, strict=False)
if d.get("machine_challenge", "rate_limit") not in ACTIONS:
raise ValueError(f"policy.machine_challenge must be one of {ACTIONS}")
# -- decisions
def allowed(self, ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in self._allow)
def verified_crawler(self, ip: str, ua: str) -> bool:
"""the user-agent names a listed crawler AND the address is in that crawler's published ranges"""
ua = ua.lower()
try:
addr = ipaddress.ip_address(str(ip).split("#", 1)[0])
except ValueError:
return False
return any(tok in ua and any(addr in n for n in nets) for tok, nets in self._crawlers)
def action_for(self, family: str, score: int, ip: str | None = None) -> str:
"""what the policy lets the layer do about one verdict (mode 'report' still returns the would-be action)"""
if ip is not None and self.allowed(ip):
return "monitor"
if score < int(self.d["min_action_score"]):
return "monitor"
return self.d["actions"].get(family, self.d["actions"].get("*", "monitor"))
def ttl(self, action: str) -> float:
return float(self.d.get(f"{action}_ttl_s", 0.0))
@property
def enforcing(self) -> bool:
return self.d["mode"] == "enforce"
def dump(self) -> str:
return json.dumps(self.d, ensure_ascii=False, indent=1)
|