File size: 7,401 Bytes
e4375ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""Standalone loader for the character-level XSS detector.

Needs only torch, numpy and safetensors (plus huggingface_hub to download):

    from xss_detector import XSSDetector
    detector = XSSDetector.from_pretrained("<repo id or local folder>")
    detector.predict(["<img src=x onerror=alert(1)>", "<b>hello</b>"])

The weights are a safetensors file, so loading them runs no code from the download.
"""
from __future__ import annotations

import html
import json
import math
import re
from pathlib import Path
from urllib.parse import unquote

import numpy as np
import torch
from safetensors.torch import load_file
from torch import nn
from torch.nn import functional as F

PAD, OTHER, VOCAB = 0, 257, 258  # 1..256 are code points 0..255; OTHER is every later code point
_WHITESPACE = re.compile(r"\s+")


def normalize(text: str, rounds: int = 3) -> str:
    """Undo URL and HTML encoding (up to `rounds` times), drop NUL bytes, squeeze whitespace, lowercase."""
    text = str(text)
    for _ in range(rounds):
        decoded = html.unescape(unquote(text))
        if decoded == text:
            break
        text = decoded
    text = text.replace("\x00", "")
    return _WHITESPACE.sub(" ", text).strip().lower()


def windows(text: str, window: int, stride: int) -> list[str]:
    """Overlapping windows of a normalised text. Each is scored; the highest score counts."""
    if len(text) <= window:
        return [text]
    return [text[i : i + window] for i in range(0, len(text) - window + stride, stride)]


def encode(text: str, max_length: int) -> np.ndarray:
    points = np.frombuffer((text[:max_length] or " ").encode("utf-32-le", "surrogatepass"), dtype=np.uint32)
    return np.where(points < 256, points + 1, OTHER).astype(np.int64)


class XSSNet(nn.Module):
    """Character embeddings -> convolutions over 1/3/5/7 characters -> 2 stride-2 convolutions -> Transformer encoder -> pooled head."""

    def __init__(self, max_length: int, embed_dim: int, width: int, layers: int, heads: int, dropout: float = 0.0):
        super().__init__()
        self.max_length = max_length
        self.embed = nn.Embedding(VOCAB, embed_dim, padding_idx=PAD)
        self.convs = nn.ModuleList(nn.Conv1d(embed_dim, width // 4, k, padding=k // 2) for k in (1, 3, 5, 7))
        self.down = nn.ModuleList(nn.Conv1d(width, width, 3, stride=2, padding=1) for _ in range(2))
        self.position = nn.Embedding((max_length + 3) // 4, width)
        layer = nn.TransformerEncoderLayer(width, heads, 3 * width, dropout, activation="gelu", batch_first=True, norm_first=True)
        self.encoder = nn.TransformerEncoder(layer, layers, enable_nested_tensor=False)
        self.norm = nn.LayerNorm(width)
        self.head = nn.Sequential(nn.Linear(2 * width, width), nn.GELU(), nn.Dropout(dropout), nn.Linear(width, 1))

    def forward(self, tokens: torch.Tensor) -> torch.Tensor:
        """Logit per row; positive means XSS."""
        mask = tokens != PAD
        x = self.embed(tokens).transpose(1, 2)
        x = torch.cat([F.gelu(conv(x)) for conv in self.convs], dim=1) * mask.unsqueeze(1)
        for down in self.down:
            mask = mask[:, ::2]
            x = F.gelu(down(x)) * mask.unsqueeze(1)
        x = x.transpose(1, 2)
        x = x + self.position(torch.arange(x.size(1), device=x.device))
        x = self.norm(self.encoder(x, src_key_padding_mask=~mask))
        keep = mask.unsqueeze(-1)
        highest = x.masked_fill(~keep, -1e4).amax(dim=1)
        average = (x * keep).sum(dim=1) / keep.sum(dim=1)
        return self.head(torch.cat([highest, average], dim=-1)).squeeze(-1)


class XSSDetector:
    def __init__(self, folder: str | Path, device: str | None = None, batch_size: int = 256):
        folder = Path(folder)
        self.config = json.loads((folder / "config.json").read_text(encoding="utf-8"))
        if self.config.get("architecture") != "xss_char_cnn_transformer" or self.config.get("tokenizer") != "codepoints_v1":
            raise ValueError("config.json does not describe this detector")
        net = self.config["network"]
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        self.model = XSSNet(net["max_length"], net["embed_dim"], net["width"], net["layers"], net["heads"])
        self.model.load_state_dict(load_file(str(folder / "model.safetensors")))
        self.model.to(self.device).eval()
        self.threshold = float(self.config["threshold"])
        self.temperature = float(self.config["temperature"])
        if not (math.isfinite(self.temperature) and self.temperature > 0 and 0 <= self.threshold <= 1):
            raise ValueError("invalid threshold or temperature in config.json")
        self.batch_size = batch_size

    @classmethod
    def from_pretrained(cls, repo_or_folder: str, revision: str | None = None, device: str | None = None) -> "XSSDetector":
        """A local folder, or a Hugging Face repo id (downloads only config.json and model.safetensors)."""
        if Path(repo_or_folder).is_dir():
            return cls(repo_or_folder, device)
        from huggingface_hub import snapshot_download

        folder = snapshot_download(repo_or_folder, revision=revision, allow_patterns=["config.json", "model.safetensors"])
        return cls(folder, device)

    @torch.inference_mode()
    def _logits(self, chunks: list[str]) -> np.ndarray:
        rows = [encode(chunk, self.model.max_length) for chunk in chunks]
        order = np.argsort([len(row) for row in rows], kind="stable")
        out = np.zeros(len(rows))
        start = 0
        while start < len(order):
            longest = len(rows[order[min(start + self.batch_size, len(order)) - 1]])
            picks = order[start : start + max(8, min(self.batch_size, self.batch_size * 250 // longest))]
            tokens = np.zeros((len(picks), max(len(rows[i]) for i in picks)), dtype=np.int64)
            for j, i in enumerate(picks):
                tokens[j, : len(rows[i])] = rows[i]
            out[picks] = self.model(torch.from_numpy(tokens).to(self.device)).double().cpu().numpy()
            start += len(picks)
        return out

    def score(self, texts: list[str]) -> np.ndarray:
        """Calibrated probability of XSS for each text (the highest over its windows)."""
        chunks, owners = [], []
        for i, text in enumerate(texts):
            for chunk in windows(normalize(text, self.config["max_decode_rounds"]), self.config["window"], self.config["stride"]):
                chunks.append(chunk)
                owners.append(i)
        out = np.zeros(len(texts))
        if chunks:
            z = np.clip(self._logits(chunks) / self.temperature, -50, 50)
            np.maximum.at(out, owners, 1 / (1 + np.exp(-z)))
        return out

    def predict(self, texts: list[str]) -> list[dict]:
        """[{"text", "score", "is_xss"}] using the threshold tuned for 0.1% false alarms on validation data."""
        return [{"text": text, "score": round(float(s), 6), "is_xss": bool(s > self.threshold)} for text, s in zip(texts, self.score(texts))]


if __name__ == "__main__":
    import sys

    detector = XSSDetector.from_pretrained(sys.argv[1] if len(sys.argv) > 1 else ".")
    for row in detector.predict(sys.argv[2:] or ["<img src=x onerror=alert(1)>", "<b>hello</b>"]):
        print(f"{'XSS   ' if row['is_xss'] else 'benign'}  {row['score']:.3f}  {row['text'][:100]}")