simoox's picture
Upload 204 files
5e0bd20 verified
Raw
History Blame Contribute Delete
10.8 kB
"""
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__)
@dataclass
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"
@staticmethod
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"
@staticmethod
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"
@staticmethod
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)