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)