Ctrl K
ComunidadGuías…

soc_ai/agent/classifier.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Deterministic alert-class classifier.

Tags every alert with a coarse class. The orchestrator uses it for
high-stakes gating (e.g. an ``exploit_attempt`` / ``post_compromise``
alert is never auto-acked on a confident false-positive verdict).

The classifier is **deterministic** (no LLM call) and operates on the
typed :class:`SoAlert` fields populated by :func:`SoAlert.from_es_hit`.
It must not make any IO.

The classes:

- ``informational_visibility`` — ET INFO / misc-activity / policy-only
  signals. The dominant FP class on this grid.
- ``recon`` — port scans, fingerprinting, attempted-recon classtypes.
  Correlation with related alerts is the deciding factor.
- ``exploit_attempt`` — active exploitation signatures (attempted-admin /
  attempted-user / web-application-attack / shellcode-detect).
- ``post_compromise`` — confirmed C2 / exfil / trojan-activity. Verdict
  bias should never tip toward false-positive without strong evidence.
- ``unknown`` — fallback when no signal matches.

Totality: the classification table (:data:`_CLASSTYPE_DESCRIPTIONS`) and the
routing map (:data:`_CLASSTYPE_MAP`) cover the same ground. Every shortname the
table can produce is either mapped to a class or named in
:data:`_UNROUTED_CLASSTYPES`, and a test enforces it, so a classification can no
longer arrive with nobody having decided what it means.
"""

from __future__ import annotations

from enum import StrEnum

from soc_ai.so_client.models import SoAlert


class AlertClass(StrEnum):
    """Coarse alert class used by the orchestrator's routing layer."""

    INFORMATIONAL_VISIBILITY = "informational_visibility"
    RECON = "recon"
    EXPLOIT_ATTEMPT = "exploit_attempt"
    POST_COMPROMISE = "post_compromise"
    UNKNOWN = "unknown"


# Suricata classtype → AlertClass. Drawn from the upstream
# `classification.config` shipped with Suricata 6/7.
#
# This map and the description table below cover the SAME ground: every
# shortname the table can produce either appears here or is listed in
# ``_UNROUTED_CLASSTYPES``, and a test holds that (see
# ``tests/test_classifier.py::test_every_classification_the_table_knows_has_a_
# routing_decision``). The map used to enumerate only the 20 classifications
# someone had got round to, and the other 23 fell through to UNKNOWN in
# silence, a default that reads as "no opinion" and spends as "safe". That is
# how "GPL SHELLCODE x86 setgid 0" came to be acknowledged unattended: it
# carries the category "A system call was detected", which normalizes to
# system-call-detect, which nothing here had an opinion about. 156 unattended
# acknowledgements across two shellcode rules on the production instance.
#
# So the rule is totality, not a longer list: a classification with no routing
# decision is a defect, and the place to record the decision is here, on the
# field the rule author declared, rather than in a list of substrings to look
# for in rule names.
_CLASSTYPE_MAP: dict[str, AlertClass] = {
    # informational_visibility
    "misc-activity": AlertClass.INFORMATIONAL_VISIBILITY,
    "not-suspicious": AlertClass.INFORMATIONAL_VISIBILITY,
    "policy-violation": AlertClass.INFORMATIONAL_VISIBILITY,
    "protocol-command-decode": AlertClass.INFORMATIONAL_VISIBILITY,
    "external-ip-check": AlertClass.INFORMATIONAL_VISIBILITY,
    "tcp-connection": AlertClass.INFORMATIONAL_VISIBILITY,
    "icmp-event": AlertClass.INFORMATIONAL_VISIBILITY,
    "inappropriate-content": AlertClass.INFORMATIONAL_VISIBILITY,
    # recon
    "attempted-recon": AlertClass.RECON,
    "successful-recon-limited": AlertClass.RECON,
    "successful-recon-largescale": AlertClass.RECON,
    "network-scan": AlertClass.RECON,
    "rpc-portmap-decode": AlertClass.RECON,
    # exploit_attempt: the rule author declared that something was tried.
    "attempted-admin": AlertClass.EXPLOIT_ATTEMPT,
    "attempted-user": AlertClass.EXPLOIT_ATTEMPT,
    "unsuccessful-user": AlertClass.EXPLOIT_ATTEMPT,
    "web-application-attack": AlertClass.EXPLOIT_ATTEMPT,
    "shellcode-detect": AlertClass.EXPLOIT_ATTEMPT,
    # The classification the GPL SHELLCODE family actually ships with. Its
    # description is the mild-sounding "A system call was detected"; the rules
    # carrying it match x86 setgid/setuid/execve stubs in traffic.
    "system-call-detect": AlertClass.EXPLOIT_ATTEMPT,
    "attempted-dos": AlertClass.EXPLOIT_ATTEMPT,
    "successful-dos": AlertClass.EXPLOIT_ATTEMPT,
    "denial-of-service": AlertClass.EXPLOIT_ATTEMPT,
    "exploit-kit": AlertClass.EXPLOIT_ATTEMPT,
    "misc-attack": AlertClass.EXPLOIT_ATTEMPT,
    "suspicious-login": AlertClass.EXPLOIT_ATTEMPT,
    "default-login-attempt": AlertClass.EXPLOIT_ATTEMPT,
    "social-engineering": AlertClass.EXPLOIT_ATTEMPT,
    # post_compromise: the rule author declared that something is running.
    "trojan-activity": AlertClass.POST_COMPROMISE,
    "successful-admin": AlertClass.POST_COMPROMISE,
    "successful-user": AlertClass.POST_COMPROMISE,
    "command-and-control": AlertClass.POST_COMPROMISE,
    "domain-c2": AlertClass.POST_COMPROMISE,
    "exfiltration": AlertClass.POST_COMPROMISE,
    "targeted-activity": AlertClass.POST_COMPROMISE,
    "credential-theft": AlertClass.POST_COMPROMISE,
    "coin-mining": AlertClass.POST_COMPROMISE,
    "pup-activity": AlertClass.POST_COMPROMISE,
}


# Classifications the map deliberately says nothing about, so that "nothing to
# say" is a written decision rather than an oversight. Each of these describes a
# property of the traffic rather than a judgement about it (a string matched, a
# port that was unusual, a protocol that was odd), and the population behind them
# is dominated by ordinary traffic. They fall through to UNKNOWN and get the full
# pipeline, which is the same behaviour as before; the difference is that a new
# classification arriving in the table cannot join them by accident.
_UNROUTED_CLASSTYPES: frozenset[str] = frozenset(
    {
        "unknown",
        "bad-unknown",
        "string-detect",
        "suspicious-filename-detect",
        "unusual-client-port-connection",
        "non-standard-protocol",
        "web-application-activity",
    }
)


# Suricata classification DESCRIPTION → shortname.
#
# ``SoAlert.classtype`` is parsed from Suricata EVE's ``alert.category``, and
# EVE writes the classification's description text, never its shortname. Every
# classtype table in this codebase (the map above, decision_templates'
# _ATTACK_CLASSTYPES, the auto-acknowledge high-stakes guard, three decision
# templates) was keyed on shortnames, so on live Security Onion data none of
# them ever matched anything. It stayed invisible because the synthetic eval
# scenarios render the shortname, so every test agreed with the code and
# disagreed with the grid.
#
# Measured across 180 recorded production runs, the classtype field held only
# these nine values, all of them descriptions: "Misc activity" (77), "Device
# Retrieving External IP Address Detected" (27), "Not Suspicious Traffic" (26),
# "Potentially Bad Traffic" (18), "Potential Corporate Privacy Violation" (17),
# "Attempted Denial of Service" (6), "Attempted Information Leak" (4), "Malware
# Command and Control Activity Detected" (4), "Attempted Administrator
# Privilege Gain" (1). A "GPL MISC Teardrop attack" carrying the denial-of-
# service description was auto-acknowledged twice as a result.
#
# Source: Suricata's own etc/classification.config. The mapping is exact, so an
# unrecognized value is passed through rather than guessed at; a deployment
# running a ruleset with its own classifications keeps whatever it sends, and
# the shortname form (fixtures, rendered synthetic scenarios, Snort-style
# sources) is idempotent under this.
_CLASSTYPE_DESCRIPTIONS: dict[str, str] = {
    "not suspicious traffic": "not-suspicious",
    "unknown traffic": "unknown",
    "potentially bad traffic": "bad-unknown",
    "attempted information leak": "attempted-recon",
    "information leak": "successful-recon-limited",
    "large scale information leak": "successful-recon-largescale",
    "attempted denial of service": "attempted-dos",
    "denial of service": "successful-dos",
    "attempted user privilege gain": "attempted-user",
    "unsuccessful user privilege gain": "unsuccessful-user",
    "successful user privilege gain": "successful-user",
    "attempted administrator privilege gain": "attempted-admin",
    "successful administrator privilege gain": "successful-admin",
    "decode of an rpc query": "rpc-portmap-decode",
    "executable code was detected": "shellcode-detect",
    "a suspicious string was detected": "string-detect",
    "a suspicious filename was detected": "suspicious-filename-detect",
    "an attempted login using a suspicious username was detected": "suspicious-login",
    "a system call was detected": "system-call-detect",
    "a tcp connection was detected": "tcp-connection",
    "a network trojan was detected": "trojan-activity",
    "a client was using an unusual port": "unusual-client-port-connection",
    "detection of a network scan": "network-scan",
    "detection of a denial of service attack": "denial-of-service",
    "detection of a non-standard protocol or event": "non-standard-protocol",
    "generic protocol command decode": "protocol-command-decode",
    "access to a potentially vulnerable web application": "web-application-activity",
    "web application attack": "web-application-attack",
    "misc activity": "misc-activity",
    "misc attack": "misc-attack",
    "generic icmp event": "icmp-event",
    "inappropriate content was detected": "inappropriate-content",
    "potential corporate privacy violation": "policy-violation",
    "attempt to login by a default username and password": "default-login-attempt",
    "targeted malicious activity was detected": "targeted-activity",
    "exploit kit activity detected": "exploit-kit",
    "device retrieving external ip address detected": "external-ip-check",
    "domain observed used for c2 detected": "domain-c2",
    "possibly unwanted program detected": "pup-activity",
    "successful credential theft detected": "credential-theft",
    "possible social engineering attempted": "social-engineering",
    "crypto currency mining activity detected": "coin-mining",
    "malware command and control activity detected": "command-and-control",
    "data exfiltration detected": "exfiltration",
}


def normalize_classtype(classtype: str | None) -> str:
    """Return the Suricata classtype SHORTNAME for whatever the sensor sent.

    Casefolded and whitespace-trimmed. A recognized description maps to its
    shortname; anything else (a shortname already, or a classification a local
    ruleset invented) is returned lowercased and stripped, so this is idempotent
    and never invents a class it does not have evidence for. ``None`` becomes
    the empty string, which matches no table.

    Every classtype comparison in the codebase goes through here. The raw value
    stays on ``SoAlert.classtype`` for display, because that is what the analyst
    will see in Security Onion.
    """
    key = (classtype or "").strip().lower()
    return _CLASSTYPE_DESCRIPTIONS.get(key, key)


def classify_alert(alert: SoAlert) -> AlertClass:
    """Map a typed :class:`SoAlert` to its :class:`AlertClass`.

    Decision order:

    1. Suricata ``classtype`` (when present) is the strongest signal —
       it's rule-author-declared metadata. Normalized through
       :func:`normalize_classtype` first, because the wire carries the
       classification's description text rather than its shortname. A
       classification listed in :data:`_UNROUTED_CLASSTYPES` falls through on
       purpose and the remaining steps decide.
    2. ``signature_severity == "Critical"`` upgrades to POST_COMPROMISE
       even when classtype is missing — Critical signatures are
       rule-author-declared post-compromise indicators.
    3. ``signature_severity == "Major"`` maps to EXPLOIT_ATTEMPT in the
       absence of classtype data.
    4. ``rule_metadata.is_informational`` (Informational severity) maps
       to INFORMATIONAL_VISIBILITY.
    5. Fall through to UNKNOWN.

    The classifier never reads ``rule_name`` strings (mitigation:
    closed allowlist on classtype/metadata, not regex on names).
    """
    classtype = normalize_classtype(alert.classtype)
    if classtype in _CLASSTYPE_MAP:
        return _CLASSTYPE_MAP[classtype]

    rm = alert.rule_metadata
    sig_sev = (rm.signature_severity or "").strip().lower() if rm else ""
    if sig_sev == "critical":
        return AlertClass.POST_COMPROMISE
    if sig_sev == "major":
        return AlertClass.EXPLOIT_ATTEMPT
    if sig_sev == "informational":
        return AlertClass.INFORMATIONAL_VISIBILITY

    return AlertClass.UNKNOWN


__all__ = [
    "AlertClass",
    "classify_alert",
    "normalize_classtype",
]