Download xss_detector.py from hareramray325/websec: direct link, hf CLI and curl.
- Browser
- Download file 7.4 kB
-
https://huggingface.co/hareramray325/websec/resolve/main/xss_detector.py
- Command line
-
hf download hf://hareramray325/websec/xss_detector.py
-
curl -L -o xss_detector.py https://huggingface.co/hareramray325/websec/resolve/main/xss_detector.py
7.4 kB
| """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 | |
| 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) | |
| 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]}") | |