Spaces:
Runtime error
Runtime error
| """ | |
| Module 3: Analyzer | |
| Signature-based detector that labels each injection as VULNERABLE or | |
| NOT_VULNERABLE. Per-type heuristics: | |
| * SQLi: count "First name:" occurrences (DVWA dump signature) and SQL error msgs | |
| * XSS: check payload reflection in HTML response, dangerous tag/attr patterns | |
| * LFI: search for /etc/passwd content signatures (root:x:0:0, etc.) | |
| When a pre-trained CSIC Random Forest model is supplied, the analyzer also | |
| attaches an ML confidence score (ml_confidence) and ML label (ml_label) to | |
| each result, giving a second opinion alongside the signature heuristics. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from dataclasses import asdict, dataclass, fields | |
| from pathlib import Path | |
| from typing import Any | |
| from intelliscan.config import ( | |
| LFI_SIGNATURES, | |
| RESULTS_DIR, | |
| SEVERITY, | |
| SQL_ERROR_PATTERNS, | |
| SQLI_DUMP_SIGNATURES, | |
| XSS_DANGEROUS_PATTERNS, | |
| ) | |
| log = logging.getLogger(__name__) | |
| class LabeledResult: | |
| """An injection result enriched with a vulnerability label and reason.""" | |
| target_url: str | |
| method: str | |
| vuln_type: str | |
| param: str | |
| payload: str | |
| status_code: int | |
| response_len: int | |
| response_body: str | |
| label: str # "VULNERABLE" | "NOT_VULNERABLE" | |
| reason: str | |
| severity: str # "HIGH" | "MEDIUM" | "LOW" | "INFO" | |
| ml_confidence: float | None = None # CSIC model confidence score [0β1] | |
| ml_label: str | None = None # "ATTACK" | "NORMAL" from CSIC model | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| # Injection-schema fields accepted from raw entries; anything else (e.g. a | |
| # previous run's label/reason when re-analyzing a labeled file) is dropped | |
| # instead of crashing the LabeledResult constructor. | |
| _INPUT_FIELDS: frozenset[str] = frozenset(f.name for f in fields(LabeledResult)) - { | |
| "label", | |
| "reason", | |
| "severity", | |
| "ml_confidence", | |
| "ml_label", | |
| } | |
| class Analyzer: | |
| """Signature-based vulnerability analyzer with optional CSIC ML scoring.""" | |
| def __init__( | |
| self, | |
| results: list[dict], | |
| csic_model: Any | None = None, | |
| baselines: dict[str, str] | None = None, | |
| ) -> None: | |
| self.raw = results | |
| self.labeled: list[LabeledResult] = [] | |
| self._csic_model = csic_model # sklearn RandomForestClassifier or None | |
| # target_url -> benign baseline response body (lowercased on lookup). | |
| # Used to subtract static page content from signature counts so form | |
| # chrome ("Username:"/"Password:" labels) isn't counted as a data dump. | |
| self._baselines = baselines or {} | |
| # ββ public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run(self) -> list[LabeledResult]: | |
| for entry in self.raw: | |
| label, reason = self._classify(entry) | |
| severity = SEVERITY.get(entry["vuln_type"], "INFO") if label == "VULNERABLE" else "INFO" | |
| self.labeled.append( | |
| LabeledResult( | |
| **{k: v for k, v in entry.items() if k in _INPUT_FIELDS}, | |
| label=label, | |
| reason=reason, | |
| severity=severity, | |
| ) | |
| ) | |
| if self._csic_model is not None: | |
| self._attach_ml_scores() | |
| n_vuln = sum(1 for r in self.labeled if r.label == "VULNERABLE") | |
| log.info( | |
| "Analyzed %d entries: %d vulnerable, %d not vulnerable%s", | |
| len(self.labeled), | |
| n_vuln, | |
| len(self.labeled) - n_vuln, | |
| " (CSIC ML scores attached)" if self._csic_model else "", | |
| ) | |
| return self.labeled | |
| # ββ CSIC ML scoring βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _attach_ml_scores(self) -> None: | |
| """Score every labeled result with the CSIC model in one batched call.""" | |
| import numpy as np | |
| from intelliscan.modules.csic_trainer import features_from_request | |
| model = self._csic_model | |
| if model is None or not self.labeled: | |
| return | |
| try: | |
| X = np.array( | |
| [ | |
| features_from_request( | |
| url=r.target_url, | |
| content=r.payload, | |
| method=r.method, | |
| ) | |
| for r in self.labeled | |
| ], | |
| dtype=np.float32, | |
| ) | |
| expected = getattr(model, "n_features_in_", X.shape[1]) | |
| if X.shape[1] != expected: | |
| log.warning( | |
| "CSIC model expects %d features but extractor produced %d; " | |
| "skipping ML scoring β retrain with `intelliscan train-csic`", | |
| expected, | |
| X.shape[1], | |
| ) | |
| return | |
| preds = model.predict(X) | |
| probas = model.predict_proba(X) | |
| for r, pred, proba in zip(self.labeled, preds, probas, strict=True): | |
| r.ml_label = "ATTACK" if int(pred) == 1 else "NORMAL" | |
| r.ml_confidence = float(proba[int(pred)]) | |
| except Exception as exc: # noqa: BLE001 | |
| log.warning("CSIC ML scoring failed: %s", exc) | |
| # ββ per-type detectors ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _classify(self, entry: dict) -> tuple[str, str]: | |
| body = entry.get("response_body", "").lower() | |
| payload = entry.get("payload", "") | |
| vt = entry.get("vuln_type", "").lower() | |
| baseline = self._baselines.get(entry.get("target_url", ""), "").lower() | |
| if vt == "sqli": | |
| return self._detect_sqli(body, payload, baseline) | |
| if vt.startswith("xss"): | |
| return self._detect_xss(body, payload, baseline) | |
| if vt == "lfi": | |
| return self._detect_lfi(body, payload) | |
| return "NOT_VULNERABLE", "Unknown vulnerability type" | |
| def _detect_sqli(body: str, payload: str, baseline: str = "") -> tuple[str, str]: | |
| # SQL error messages (multi-dialect: MySQL, PostgreSQL, MSSQL, Oracle, SQLite). | |
| # Only count errors the benign baseline did NOT already contain, so a page | |
| # that always shows an error string isn't flagged for every payload. | |
| for pattern in SQL_ERROR_PATTERNS: | |
| if pattern in body and pattern not in baseline: | |
| return "VULNERABLE", f"SQL error: {pattern!r}" | |
| # Data-dump indicators: repeated column-name labels suggest row exfiltration. | |
| # Subtract two sources of noise before counting: | |
| # 1. the benign baseline (static form labels like "Username:"), and | |
| # 2. the payload's OWN reflected text β a page that echoes the payload | |
| # back (e.g. the stored-XSS guestbook reflecting a UNION payload that | |
| # literally contains "information_schema") must not look like a dump. | |
| # Require at least 2 NET occurrences beyond both. | |
| payload_lower = payload.lower() | |
| reflections = body.count(payload_lower) if payload_lower else 0 | |
| for sig in SQLI_DUMP_SIGNATURES: | |
| net = body.count(sig) - baseline.count(sig) - reflections * payload_lower.count(sig) | |
| if net >= 2: | |
| return "VULNERABLE", f"Data dump β {sig!r} appears {net} more time(s) than baseline" | |
| return "NOT_VULNERABLE", "No SQL error or data-dump signature beyond baseline" | |
| def _detect_xss(body: str, payload: str, baseline: str = "") -> tuple[str, str]: | |
| payload_lower = payload.lower() | |
| # Full payload reflection (strongest signal for reflected XSS). Ignore if the | |
| # exact payload already appears in the benign baseline (static page content). | |
| if payload_lower in body and payload_lower not in baseline: | |
| return "VULNERABLE", "Payload reflected verbatim in response" | |
| # Partial match: a dangerous XSS pattern present in both payload and body, and | |
| # not already in the baseline page. | |
| for pattern in XSS_DANGEROUS_PATTERNS: | |
| if pattern in payload_lower and pattern in body and pattern not in baseline: | |
| return "VULNERABLE", f"Dangerous XSS pattern reflected: {pattern!r}" | |
| return "NOT_VULNERABLE", "Payload not reflected in response" | |
| def _detect_lfi(body: str, payload: str) -> tuple[str, str]: | |
| for sig in LFI_SIGNATURES: | |
| if sig in body: | |
| return "VULNERABLE", f"File content signature: {sig!r}" | |
| return "NOT_VULNERABLE", "No file inclusion signature" | |
| # ββ persistence βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save(self, path: str | Path) -> Path: | |
| path = Path(path) | |
| path.write_text( | |
| json.dumps([r.to_dict() for r in self.labeled], indent=2), | |
| encoding="utf-8", | |
| ) | |
| return path | |
| # ββ stats βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def stats_by_type(self) -> dict[str, dict[str, int]]: | |
| out: dict[str, dict[str, int]] = {} | |
| for r in self.labeled: | |
| d = out.setdefault(r.vuln_type, {"total": 0, "vulnerable": 0, "not_vulnerable": 0}) | |
| d["total"] += 1 | |
| d["vulnerable" if r.label == "VULNERABLE" else "not_vulnerable"] += 1 | |
| return out | |
| # ββ CLI helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(input_file: str = "injection_results.json", output: str = "labeled_results.json") -> None: | |
| input_path = RESULTS_DIR / input_file | |
| raw = json.loads(input_path.read_text()) | |
| analyzer = Analyzer(raw) | |
| analyzer.run() | |
| analyzer.save(RESULTS_DIR / output) | |
| print(json.dumps(analyzer.stats_by_type(), indent=2)) | |
| if __name__ == "__main__": | |
| import argparse | |
| p = argparse.ArgumentParser(description="IntelliScan response analyzer") | |
| p.add_argument("--input", default="injection_results.json") | |
| p.add_argument("--output", default="labeled_results.json") | |
| args = p.parse_args() | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") | |
| main(args.input, args.output) | |