detect

package
v0.4.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 26, 2026 License: MIT Imports: 13 Imported by: 0

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

View Source
const (
	RuleNewDevice = "TH-0006"
	RuleRareJA4   = "TH-0007"
)

Rule identifiers for inventory-derived findings.

View Source
const (
	RuleVerticalScan   = "TH-0003"
	RuleHorizontalScan = "TH-0004"
)

Rule identifiers for the two scan shapes.

View Source
const RuleBeaconing = "TH-0001"

RuleBeaconing is the stable rule identifier for this detector's alerts.

View Source
const RuleDNSTunnel = "TH-0002"

RuleDNSTunnel is the stable rule identifier for DNS tunnelling alerts.

View Source
const RuleExfil = "TH-0005"

RuleExfil is the rule identifier for large asymmetric outbound transfers.

Variables

View Source
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) Name

func (b *Beacon) Name() string

Name implements Detector.

func (*Beacon) OnFlowClosed

func (b *Beacon) OnFlowClosed(c *Context, f *model.Flow)

OnFlowClosed records how much was sent on each completed connection, which gives the size-consistency half of the score.

func (*Beacon) OnPacket

func (b *Beacon) OnPacket(c *Context, p *model.Packet, f *model.Flow, isNew bool)

OnPacket records the start of each new outbound connection.

func (*Beacon) OnTick

func (b *Beacon) OnTick(c *Context, now time.Time)

OnTick scores every tracked destination.

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.

func (Config) IsInternal

func (c Config) IsInternal(a netip.Addr) bool

IsInternal reports whether an address falls inside the monitored network.

type Context

type Context struct {
	Cfg Config
	Now time.Time
	// contains filtered or unexported fields
}

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.

func (*Context) Emit

func (c *Context) Emit(a model.Alert)

Emit publishes an alert. The detector name and timestamp are filled in automatically; suppression is applied by the engine.

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.

func (*DNSTunnel) Name

func (d *DNSTunnel) Name() string

Name implements Detector.

func (*DNSTunnel) OnPacket

func (d *DNSTunnel) OnPacket(c *Context, p *model.Packet, f *model.Flow, isNew bool)

OnPacket accumulates evidence from outbound DNS queries.

func (*DNSTunnel) OnTick

func (d *DNSTunnel) OnTick(c *Context, now time.Time)

OnTick scores every tracked domain.

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 NewEngine

func NewEngine(cfg Config, out func(model.Alert)) *Engine

NewEngine returns an engine that publishes alerts through out.

func (*Engine) Config

func (e *Engine) Config() Config

Config returns the engine's effective configuration.

func (*Engine) Detectors

func (e *Engine) Detectors() []string

Detectors lists the registered detector names.

func (*Engine) FlowClosed

func (e *Engine) FlowClosed(f *model.Flow)

FlowClosed dispatches a completed flow to the flow detectors.

func (*Engine) Packet

func (e *Engine) Packet(p *model.Packet, f *model.Flow, isNew bool)

Packet dispatches a decoded packet and its flow to the packet detectors.

func (*Engine) Register

func (e *Engine) Register(d Detector)

Register adds a detector, wiring it to whichever hooks it implements.

func (*Engine) Stats

func (e *Engine) Stats() Stats

Stats returns engine counters.

func (*Engine) Tick

func (e *Engine) Tick(now time.Time)

Tick dispatches a scoring cycle to the tick 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.

func (*Exfil) Name

func (e *Exfil) Name() string

Name implements Detector.

func (*Exfil) OnFlowClosed

func (e *Exfil) OnFlowClosed(c *Context, f *model.Flow)

OnFlowClosed evaluates a completed conversation.

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

type FlowDetector interface {
	Detector
	OnFlowClosed(c *Context, f *model.Flow)
}

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

func (in *Inventory) Devices() []model.Device

Devices returns a snapshot of the inventory, sorted by address, for the API.

func (*Inventory) Name

func (in *Inventory) Name() string

Name implements Detector.

func (*Inventory) OnFlowClosed

func (in *Inventory) OnFlowClosed(c *Context, f *model.Flow)

OnFlowClosed attributes TLS fingerprints and hostnames to devices.

func (*Inventory) OnPacket

func (in *Inventory) OnPacket(c *Context, p *model.Packet, f *model.Flow, isNew bool)

OnPacket maintains the device table.

func (*Inventory) OnTick

func (in *Inventory) OnTick(c *Context, now time.Time)

OnTick looks for fingerprints unique to a single host.

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
	// MinSharedFingerprints is how many fingerprints must be in use by two or
	// 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.

func (*Scan) Name

func (s *Scan) Name() string

Name implements Detector.

func (*Scan) OnPacket

func (s *Scan) OnPacket(c *Context, p *model.Packet, f *model.Flow, isNew bool)

OnPacket records connection attempts and which of them were accepted.

func (*Scan) OnTick

func (s *Scan) OnTick(c *Context, now time.Time)

OnTick evaluates each tracked source and expires stale evidence.

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.

type TickDetector

type TickDetector interface {
	Detector
	OnTick(c *Context, now time.Time)
}

TickDetector is called on a fixed cadence. Detectors that accumulate evidence over time (beaconing, tunnelling, scanning) do their scoring here rather than re-scoring on every packet.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL