Download runtime/python/dibaguard/http/parse.py from Dibachain/DibaGuard: direct link, hf CLI and curl.
- Browser
- Download file 16.8 kB
-
https://huggingface.co/Dibachain/DibaGuard/resolve/main/runtime/python/dibaguard/http/parse.py
- Command line
-
hf download hf://Dibachain/DibaGuard/runtime/python/dibaguard/http/parse.py
-
curl -L -o parse.py https://huggingface.co/Dibachain/DibaGuard/resolve/main/runtime/python/dibaguard/http/parse.py
16.8 kB
| """Every HTTP input form -> one Request: raw requests (CSIC-style text), access-log lines (nginx/apache combined), | |
| bare URLs. Parameters are extracted from the query, a form, JSON or multipart body and cookies; the model text is | |
| built from the canonical form of each part (canon.py). | |
| The text is what every tier judges (playbook signatures, the A22 SQL-context tier, the HTTP model), and the Go fast | |
| path builds the very same text (go/dibaguard/canon.go, proven by the canon_parity.json fixture of | |
| tests/test_parity.py) - so the Python-side defences hold in a Go deployment and in its sidecar too (V-08). | |
| Nothing is dropped from judgement silently (V-05, V-06, V-07): every occurrence of a duplicate JSON key, every | |
| multipart field, every value of a repeated header, every field and the whole of a value are written into the text | |
| while a judging budget lasts (MAX_TEXT characters); past it, only the parts that can carry a payload are written | |
| (_keep, up to EXTRA_TEXT more), and whatever still does not fit is counted in an `x:oversized=<chars>` part, which the | |
| SQL-context tier answers with a soft alarm (sqlctx.analyse) - a request too large to judge is never simply allowed. | |
| The work stays linear in the request size and bounded: at most MAX_FIELDS fields are canonicalised one by one (the | |
| rest as one blob), a value is decoded up to canon.MAX_LEN characters, the text never exceeds MAX_TEXT + EXTRA_TEXT.""" | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from dataclasses import dataclass, field | |
| from urllib.parse import urlsplit | |
| from dibaguard.http.canon import MAX_LEN, canon_views | |
| COMBINED = re.compile(r'^(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]*)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<size>\d+|-)' | |
| r'(?: "(?P<ref>[^"]*)" "(?P<ua>[^"]*)")?') | |
| KEEP_HEADERS = ("host", "user-agent", "referer", "cookie", "content-type", "x-forwarded-for", "accept", "authorization", | |
| "x-requested-with", "origin") | |
| # judging budget (characters of canonical text; every limit below is mirrored in go/dibaguard/canon.go) | |
| MAX_PART = 8192 # a value longer than this that does not fit the budget whole is cut into overlapping chunks | |
| CHUNK_OVERLAP = 512 # a payload up to this long is whole in one of two neighbouring chunks | |
| MAX_TEXT = 32768 # everything is written while the text is shorter than this | |
| EXTRA_TEXT = 32768 # then only parts that can carry a payload, up to this much more | |
| MAX_FIELDS = 2000 # fields canonicalised one by one; the rest are judged as one blob (part "x:rest") | |
| MAX_DEPTH = 512 # JSON nesting deeper than this is judged as one raw value | |
| MAX_FILE = MAX_PART # an uploaded file's content is data: its head is judged (a script / web shell shows there) | |
| REST_MAX = 1 << 20 # raw characters past a decoding limit (MAX_LEN per value, MAX_FIELDS) canonicalised per request | |
| _STD_HEADERS = ("user-agent", "referer", "x-forwarded-for", "content-type") | |
| _NEVER_HEADERS = {"cookie", "host", "content-length", "connection", "accept-encoding", "cache-control", "pragma", "upgrade-insecure-requests"} | |
| # word boundaries are written as explicit ASCII classes (no \b): Python's \b is Unicode-aware and RE2's is not, and the | |
| # Go fast path must include exactly the same headers | |
| _HDR_SUSPICIOUS = re.compile(r"['\"<>`]|\$\{|\{\{|--|/\*|\.\./|(?:^|[^a-z0-9_])(?:union|select|sleep|benchmark|jndi|script|javascript|vbscript|document|onerror|eval|exec" | |
| r"|cmd|wget|curl|waitfor|extractvalue|updatexml|information_schema|passwd|whoami)(?:$|[^a-z0-9_])") | |
| # what a part needs to be able to carry a payload any tier knows: a character other than letters, digits, space, '.', | |
| # '_' and '-', or "--" / "..", or one of these words (ASCII tokens, the same in Go) | |
| _PLAIN = re.compile(r"[\w .-]*") | |
| _TOKEN = re.compile(r"[a-z0-9_]+") | |
| _KEEP_WORDS = frozenset(("or", "and", "xor", "not", "union", "select", "sleep", "benchmark", "waitfor", "having", "exec", "execute", | |
| "declare", "drop", "insert", "update", "delete", "truncate", "shutdown", "grant", "information_schema", | |
| "pg_sleep", "extractvalue", "updatexml", "dual", "concat", "char", "chr", "rlike", "regexp", "like", "null", | |
| "jndi", "script", "javascript", "passwd", "whoami", "cmd", "eval", "system")) | |
| _BOUNDARY = re.compile(r'boundary=(?:"([^"]+)"|([^";,\s]+))', re.I) | |
| _DISP_NAME = re.compile(r'(?:^|;)\s*name\s*=\s*(?:"([^"]*)"|([^;\s]*))', re.I) | |
| _DISP_FILE = re.compile(r'(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;\s]*))', re.I) | |
| def _keep(s: str) -> bool: | |
| """can this canonical text carry a payload? (past the plain budget only such parts are written)""" | |
| if "--" in s or ".." in s or not _PLAIN.fullmatch(s): | |
| return True | |
| return any(t in _KEEP_WORDS for t in _TOKEN.findall(s)) | |
| def _join_header(name: str, values) -> str: | |
| """every value of a repeated header, joined the way HTTP folds them (cookies with '; '): a stack that reads the | |
| last value or joins them gets what is judged here (V-07)""" | |
| if isinstance(values, (list, tuple)): | |
| return ("; " if name == "cookie" else ", ").join(str(v) for v in values) | |
| return "" if values is None else str(values) | |
| class Request: | |
| method: str = "GET" | |
| path: str = "/" | |
| query: str = "" | |
| headers: dict = field(default_factory=dict) # name -> value, or name -> [values] of a repeated header | |
| body: str = "" | |
| meta: dict = field(default_factory=dict) # status, size, ip, time from logs (never model text) | |
| def __post_init__(self): | |
| h: dict = {} | |
| for k, v in (self.headers or {}).items(): | |
| k = str(k).lower() | |
| v = _join_header(k, v) | |
| h[k] = (h[k] + ("; " if k == "cookie" else ", ") + v) if k in h else v | |
| self.headers = h | |
| def params(self) -> list[tuple[str, str, str]]: | |
| """[(where, name, value)] from the query, the body (form / JSON / multipart) and cookies - all of them""" | |
| out = [("q", k, v) for k, v in _pairs(self.query)] | |
| ct = self.headers.get("content-type", "").lower() | |
| b = self.body.strip() | |
| if b: | |
| if "json" in ct or b[:1] in "[{": | |
| try: | |
| out += [("b", k, v) for k, v in _flatten(json.loads(b, object_pairs_hook=_Obj, parse_constant=_no_constant, | |
| parse_int=_int))] | |
| except (ValueError, RecursionError): | |
| out.append(("b", "", b)) | |
| elif "multipart/" in ct: | |
| mp = _multipart(b, self.headers.get("content-type", "")) # the boundary keeps its case | |
| out += [("b", "", b)] if mp is None else [("b", k, v) for k, v in mp] | |
| elif "=" in b: | |
| out += [("b", k, v) for k, v in _pairs(b)] | |
| else: | |
| out.append(("b", "", b)) | |
| ck = self.headers.get("cookie", "") | |
| if ck: | |
| out += [("c", k.strip(), v) for k, v in (p.partition("=")[::2] for p in ck.split(";") if p.strip())] | |
| return out | |
| def model_text(self) -> tuple[str, int]: | |
| """Canonical text the models read, and the deepest encoding layer peeled anywhere in the request.""" | |
| t = _Text() | |
| p, pa, r = canon_views(self.path) | |
| t.deepest = r | |
| t.add(f"{self.method.lower()} ", p) | |
| if pa is not None: | |
| t.add("p:=", pa) | |
| ps = self.params() | |
| for where, k, v in ps[:MAX_FIELDS]: | |
| cv, va, r = canon_views(v, plus_is_space=(where != "c")) | |
| ck, ka, _ = canon_views(k) | |
| t.deepest = max(t.deepest, r) | |
| t.add(f"{where}:{ck}=", cv) | |
| if va is not None: # the value with its comments kept (canon.views) | |
| t.add(f"{where}:{ck}=", va) | |
| if ka is not None: | |
| t.add(f"{where}:{ka}=", cv) | |
| if len(v) > MAX_LEN: # decoded up to MAX_LEN: the rest is judged as raw text, never dropped | |
| t.add_raw(f"{where}:{ck}=", v[MAX_LEN - CHUNK_OVERLAP:]) | |
| if len(ps) > MAX_FIELDS: # fields past MAX_FIELDS: one blob, canonicalised once | |
| t.add_raw("x:rest=", "&".join(f"{k}={v}" for _, k, v in ps[MAX_FIELDS:])) | |
| for h in _STD_HEADERS: | |
| if self.headers.get(h): | |
| ch, ha, r = canon_views(self.headers[h]) | |
| t.deepest = max(t.deepest, r) | |
| t.add(f"h:{h}=", ch) | |
| if ha is not None: | |
| t.add(f"h:{h}=", ha) | |
| # any other header joins the text only when its value looks like a payload: a normal Accept-Language or | |
| # Authorization leaves the model's input exactly as it was trained, a payload hidden in a header is scored. | |
| # Sorted by name: the Go fast path sees the headers as a map, and both must write the same text | |
| for h in sorted(self.headers): | |
| v = self.headers[h] | |
| if h in _STD_HEADERS or h in _NEVER_HEADERS or not v: | |
| continue | |
| ch, ha, r = canon_views(v) | |
| if _HDR_SUSPICIOUS.search(ch) or (ha is not None and _HDR_SUSPICIOUS.search(ha)): | |
| t.deepest = max(t.deepest, r) | |
| t.add(f"h:{h}=", ch) | |
| if ha is not None: | |
| t.add(f"h:{h}=", ha) | |
| return t.text(), t.deepest | |
| class _Text: | |
| """the parts of a model text under the judging budget (see the module docstring)""" | |
| def __init__(self): | |
| self.parts: list[str] = [] | |
| self.n = 0 # characters written within MAX_TEXT (separators included) | |
| self.extra = 0 # characters written past it (parts that can carry a payload) | |
| self.over = 0 # characters of such parts that fit neither, and raw past REST_MAX: x:oversized | |
| self.raw_left = REST_MAX | |
| self.deepest = 0 | |
| def add(self, prefix: str, value: str) -> None: | |
| if self.n + len(prefix) + len(value) + 3 <= MAX_TEXT: | |
| self.parts.append(prefix + value) | |
| self.n += len(prefix) + len(value) + 3 | |
| return | |
| for c in _chunks(value): | |
| k = len(prefix) + len(c) + 3 | |
| if self.n + k <= MAX_TEXT: | |
| self.parts.append(prefix + c) | |
| self.n += k | |
| elif _keep(c): | |
| if self.extra + k <= EXTRA_TEXT: | |
| self.parts.append(prefix + c) | |
| self.extra += k | |
| else: | |
| self.over += len(c) | |
| def add_raw(self, prefix: str, raw: str) -> None: | |
| """raw text past a decoding limit: canonicalised in MAX_LEN pieces (overlapping) and written like a value; past | |
| REST_MAX raw characters per request it is only counted (x:oversized)""" | |
| if len(raw) > self.raw_left: | |
| self.over += len(raw) - self.raw_left | |
| raw = raw[:self.raw_left] | |
| self.raw_left -= len(raw) | |
| if not raw: | |
| return | |
| step = MAX_LEN - CHUNK_OVERLAP | |
| for i in range(0, max(1, len(raw) - CHUNK_OVERLAP), step): | |
| a, b, r = canon_views(raw[i:i + MAX_LEN]) | |
| self.deepest = max(self.deepest, r) | |
| self.add(prefix, a) | |
| if b is not None: | |
| self.add(prefix, b) | |
| def text(self) -> str: | |
| if self.over: | |
| self.parts.append(f"x:oversized={self.over}") | |
| return " | ".join(self.parts) | |
| def _chunks(v: str) -> list[str]: | |
| """overlapping MAX_PART pieces of a long value (a short one is its own only piece)""" | |
| if len(v) <= MAX_PART: | |
| return [v] | |
| step = MAX_PART - CHUNK_OVERLAP | |
| return [v[i:i + MAX_PART] for i in range(0, len(v) - CHUNK_OVERLAP, step)] | |
| def _pairs(s: str) -> list[tuple[str, str]]: | |
| return [(k, v) for k, _, v in (p.partition("=") for p in s.split("&") if p)] | |
| class _Obj(list): | |
| """a JSON object as the list of its (key, value) pairs in order: a duplicate key keeps every value (V-05) - parsers | |
| that give the application the first one (several streaming / Go parsers) or the last one (json.loads) both get a | |
| value that was judged""" | |
| def _no_constant(name: str): | |
| raise ValueError(f"not JSON: {name}") # NaN / Infinity are not JSON (and the Go decoder refuses them too) | |
| def _int(s: str) -> int: | |
| if len(s) > 4300: # Python's own int-from-string limit, explicit so Go can refuse the same | |
| raise ValueError("integer too long") | |
| return int(s) | |
| def _flatten(o, prefix: str = "", depth: int = 0) -> list[tuple[str, str]]: | |
| if depth > MAX_DEPTH: | |
| raise ValueError("JSON nested too deeply") | |
| if isinstance(o, _Obj): | |
| return [x for k, v in o for x in _flatten(v, f"{prefix}.{k}" if prefix else str(k), depth + 1)] | |
| if isinstance(o, dict): | |
| return [x for k, v in o.items() for x in _flatten(v, f"{prefix}.{k}" if prefix else str(k), depth + 1)] | |
| if isinstance(o, list): | |
| return [x for i, v in enumerate(o) for x in _flatten(v, f"{prefix}[{i}]", depth + 1)] | |
| return [(prefix, "" if o is None else str(o))] | |
| def _multipart(body: str, ct: str) -> list[tuple[str, str]] | None: | |
| """multipart/form-data -> [(name, value)]: every field is a value of its own, a file part gives its filename and | |
| the head of its content (MAX_FILE characters: a file is data, stored rather than interpreted). None when the body is | |
| not multipart after all (judged as one raw value then). The same simple reading as the Go fast path.""" | |
| m = _BOUNDARY.search(ct) | |
| if not m: | |
| return None | |
| delim = "--" + (m.group(1) or m.group(2)) | |
| pieces = body.split(delim) | |
| if len(pieces) < 2: | |
| return None | |
| out = [] | |
| for piece in pieces[1:]: | |
| if piece.startswith("--"): # the closing delimiter: the epilogue is not delivered | |
| break | |
| if piece.startswith("\r\n"): | |
| piece = piece[2:] | |
| elif piece.startswith("\n"): | |
| piece = piece[1:] | |
| i, j = piece.find("\r\n\r\n"), piece.find("\n\n") | |
| if i < 0 and j < 0: | |
| out.append(("", piece)) # no header block: judged as it is | |
| continue | |
| cut, skip = (i, 4) if j < 0 or (0 <= i < j) else (j, 2) | |
| head, content = piece[:cut], piece[cut + skip:] | |
| if content.endswith("\r\n"): | |
| content = content[:-2] | |
| elif content.endswith("\n"): | |
| content = content[:-1] | |
| disp = "" | |
| for ln in head.split("\n"): | |
| hk, sep, hv = ln.partition(":") | |
| if sep and hk.strip().lower() == "content-disposition": | |
| disp = hv.strip() | |
| nm, fn = _DISP_NAME.search(disp), _DISP_FILE.search(disp) | |
| name = (nm.group(1) if nm.group(1) is not None else nm.group(2)) if nm else "" | |
| if fn: | |
| out.append((name, fn.group(1) if fn.group(1) is not None else fn.group(2))) | |
| out.append((name, content[:MAX_FILE])) | |
| else: | |
| out.append((name, content)) | |
| return out | |
| def from_url(url: str, method: str = "GET", headers: dict | None = None, body: str = "") -> Request: | |
| u = urlsplit(url if "://" in url or url.startswith("/") else "/" + url) | |
| h = {k.lower(): v for k, v in (headers or {}).items()} | |
| if u.netloc and "host" not in h: | |
| h["host"] = u.netloc | |
| return Request(method.upper(), u.path or "/", u.query, h, body or "") | |
| def from_raw(text: str) -> Request: | |
| """'GET /a?b=1 HTTP/1.1\\nHeader: v\\n\\nbody' (CRLF or LF). A repeated header keeps every value.""" | |
| text = text.replace("\r\n", "\n") | |
| head, _, body = text.partition("\n\n") | |
| lines = head.split("\n") | |
| first = lines[0].split(" ") | |
| method = first[0] if first else "GET" | |
| url = first[1] if len(first) > 1 else "/" | |
| headers: dict = {} | |
| for ln in lines[1:]: | |
| k, sep, v = ln.partition(":") | |
| if sep and k.strip().lower() in KEEP_HEADERS: | |
| headers.setdefault(k.strip().lower(), []).append(v.strip()) | |
| return from_url(url, method, headers, body.strip("\n")) | |
| def from_log_line(line: str) -> Request | None: | |
| """nginx/apache 'combined' (or 'common') format; None when the line is not one.""" | |
| m = COMBINED.match(line.strip()) | |
| if not m: | |
| return None | |
| req = m.group("req").split(" ") | |
| if len(req) < 2: | |
| return None | |
| h = {} | |
| if m.group("ua") and m.group("ua") != "-": | |
| h["user-agent"] = m.group("ua") | |
| if m.group("ref") and m.group("ref") != "-": | |
| h["referer"] = m.group("ref") | |
| r = from_url(req[1], req[0], h) | |
| r.meta = {"ip": m.group("ip"), "time": m.group("time"), "status": m.group("status"), "size": m.group("size")} | |
| return r | |