Documentation
¶
Overview ¶
Package detect hosts the detection engine and the built-in detectors.
Detectors are deliberately small, independent objects with one job each. The engine owns dispatch, alert identity, and suppression, so a detector author writes only the analysis and never has to think about rate limiting or concurrency. Adding a detector is implementing one interface and registering it — that is the extension point the whole tool is built around.
Index ¶
- Constants
- Variables
- func NewAlertID() string
- type Beacon
- type BeaconConfig
- type Config
- type Context
- type DNSTunnel
- type DNSTunnelConfig
- type Detector
- type Engine
- type Exfil
- type ExfilConfig
- type FlowDetector
- type Inventory
- type InventoryConfig
- type PacketDetector
- type Scan
- type ScanConfig
- type Stats
- type TickDetector
Constants ¶
const ( RuleNewDevice = "TH-0006" RuleRareJA4 = "TH-0007" )
Rule identifiers for inventory-derived findings.
const ( RuleVerticalScan = "TH-0003" RuleHorizontalScan = "TH-0004" )
Rule identifiers for the two scan shapes.
const RuleBeaconing = "TH-0001"
RuleBeaconing is the stable rule identifier for this detector's alerts.
const RuleDNSTunnel = "TH-0002"
RuleDNSTunnel is the stable rule identifier for DNS tunnelling alerts.
const RuleExfil = "TH-0005"
RuleExfil is the rule identifier for large asymmetric outbound transfers.
Variables ¶
var DefaultHomeNets = []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"), }
DefaultHomeNets is the RFC 1918 / link-local / loopback set, which is the right default for the overwhelming majority of deployments.
Functions ¶
func NewAlertID ¶
func NewAlertID() string
NewAlertID returns a random 128-bit identifier rendered as hex.
Types ¶
type Beacon ¶
type Beacon struct {
// contains filtered or unexported fields
}
Beacon detects command-and-control check-in patterns.
The intuition: a human browsing generates connections at irregular intervals, while an implant polling for tasking generates them on a timer. Malware authors know this and add jitter, so the detector cannot simply look for identical gaps — it measures *dispersion* and accepts anything tight enough, using a robust measure so a few missed check-ins do not mask the pattern.
Payload size is a second, independent axis: a beacon with nothing to report sends near-identical bytes every time, which is a signal that survives even when the timing jitter is wide.
func NewBeacon ¶
func NewBeacon(cfg BeaconConfig) *Beacon
NewBeacon returns a beaconing detector. A zero config selects the defaults.
func (*Beacon) OnFlowClosed ¶
OnFlowClosed records how much was sent on each completed connection, which gives the size-consistency half of the score.
type BeaconConfig ¶
type BeaconConfig struct {
// MinConnections is how many connections must be observed before a verdict
// is possible. Too low and ordinary repeated requests look periodic; the
// default of 8 is roughly where random traffic stops producing low
// dispersion by chance.
MinConnections int
// MinInterval and MaxInterval bound the beacon periods considered. Below
// MinInterval we are looking at a chatty application, above MaxInterval
// there is not enough evidence in a capture to call it.
MinInterval time.Duration
MaxInterval time.Duration
// Threshold is the score in [0,1] at or above which an alert fires.
Threshold float64
// History is how far back connection times are retained.
History time.Duration
// MaxTracked bounds the number of (src,dst,port) triples tracked.
MaxTracked int
}
BeaconConfig tunes the beaconing detector.
func DefaultBeaconConfig ¶
func DefaultBeaconConfig() BeaconConfig
DefaultBeaconConfig returns tuning that finds typical C2 check-ins without firing on ordinary periodic infrastructure traffic.
type Config ¶
type Config struct {
// HomeNets defines which addresses count as "inside". Direction matters
// enormously for detection: 500 MB leaving the network is exfiltration,
// 500 MB arriving is a software update.
HomeNets []netip.Prefix
// MaxSuppressionEntries bounds how many distinct findings the engine
// remembers for duplicate suppression. Zero selects the default.
MaxSuppressionEntries int
// AlertCooldown is the minimum gap between two identical alerts. Without
// it a host beaconing every 30 seconds would produce an alert on every
// tick forever, and the analyst would mute the tool by the end of the day.
AlertCooldown time.Duration
// Policy is consulted for every alert before it is emitted. Returning
// false drops the alert; the alert may also be modified in place.
//
// This is the seam the YAML rule pack plugs into — disabling a rule,
// exempting a known-good host, overriding a severity, or attaching extra
// ATT&CK techniques. The engine deliberately knows nothing about rule
// files: detection logic and deployment policy are different concerns with
// different rates of change, and a detector that had to parse YAML would
// be markedly harder to test.
//
// Nil means allow everything.
Policy func(*model.Alert) bool
}
Config holds engine-wide settings shared with every detector.
type Context ¶
Context is handed to a detector on every callback. It carries the shared configuration, the current time, and the channel back to the engine.
One Context is allocated per detector at registration and reused, so dispatch itself does not allocate.
type DNSTunnel ¶
type DNSTunnel struct {
// contains filtered or unexported fields
}
DNSTunnel detects data smuggled through DNS queries.
DNS is the ideal covert channel: it is allowed out of nearly every network, it is rarely inspected, and recursive resolvers will faithfully deliver an attacker's query to an attacker's authoritative server. Tunnelling tools encode data into subdomain labels, which produces a signature no legitimate resolution pattern matches — thousands of never-repeated, high-entropy, unusually long names under a single domain.
Each of those properties alone has innocent explanations (CDNs generate long names, antivirus lookups generate high-entropy ones, and a busy host generates volume). Requiring all of them together is what keeps this quiet.
func NewDNSTunnel ¶
func NewDNSTunnel(cfg DNSTunnelConfig) *DNSTunnel
NewDNSTunnel returns a DNS tunnelling detector.
type DNSTunnelConfig ¶
type DNSTunnelConfig struct {
MinQueries int // evidence required before scoring
Threshold float64 // score in [0,1] at which an alert fires
History time.Duration // how long a domain's evidence is retained
MaxTracked int // bound on tracked (client, domain) pairs
MaxUniqueNames int // bound on the per-domain unique-name set
}
DNSTunnelConfig tunes the DNS tunnelling detector.
func DefaultDNSTunnelConfig ¶
func DefaultDNSTunnelConfig() DNSTunnelConfig
DefaultDNSTunnelConfig returns tuning that catches iodine/dnscat2-style tunnels and DNS exfiltration without firing on CDN or antivirus lookups.
type Detector ¶
type Detector interface {
// Name is the stable identifier used in alerts and configuration.
Name() string
}
Detector is the base interface every detector satisfies.
A detector opts into the events it cares about by additionally implementing PacketDetector, FlowDetector, and/or TickDetector. Splitting the hooks like this means a cheap flow-level detector is never invoked per packet.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine dispatches events to registered detectors and publishes their alerts.
func (*Engine) FlowClosed ¶
FlowClosed dispatches a completed flow to the flow detectors.
type Exfil ¶
type Exfil struct {
// contains filtered or unexported fields
}
Exfil flags flows that move a lot of data out of the network.
This detector is deliberately simple, because the sophisticated version is not better — it is just harder to explain. The value is in the direction asymmetry: a client that uploads 200 MB while downloading 2 MB has inverted the shape of ordinary traffic, and that is worth an analyst's attention whether the destination is an attacker's server or an unsanctioned file host.
It runs on flow close rather than per packet so that the verdict is made on the complete conversation, which avoids alerting mid-transfer on something that turns out to be a symmetric sync.
func NewExfil ¶
func NewExfil(cfg ExfilConfig) *Exfil
NewExfil returns a data-transfer detector. A zero config selects the defaults.
type ExfilConfig ¶
type ExfilConfig struct {
// MinBytesOut is the floor below which a transfer is not worth reporting
// regardless of how lopsided it is.
MinBytesOut uint64
// MinRatio is how many times more data must leave than arrive. Normal
// client traffic is heavily inbound — you download far more than you
// upload — so inverting that ratio is the signal.
MinRatio float64
}
ExfilConfig tunes the data-transfer detector.
func DefaultExfilConfig ¶
func DefaultExfilConfig() ExfilConfig
DefaultExfilConfig returns thresholds tuned to catch a meaningful staging transfer without firing on video calls or routine backups.
type FlowDetector ¶
FlowDetector is called once per flow, when the flow is reaped. The flow is a copy and is safe to retain.
type Inventory ¶
type Inventory struct {
// contains filtered or unexported fields
}
Inventory builds the passive asset inventory and flags anomalous TLS stacks.
The inventory is the part of this tool that stays useful on a quiet network: knowing what is on the wire, and what software it speaks, is valuable before anything goes wrong. The rare-fingerprint detector then falls straight out of it — if every workstation in the building presents the same three JA4 hashes because they all run the same browser build, a fourth hash appearing on exactly one host is worth a look. That is frequently how an implant with its own statically-linked TLS stack announces itself.
func NewInventory ¶
func NewInventory(cfg InventoryConfig) *Inventory
NewInventory returns an inventory detector. A zero config selects defaults.
func (*Inventory) Devices ¶
Devices returns a snapshot of the inventory, sorted by address, for the API.
func (*Inventory) OnFlowClosed ¶
OnFlowClosed attributes TLS fingerprints and hostnames to devices.
type InventoryConfig ¶
type InventoryConfig struct {
// MinHostsForRarity is how many internal hosts must be known before a
// "only one host uses this TLS stack" claim is meaningful. On a network of
// three machines, every fingerprint is rare.
MinHostsForRarity int
// MinFingerprintsForRarity is the equivalent baseline for fingerprints.
MinFingerprintsForRarity int
// more hosts before any fingerprint may be called rare.
//
// This is the guard that makes the detector honest. Early in a capture
// every host has contributed exactly one fingerprint, so *everything* looks
// unique and the detector would indict the entire network. Requiring
// evidence that some stacks are genuinely shared proves a baseline exists
// before anything is measured against it.
MinSharedFingerprints int
// MinObservations is how many flows a fingerprint must appear on before its
// rarity counts. One connection from one host is not evidence of anything.
MinObservations int
// MinAge is how long a fingerprint must have been known before it may be
// called rare.
//
// Without this the detector reports the first host to use any new stack.
// A browser that prefers HTTP/3 produces a fingerprint nobody has yet, and
// for the few minutes before a second machine happens to use it, it is
// indistinguishable from an implant. Rarity is a claim about the network
// over time, so it needs time to be true.
MinAge time.Duration
// MaxDevices bounds the inventory.
MaxDevices int
// MaxFingerprints bounds the set of distinct JA4s tracked.
//
// Sibling to MaxDevices, and it was missing. The device map was capped
// while the fingerprint map beside it grew without limit, which is the
// wrong way round for a daemon: a network can only hold so many hosts,
// but a host that varies its TLS stack, or an attacker who chooses to,
// can mint fingerprints indefinitely.
MaxFingerprints int
// MaxJA4sPerDevice bounds the per-device fingerprint list.
//
// The list is display detail, and it is scanned linearly on the packet
// path, so an unbounded one costs time as well as memory.
MaxJA4sPerDevice int
// SilenceNewDevice suppresses the informational new-host alert. Phrased as
// an opt-out so the zero value behaves like the documented default.
SilenceNewDevice bool
}
InventoryConfig tunes the asset inventory and rare-fingerprint detector.
func DefaultInventoryConfig ¶
func DefaultInventoryConfig() InventoryConfig
DefaultInventoryConfig returns sensible baselines.
type PacketDetector ¶
type PacketDetector interface {
Detector
OnPacket(c *Context, p *model.Packet, f *model.Flow, isNew bool)
}
PacketDetector is called for every decoded packet, with the flow it belongs to already updated. This is the hot path: implementations must not allocate per packet in the common case.
type Scan ¶
type Scan struct {
// contains filtered or unexported fields
}
Scan detects port scanning and network sweeps.
The signal is unanswered SYNs at high cardinality. A legitimate client knows which port it wants; a scanner is asking a question and most of the answers are "no". Distinguishing the two shapes matters for triage — a vertical scan against one host is reconnaissance of that host, while a horizontal sweep for one port across a subnet is usually an attacker looking for a specific exploitable service, which is a much more urgent finding.
Both halves of that sentence are load-bearing and both are enforced. Evidence ages out of Window, so a host is judged on what it did recently rather than on everything it has ever done, and MaxAnsweredRatio requires that the probes mostly failed. Without the first, any long-lived host eventually crosses the cardinality thresholds; without the second, a busy client crosses them legitimately.
func NewScan ¶
func NewScan(cfg ScanConfig) *Scan
NewScan returns a port-scan detector. A zero config selects the defaults.
type ScanConfig ¶
type ScanConfig struct {
// VerticalPorts is how many distinct ports on one target constitute a
// vertical scan ("what is this host running?").
VerticalPorts int
// HorizontalHosts is how many distinct targets on one port constitute a
// horizontal sweep ("who else runs SMB?").
HorizontalHosts int
// Window is the sliding period over which evidence accumulates. An
// observation older than this stops counting towards either threshold.
Window time.Duration
// MaxAnsweredRatio is the largest fraction of probes that may have been
// answered for the source to still be called a scanner.
//
// This is what separates a scanner from a busy client, and without it the
// cardinality thresholds alone accuse any host that talks to enough peers.
// A browser knows the port it wants and nearly everything it opens
// succeeds; a scanner is asking a question and most of the answers are no.
MaxAnsweredRatio float64
// MaxTracked bounds the number of source hosts tracked at once.
MaxTracked int
// MaxPortsPerTarget and MaxTargetsPerPort bound per-source memory. A scan
// is by definition high-cardinality, so these caps are what stop the
// detector from becoming the denial of service it is meant to detect.
MaxPortsPerTarget int
MaxTargetsPerPort int
}
ScanConfig tunes the port-scan detector.
func DefaultScanConfig ¶
func DefaultScanConfig() ScanConfig
DefaultScanConfig returns thresholds that catch an nmap default scan while ignoring a browser opening a dozen parallel connections.
type Stats ¶
type Stats struct {
Emitted uint64 `json:"emitted"`
Suppressed uint64 `json:"suppressed"`
// Filtered counts alerts dropped by rule policy: a disabled rule or a
// matching exception. Reported separately from Suppressed so an operator
// can tell "the tool is quiet" from "I told the tool to be quiet".
Filtered uint64 `json:"filtered"`
Detectors int `json:"detectors"`
}
Stats reports engine counters.