runtime

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 32 Imported by: 0

Documentation

Overview

Package runtime is the offline core of the runtime threat-detection sensor (dsecrat-runtime). It defines the telemetry Event model, an EventSource interface that abstracts where events come from (a live kernel probe on Linux, or a recorded fixture stream anywhere), a versioned detection rule set mapped to MITRE ATT&CK for Containers, plus forensic capture and response hooks.

The design is deliberately split into a deterministic, portable core and a platform-specific edge. Every rule is a pure function of (Event, State) — it never reads the wall clock or a random source — so the same recorded stream always yields byte-identical detections. That makes the whole engine testable on any OS from committed fixtures, with the real eBPF attach parked behind Linux build tags (see NOTES.md). Phases 6 (reporting/MCP) and 7 (prevention) consume the Event and EventSource types and the generated SeccompProfile.

Package runtime (this file): a live /proc-polling EventSource.

This file holds every piece of logic that is testable without a real Linux kernel: parsing the handful of /proc/<pid> files the sensor cares about, diffing successive process-table snapshots into exec events, and decoding /proc/<pid>/net/tcp connection rows. None of it touches an actual /proc directory — callers pass a root path, so tests substitute a fixture directory built with plain files (see procsource_test.go). The only bits that require real Linux (walking /proc's numeric entries and reading the exe symlink via os.Readlink) live behind the //go:build linux tag in procsource_linux.go, which calls into the parser here.

Package runtime (this file): container attribution from cgroup lines.

This is an opt-in host integration, in the same spirit as internal/dockercli: it only activates when a runtime CLI ("docker", "ctr", or "crictl") is found on PATH, all shelling out passes arguments as a vector (never a shell string), and any id used to build an exec argument is validated against a strict charset before it ever reaches exec.CommandContext. None of this is part of the deterministic detection core; it is a best-effort enrichment step that maps a raw cgroup path (as read from /proc/<pid>/cgroup) to container metadata (ContainerInfo) so a Process/Event can be attributed to the workload it belongs to.

Index

Constants

View Source
const EnforceAck = "I acknowledge dsecrat-runtime may kill or quarantine workloads on detection"

EnforceAck is the exact acknowledgement required to arm destructive response. Mirroring the attack-sim opt-in, a boolean alone is not enough — arming prevention must be a conscious, explicit act.

View Source
const RuleSetVersion = "ds-rt-2026.07"

RuleSetVersion identifies the built-in detection rule pack. It is stamped onto every detection and forensic bundle so an alert is traceable to the exact heuristics that produced it. Bump it whenever rule behavior changes.

View Source
const SensorVersion = "0.1.0"

SensorVersion is the sensor/core version, surfaced by `dsecrat-runtime version`.

Variables

View Source
var ErrLiveKernelParked = errors.New("runtime: live eBPF attach is not built into this binary yet (see NOTES.md master action); use replay mode")

ErrLiveKernelParked is returned by the Linux NewLiveSource until the eBPF loader is wired in (a deliberately parked master action — see NOTES.md). It lets the daemon compile and run on Linux today, failing loudly and safely rather than pretending to attach to the kernel.

View Source
var ErrLiveUnsupported = errors.New("runtime: live kernel event source is only available on Linux")

ErrLiveUnsupported is returned by NewLiveSource on platforms without a kernel probe (everything that is not Linux). Callers should fall back to replay.

Functions

func SortDetections

func SortDetections(ds []Detection)

SortDetections orders detections deterministically: by event sequence, then rule id. Two runs over the same stream therefore emit an identical list.

func VerifyEvidenceBytes

func VerifyEvidenceBytes(data []byte) (bool, error)

VerifyEvidenceBytes checks an evidence artifact read from disk without needing to fully unmarshal the bundle into typed structs. It canonicalizes the raw bundle bytes (json.Compact is the inverse of the indentation WriteToDir adds) and compares their sha256 to the sealed digest. This is the chain-of-custody check a downstream tool runs, and it is robust to pretty-printing and to types (like engine.Severity) that marshal but do not unmarshal symmetrically.

Types

type Action

type Action struct {
	Kind      ActionKind `json:"kind"`
	RuleID    string     `json:"rule_id"`
	Container string     `json:"container"`
	PID       int        `json:"pid,omitempty"`
	Reason    string     `json:"reason"`
}

Action is a planned response for one detection.

type ActionKind

type ActionKind string

ActionKind is the response taken for a detection.

const (
	ActionAlert      ActionKind = "alert"
	ActionKill       ActionKind = "kill"       // terminate the offending process
	ActionQuarantine ActionKind = "quarantine" // isolate the container (netns/pause)
)

type Baseline

type Baseline struct {
	Version string `json:"version"`
	// Workloads maps an image identifier to its observed behavior.
	Workloads map[string]*WorkloadProfile `json:"workloads"`
}

Baseline is a learned profile of a workload's normal behavior: the syscalls, executables, and egress endpoints observed during a trusted learning window, grouped by image. It powers two things: anomaly detection (deviation from normal, catching zero-days no signature covers) and least-privilege profile generation (turn "what it actually did" into "what it is allowed to do").

It is a plain, serializable value so a baseline can be recorded once and replayed deterministically — no model, no training run, no clock.

type ContainerInfo

type ContainerInfo struct {
	ID         string `json:"id,omitempty"`
	Name       string `json:"name,omitempty"`
	ImageID    string `json:"image_id,omitempty"`
	ImageRef   string `json:"image_ref,omitempty"`
	Runtime    string `json:"runtime,omitempty"` // docker, containerd, cri-o
	Privileged bool   `json:"privileged,omitempty"`
	// AIAgent marks a workload declared to be an AI/LLM agent (from an image
	// label or deploy annotation). The novel agent-runtime rules key off this;
	// it is inert unless those opt-in rules are enabled.
	AIAgent bool `json:"ai_agent,omitempty"`
}

ContainerInfo attributes an event to a container/workload. ImageID ties the running process back to the artifact the earlier phases scanned, which is what lets drift detection ask "did this binary ship in the image?".

type ContainerResolver

type ContainerResolver struct {
	// contains filtered or unexported fields
}

ContainerResolver attributes cgroup lines to container metadata, caching results so a busy sensor does not re-exec the runtime CLI for every event from the same container.

func NewContainerResolver

func NewContainerResolver() *ContainerResolver

NewContainerResolver builds a resolver backed by whichever container/runtime CLI is first found on PATH, in order: docker, ctr, crictl. If none is found, the resolver still works but every lookup degrades to a stub ContainerInfo containing only the id (attribution without enrichment).

func (*ContainerResolver) ByCgroup

func (r *ContainerResolver) ByCgroup(cgroupLine string) (ContainerInfo, bool)

ByCgroup attributes a single /proc/<pid>/cgroup line to container metadata. The bool result reports whether the line was attributed to a container id at all (regardless of whether enrichment via inspect succeeded); ok is false only when the line contains no recognizable container id.

type DaemonConfig

type DaemonConfig struct {
	Options       Options
	Images        []ImageInventory
	Policy        ResponsePolicy
	Responder     Responder     // defaults to a RecordingResponder if nil
	ForensicsDir  string        // when set, seal a forensic bundle per detection
	WindowSize    int           // recent-event window kept for forensics (default 32)
	EmitIncidents bool          // when true, attach an Incident to each record
	Sink          Sink          // optional streaming consumer
	Exceptions    *ExceptionSet // operator-vetted suppressions; nil = suppress nothing
}

DaemonConfig configures a daemon run. The zero value is a safe detect-only sensor with the default rule pack and no forensics.

func (DaemonConfig) Run

Run executes the daemon loop over src until EOF (replay) or ctx cancellation (live). It returns whatever it gathered even on a mid-stream error, so a truncated capture still yields partial, ordered results.

type DaemonResult

type DaemonResult struct {
	Records       []DetectionRecord
	EventsScanned int
	EvidencePaths []string
	Suppressed    int // detections matched an Exception and were dropped before response/forensics/sink
}

DaemonResult is the summary of a run.

func (*DaemonResult) Detections

func (r *DaemonResult) Detections() []Detection

Detections extracts just the detections from the records, in order.

type Detection

type Detection struct {
	// RuleID is the namespaced rule identifier (DS-RAT-RT-NNN).
	RuleID   string          `json:"rule_id"`
	Severity engine.Severity `json:"severity"`
	Title    string          `json:"title"`
	// Message is the human, incident-time explanation of this specific event
	// ("nginx (pid 812) spawned /bin/sh with a tty").
	Message string `json:"message"`
	// Technique is the ATT&CK mapping.
	Technique Technique `json:"technique"`
	// Seq/Time echo the triggering event for ordering and correlation.
	Seq          uint64 `json:"seq"`
	TimeUnixNano int64  `json:"time_unix_nano,omitempty"`
	// Container and Process snapshot the actor at detection time.
	Container   ContainerInfo `json:"container"`
	Process     ProcessInfo   `json:"process"`
	Remediation string        `json:"remediation,omitempty"`
	References  []string      `json:"references,omitempty"`
	// Metadata carries rule-specific structured detail (matched path, remote
	// endpoint, drifted binary) for machine consumers and forensics.
	Metadata map[string]string `json:"metadata,omitempty"`
	// Trigger is the full triggering event, retained for the forensic bundle.
	// It is omitted from findings but kept in the daemon/forensics path.
	Trigger *Event `json:"trigger,omitempty"`
}

Detection is a single fired rule: what happened, how bad it is, where in the ATT&CK matrix it sits, and the event that triggered it. It is a runtime-domain value; the engine module projects it into engine.Finding for the unified report, but the daemon and forensic bundle keep the richer form.

func (Detection) ToFinding

func (d Detection) ToFinding(module string) engine.Finding

ToFinding projects a Detection into the engine's Finding model. The module uses this so runtime detections appear in the same report as static findings. The triggering event is dropped here (findings are summaries); the full event lives in the forensic bundle.

type DetectionRecord

type DetectionRecord struct {
	Detection    Detection `json:"detection"`
	Action       Action    `json:"action"`
	Incident     *Incident `json:"incident,omitempty"`
	EvidencePath string    `json:"evidence_path,omitempty"`
}

DetectionRecord is one emitted result: the detection plus what the daemon did with it. Sinks render this; the daemon also returns the full ordered slice.

type Detector

type Detector struct {
	// contains filtered or unexported fields
}

Detector is the deterministic heart of the sensor: it feeds each event from a source through the rule set, threading accumulated State, and collects the resulting detections. It reads no clock and no randomness — identical input yields identical output — which is what makes it fully testable from recorded fixtures and safe to golden-test.

func NewDetector

func NewDetector(opts Options, images []ImageInventory) *Detector

NewDetector builds a detector for the given options and image inventory. The inventory seeds drift detection; pass nil when drift is not in scope.

func (*Detector) Baseline

func (d *Detector) Baseline() *Baseline

Baseline returns the behavior profile learned during a run, or nil if the detector was not learning. This is the raw material the profile generator turns into a least-privilege seccomp profile.

func (*Detector) Process

func (d *Detector) Process(ev *Event) []Detection

Process runs every rule against a single event and returns its detections. It updates state first so rules see a consistent process table. Order of detections follows rule order; callers that merge across events should SortDetections for a fully canonical order.

func (*Detector) RuleSet

func (d *Detector) RuleSet() *RuleSet

RuleSet exposes the active rule set (for enumeration and reporting).

func (*Detector) Run

func (d *Detector) Run(ctx context.Context, src EventSource) ([]Detection, error)

Run drains an EventSource, processing every event until the source reports io.EOF (bounded/replay) or ctx is cancelled (live). Detections are returned in canonical order. Errors other than io.EOF are surfaced with any detections gathered so far, so a truncated capture still yields partial results rather than nothing.

type EnforcingResponder

type EnforcingResponder struct {
	Policy   ResponsePolicy
	Recorder *RecordingResponder
	// contains filtered or unexported fields
}

EnforcingResponder is a real, side-effecting Responder: it kills processes and quarantines containers when the policy is armed. The side effects are injected function fields, wired per platform by NewEnforcingResponder (enforce_linux.go / enforce_other.go) — so the gating logic in Do is shared, identical, and safe by default regardless of platform, while only the actual OS integration differs between builds.

func NewEnforcingResponder

func NewEnforcingResponder(p ResponsePolicy) *EnforcingResponder

NewEnforcingResponder builds the Linux platform responder: kill sends SIGKILL directly via syscall; quarantine pauses the container and then best-effort disconnects it from the bridge network, both via the `docker` CLI with an arg vector (never a shell string).

func (*EnforcingResponder) Do

func (r *EnforcingResponder) Do(a Action) error

Do always records the action (so alert/audit trails are unaffected by enforcement posture); it performs a destructive side effect ONLY when the policy is armed (enforce mode + acknowledged). Alert actions never act.

type Event

type Event struct {
	// Seq is a monotonic sequence number within a single stream. It defines the
	// canonical processing order and makes replay deterministic.
	Seq uint64 `json:"seq"`
	// TimeUnixNano is the observation time in nanoseconds since the Unix epoch.
	// It is data, not a clock read: rules may compare event times to each other
	// but must never consult the host clock.
	TimeUnixNano int64 `json:"time_unix_nano,omitempty"`

	Kind EventKind `json:"kind"`

	// Container attributes the event to a workload. Full attribution is what
	// makes a node-level sensor useful: a syscall is noise until you know which
	// pod, image, and namespace it came from.
	Container ContainerInfo `json:"container"`
	// Process is the acting process and its ancestry chain. Always populated.
	Process ProcessInfo `json:"process"`

	File    *FileEvent    `json:"file,omitempty"`
	Network *NetworkEvent `json:"network,omitempty"`
	Syscall *SyscallEvent `json:"syscall,omitempty"`

	// Labels carry orchestrator/cloud enrichment (pod, namespace, node, cloud
	// account). Detection does not require them, but they travel into findings
	// and forensic bundles so an incident is actionable without a second lookup.
	Labels map[string]string `json:"labels,omitempty"`
}

Event is one observation from the sensor. It is the atomic unit the detection engine consumes and the stable telemetry contract Phases 6 & 7 build on.

Determinism: Seq gives a total order within a stream so replay is stable regardless of how a source batches or timestamps. TimeUnixNano is carried for forensics and correlation but is INJECTED (by the source), never read from the wall clock inside a rule — analysis must depend only on the data in the event.

type EventKind

type EventKind string

EventKind names the class of a telemetry event. A single Event carries exactly one of the typed sub-records (Process is always present because every event is attributed to an acting process; File/Network/Syscall are set to match Kind).

const (
	// KindProcess is a process lifecycle event (exec/fork/exit). The Process
	// record describes the new/acting process and its ancestry.
	KindProcess EventKind = "process"
	// KindFile is a file access event (open/read/write/unlink/chmod). File is set.
	KindFile EventKind = "file"
	// KindNetwork is a socket event (connect/accept/dns). Network is set.
	KindNetwork EventKind = "network"
	// KindSyscall is a raw syscall of interest not covered by the above
	// (mount, setns, bpf, init_module, ptrace). Syscall is set. Kernel-level
	// abuse (LKM load, eBPF program load) is modeled here by syscall name,
	// exactly as an in-kernel probe would observe it.
	KindSyscall EventKind = "syscall"
)

type EventSource

type EventSource interface {
	// Next returns the next event in order. It returns io.EOF when a bounded
	// source is exhausted, and respects ctx for cancellation on live sources.
	Next(ctx context.Context) (Event, error)
	// Close releases resources (open files, kernel maps, ring buffers). It is
	// safe to call Close more than once.
	Close() error
}

EventSource is where the detector gets its telemetry. It abstracts over a live kernel probe (Linux/eBPF, built behind a tag) and an offline recorded stream (the portable, deterministic path used everywhere including CI). Phases 6 and 7 consume this interface to tap the same event feed without caring how events are produced.

A source is single-consumer: Next is called from one goroutine. Bounded sources (replay) return io.EOF once drained; unbounded sources (live) block until an event arrives or ctx is cancelled.

func NewEBPFSource

func NewEBPFSource(cfg LiveConfig, resolver *ContainerResolver) (EventSource, error)

NewEBPFSource loads the sensor object, attaches every probe, and returns a live EventSource. It needs a BTF-capable kernel (checked by the caller), CAP_BPF / root, and tracefs mounted for tracepoint attach.

func NewLiveSource

func NewLiveSource(cfg LiveConfig) (EventSource, error)

NewLiveSource loads the sensor's BPF programs, attaches them to the configured tracepoints, and starts draining the ring buffer. It requires CAP_BPF (or root) and a BTF-enabled kernel. On any failure it returns a descriptive error so the daemon can fall back to replay mode.

type Evidence

type Evidence struct {
	Algorithm string         `json:"algorithm"` // "sha256"
	Digest    string         `json:"digest"`    // hex sha256 of canonical bundle bytes
	Bundle    ForensicBundle `json:"bundle"`
}

Evidence is a sealed ForensicBundle: the bundle plus a chain-of-custody digest over its canonical bytes. Serialize this to durable, write-once storage.

func CaptureForensics

func CaptureForensics(d Detection, window []Event) *Evidence

CaptureForensics seals a detection and its surrounding event window into tamper-evident Evidence. window should be the recent events the daemon held when the detection fired; it is copied defensively and its argv redacted.

func (*Evidence) Verify

func (e *Evidence) Verify() bool

Verify recomputes the digest over the in-memory bundle and reports whether it matches the sealed value — the integrity check on an Evidence value.

func (*Evidence) WriteToDir

func (e *Evidence) WriteToDir(dir string) (string, error)

WriteToDir writes the evidence as a WORM-style artifact: the filename is the digest, so content and name are bound and a re-write of tampered content lands in a different file. It refuses to overwrite an existing bundle (write-once). Returns the path written.

type Exception

type Exception struct {
	RuleID     string `json:"rule_id"`
	ImageRef   string `json:"image_ref,omitempty"`
	Container  string `json:"container,omitempty"`   // matches Name or ID
	PathPrefix string `json:"path_prefix,omitempty"` // matches Metadata["path"]
	ArgSubstr  string `json:"arg_substr,omitempty"`  // substring of joined Process.Args
	Note       string `json:"note,omitempty"`
}

Exception suppresses a specific detection an operator has vetted as benign. Matching is by rule id plus optional narrowing scope (image, container, path-prefix, arg-substring). Prefix/substring only — no regex — so an exception is auditable and cannot silently over-match.

type ExceptionSet

type ExceptionSet struct {
	Rules []Exception `json:"exceptions"`
}

ExceptionSet is a loaded collection of operator-vetted exceptions.

func LoadExceptions

func LoadExceptions(path string) (*ExceptionSet, error)

LoadExceptions decodes an ExceptionSet from a JSON file, defending against oversized input and unknown fields so a malformed or hostile file fails loudly instead of silently under- or over-matching.

func (*ExceptionSet) Suppressed

func (e *ExceptionSet) Suppressed(d Detection) bool

Suppressed reports whether d matches any exception. An exception matches when its RuleID equals d.RuleID AND every non-empty narrowing field also matches. Nil-safe: a nil set suppresses nothing.

type FileEvent

type FileEvent struct {
	Path      string `json:"path"`
	Op        string `json:"op"`
	Flags     string `json:"flags,omitempty"` // e.g. "O_RDONLY", "O_WRONLY|O_CREAT"
	Mode      uint32 `json:"mode,omitempty"`  // octal file mode after the op
	TargetUID int    `json:"target_uid,omitempty"`
}

FileEvent describes a filesystem access. Op is a short verb (open, read, write, unlink, chmod, mount-target). Mode carries the resulting file mode when Op is chmod, so setuid-bit changes are visible.

type FileSink

type FileSink struct {
	// contains filtered or unexported fields
}

FileSink writes each detection record as a single JSON line to a local file, forming a durable, append-only JSONL audit trail that survives process restarts and can be replayed or shipped later.

func NewFileSink

func NewFileSink(path string) (*FileSink, error)

NewFileSink opens (creating if necessary) the file at path for append-only writes and returns a FileSink backed by it.

func (*FileSink) Close

func (s *FileSink) Close() error

Close closes the underlying file.

func (*FileSink) Emit

func (s *FileSink) Emit(rec DetectionRecord) error

Emit writes rec as a single JSON line, guarded by a mutex so concurrent callers never interleave partial lines.

type ForensicBundle

type ForensicBundle struct {
	RuleSet   string        `json:"ruleset"`
	Detection Detection     `json:"detection"`
	Container ContainerInfo `json:"container"`
	// ProcessTree is the acting process's ancestry chain, root→leaf.
	ProcessTree []string `json:"process_tree,omitempty"`
	// Window is the ordered slice of events preserved around the detection (the
	// daemon's recent-event ring buffer at alert time). Argv is already redacted.
	Window []Event `json:"window"`
	// CapturedUnixNano is the triggering event's time — evidence provenance, not
	// a clock read, so capture is reproducible.
	CapturedUnixNano int64 `json:"captured_unix_nano,omitempty"`
}

ForensicBundle is the preserved evidence for one detection.

type IOCFeed

type IOCFeed struct {
	Version string            `json:"version"`
	IPs     map[string]string `json:"ips"`
	Domains map[string]string `json:"domains"`
	Hashes  map[string]string `json:"hashes"`
}

IOCFeed is an offline threat-intel bundle: known-bad IPs, domains, and file hashes mapped to a short threat label. It is imported from a committed/airgap JSON file (no network fetch), keeping the sensor deterministic and dep-free.

func LoadIOCFeed

func LoadIOCFeed(path string) (*IOCFeed, error)

LoadIOCFeed decodes an IOCFeed from a JSON file on disk, defending against oversized input and unknown fields exactly like LoadScenario does for recorded telemetry. Nil maps in the decoded feed are tolerated by the matching methods.

func (*IOCFeed) MatchFileHash

func (f *IOCFeed) MatchFileHash(h string) (label string, hit bool)

MatchFileHash checks a file hash against the feed's known-bad hash table.

func (*IOCFeed) MatchNetwork

func (f *IOCFeed) MatchNetwork(n *NetworkEvent) (label string, hit bool)

MatchNetwork checks a network event's remote IP, then its (lowercased) domain, against the feed. Nil maps are treated as empty.

type ImageInventory

type ImageInventory struct {
	ImageID  string   `json:"image_id,omitempty"`
	ImageRef string   `json:"image_ref,omitempty"`
	Binaries []string `json:"binaries"`
}

ImageInventory lists the executable paths that shipped in an image. It is the bridge from the build-time phases (which know an image's contents) to runtime drift detection.

type Incident

type Incident struct {
	ID        string          `json:"id"`
	RuleID    string          `json:"rule_id"`
	Title     string          `json:"title"`
	Severity  engine.Severity `json:"severity"`
	Technique Technique       `json:"technique"`
	Summary   string          `json:"summary"`
	Container ContainerInfo   `json:"container"`
	// Playbook is the ordered set of suggested containment steps.
	Playbook []PlaybookStep `json:"playbook"`
	// Automatable reports whether every step is safe to automate under its
	// guardrails — an agent can act autonomously only when this is true.
	Automatable bool     `json:"automatable"`
	References  []string `json:"references,omitempty"`
}

Incident is an actionable, structured view of a detection.

func BuildIncident

func BuildIncident(d Detection) *Incident

BuildIncident enriches a detection into an incident with a playbook chosen by the detection class. Steps escalate from evidence-preserving (always safe) to containment (guarded).

type LiveConfig

type LiveConfig struct {
	// RingBufferBytes is the requested per-CPU ring-buffer size for the future
	// eBPF loader. Zero selects a sane default when the loader is implemented.
	RingBufferBytes int
	// Probes optionally restricts which probe groups to attach (process, file,
	// network, syscall). Empty means all.
	Probes []string
}

LiveConfig configures a live kernel source. It is intentionally small now; the eBPF loader will grow it (probe selection, ring-buffer sizing, CO-RE BTF path) when that dependency lands.

type MultiSink

type MultiSink struct {
	Sinks []Sink
}

MultiSink fans a single detection record out to every configured Sink. It never short-circuits on error: every sink gets a chance to run, and any failures are collected and returned together via errors.Join.

func (*MultiSink) Emit

func (m *MultiSink) Emit(rec DetectionRecord) error

Emit calls Emit on every non-nil sink in m.Sinks, continuing past errors and returning the joined set of failures (nil if all succeeded).

type NetworkEvent

type NetworkEvent struct {
	Op         string `json:"op"` // connect, accept, dns, listen
	Proto      string `json:"proto,omitempty"`
	LocalAddr  string `json:"local_addr,omitempty"`
	RemoteIP   string `json:"remote_ip,omitempty"`
	RemotePort int    `json:"remote_port,omitempty"`
	Domain     string `json:"domain,omitempty"` // DNS name (query or SNI)
	// Direction is "egress" or "ingress"; egress to unknown endpoints is the C2 signal.
	Direction string `json:"direction,omitempty"`
}

NetworkEvent describes a socket operation. For connect events the remote address is what matters; DNS events carry the queried Domain.

type Options

type Options struct {
	// EnableAnomaly turns on baseline deviation detection (DS-RAT-RT-050). Requires
	// a learned Baseline; off by default.
	EnableAnomaly bool
	// EnableAgentRuntime turns on the novel AI-agent-runtime rules (DS-RAT-RT-100).
	// Off by default — it is an intelligence layer on top of the core.
	EnableAgentRuntime bool
	// Baseline, when set, is the learned normal behavior used by the anomaly
	// rule and to suppress known-good activity.
	Baseline *Baseline
	// EgressAllow lists domains/CIDRs considered known-good egress; connections
	// outside it are candidate C2. Empty disables the allowlist check (the rule
	// then only fires on hard signals like IMDS/known-bad ports).
	EgressAllow []string
	// IntelFeed, when set, enables IOC matching (DS-RAT-RT-014). Nil = off, so
	// default detection stays deterministic and dependency-free.
	IntelFeed *IOCFeed
}

Options configures a Detector: which optional rule groups are enabled and how the response layer behaves. The zero value is a safe, detect-only sensor with only the deterministic signature rules active.

type PlaybookStep

type PlaybookStep struct {
	Order       int    `json:"order"`
	Action      string `json:"action"`
	Command     string `json:"command,omitempty"`
	Guardrail   string `json:"guardrail"`
	Automatable bool   `json:"automatable"`
}

PlaybookStep is one containment action. Guardrail states the precondition or safety check that must hold before the step runs — the difference between a helpful automation and a self-inflicted outage.

type ProcSource

type ProcSource struct {
	// contains filtered or unexported fields
}

ProcSource is a live EventSource that polls a process table snapshot function on an interval and turns the diffs into KindProcess (and associated network) events. The parsing/diff logic above is what makes this portable to test: snapshot is injected, so ProcSource itself never touches a real filesystem in tests. The real Linux constructor (NewProcSource in procsource_linux.go) wires snapshot to an actual /proc walk.

func NewProcSource

func NewProcSource(cfg LiveConfig, resolver *ContainerResolver) (*ProcSource, error)

NewProcSource builds a live EventSource that polls /proc for new/changed processes and their outbound TCP connections, attributing each to a container via resolver. cfg is accepted for symmetry with NewLiveSource and future tuning (e.g. deriving the poll interval from it); it does not yet change behavior.

func (*ProcSource) Close

func (s *ProcSource) Close() error

Close releases resources held by ProcSource. Polling holds no open descriptors between calls, so this is a no-op.

func (*ProcSource) Next

func (s *ProcSource) Next(ctx context.Context) (Event, error)

Next returns the next event. On each call it first drains any events queued from a previous poll; when the queue is empty it polls again: wait for the interval (or proceed immediately if interval is zero), take a snapshot, diff it against the previous one, and enqueue an event per changed PID (with a best-effort container attribution and any newly observed TCP connections for that PID appended).

Documented test-only behavior: to keep the polling loop from spinning forever when a caller injects a snapshot function that has reached a fixed point (two consecutive identical snapshots) after the first snapshot was already taken, Next returns io.EOF rather than looping indefinitely. A real /proc poll never reaches this path in practice since interval > 0 paces it, but it is what lets a deterministic, interval-0 unit test terminate instead of looping forever waiting for a process-table change that will never come.

type ProcessInfo

type ProcessInfo struct {
	PID  int    `json:"pid,omitempty"`
	PPID int    `json:"ppid,omitempty"`
	Comm string `json:"comm,omitempty"` // short process name (kernel comm)
	Exe  string `json:"exe,omitempty"`  // resolved executable path
	// Args is argv (excluding argv[0] duplication is not required; rules read it
	// tolerantly). Secret-shaped values are redacted before anything is logged.
	Args     []string `json:"args,omitempty"`
	UID      int      `json:"uid,omitempty"`
	GID      int      `json:"gid,omitempty"`
	CgroupID uint64   `json:"cgroup_id,omitempty"`
	// Ancestry is the ordered executable chain root→this (e.g.
	// ["/pause","/usr/sbin/nginx","/bin/sh"]). Sources that cannot resolve the
	// full chain may leave it empty; rules degrade gracefully.
	Ancestry []string `json:"ancestry,omitempty"`
	// Caps lists effective Linux capabilities held by the process (e.g.
	// "CAP_SYS_ADMIN"). Empty means unknown or none.
	Caps []string `json:"caps,omitempty"`
	// TTY reports whether the process is attached to a terminal — an
	// interactive shell in a container is far more suspicious than a scripted one.
	TTY bool `json:"tty,omitempty"`
	// StdioSocket is true when stdin/stdout is bound to a network socket, the
	// tell-tale of a reverse/bind shell.
	StdioSocket bool `json:"stdio_socket,omitempty"`
}

ProcessInfo describes the acting process and enough of its lineage to reason about behavior. Ancestry is the exe/comm chain from the container entrypoint down to this process — the single most useful signal for "a service just spawned a shell".

type RecordingResponder

type RecordingResponder struct {
	Actions []Action
}

RecordingResponder captures the actions it is asked to perform without side effects. It backs the daemon's alert path and makes response testable without a live kernel. A real enforcing responder is a parked master action (NOTES.md).

func (*RecordingResponder) Do

func (r *RecordingResponder) Do(a Action) error

Do records the action. It never fails, so response bookkeeping cannot itself break the detection loop.

type ReplaySource

type ReplaySource struct {
	// contains filtered or unexported fields
}

ReplaySource plays a fixed slice of events back in order. It is bounded: after the last event Next returns io.EOF. This is the deterministic source the detector and daemon run against in tests, CI, and offline forensics.

func NewReplaySource

func NewReplaySource(events []Event) *ReplaySource

NewReplaySource returns a source that yields the given events in slice order. The caller is expected to have ordered them (LoadScenario does).

func (*ReplaySource) Close

func (s *ReplaySource) Close() error

Close is a no-op; a replay source owns no resources.

func (*ReplaySource) Next

func (s *ReplaySource) Next(ctx context.Context) (Event, error)

Next yields the next recorded event, or io.EOF when drained. It honors ctx so a cancelled run stops promptly even mid-replay.

type Responder

type Responder interface {
	Do(a Action) error
}

Responder carries out a planned Action. Implementations decide how (a live responder signals the kernel/runtime; the recording one just remembers).

type ResponseMode

type ResponseMode string

ResponseMode selects the sensor's posture.

const (
	// ResponseDetect alerts only. The safe default.
	ResponseDetect ResponseMode = "detect"
	// ResponseEnforce may take containment actions for severe detections, but
	// only when the policy is also acknowledged.
	ResponseEnforce ResponseMode = "enforce"
)

type ResponsePolicy

type ResponsePolicy struct {
	Mode         ResponseMode
	Acknowledged bool
	KillSeverity engine.Severity
}

ResponsePolicy decides the action for a detection. The zero value is detect-only (safe). KillSeverity is the minimum severity that triggers a destructive action in acknowledged enforce mode.

func DefaultResponsePolicy

func DefaultResponsePolicy() ResponsePolicy

DefaultResponsePolicy returns the shipped posture: detect-only.

func (ResponsePolicy) Plan

func (p ResponsePolicy) Plan(d Detection) Action

Plan returns the response for a detection. In detect mode (or unacknowledged enforce) it is always an alert. In armed enforce mode, detections at or above KillSeverity are killed; container-escape and kernel-abuse additionally warrant quarantine because the blast radius is the host.

type Rule

type Rule interface {
	// ID is the stable rule identifier (DS-RAT-RT-NNN).
	ID() string
	// Info returns the rule's static metadata (severity, ATT&CK, references).
	Info() RuleInfo
	// Evaluate returns detections for this event, or nil.
	Evaluate(ev *Event, st *State) []Detection
}

Rule is one detection heuristic. It inspects a single event with access to accumulated State (process tree, image inventory, per-container history) and returns zero or more Detections.

The contract that makes the whole engine deterministic and testable: Evaluate must be a pure function of (ev, st). It may read and update st, but it must never read the wall clock, a random source, or any ambient I/O. Everything a rule needs is in the event or the state built from prior events.

type RuleInfo

type RuleInfo struct {
	Title       string
	Severity    engine.Severity
	Technique   Technique
	Remediation string
	References  []string
	// Description explains what the rule looks for and why it matters.
	Description string
	// Default reports whether the rule is on by default. Novel/behavioral rules
	// (agent-runtime, anomaly) ship off by default so correctness never depends
	// on them (SHARED_CONTRACT §4).
	Default bool
}

RuleInfo is a rule's static description. Keeping severity/technique/references out of Evaluate keeps detections consistent and lets `dsecrat-runtime rules` enumerate coverage without running anything.

type RuleSet

type RuleSet struct {
	Version string
	// contains filtered or unexported fields
}

RuleSet is an ordered, versioned collection of rules. Versioning matters operationally: an alert should record which rule pack produced it, so tuning changes are auditable.

func NewRuleSet

func NewRuleSet(opts Options) *RuleSet

NewRuleSet builds the built-in rule set for the given options.

func (*RuleSet) Rules

func (rs *RuleSet) Rules() []Rule

Rules returns the rules in stable order.

type Scenario

type Scenario struct {
	// Version guards the on-disk format so we can evolve it without silently
	// misreading old captures.
	Version int `json:"version"`
	// Name is a human label for the capture (used in reports/tests).
	Name string `json:"name,omitempty"`
	// Images is the per-image binary inventory. Drift detection asks whether an
	// executed path shipped in the image; without an entry for a container's
	// image, drift is simply not evaluated for that container (fail-open on data
	// we do not have, rather than crying wolf).
	Images []ImageInventory `json:"images,omitempty"`
	// Events is the ordered telemetry stream.
	Events []Event `json:"events"`
}

Scenario is a recorded telemetry stream plus the image inventory needed to evaluate it. It is the committed-fixture format the whole engine is tested against, and the shape a real sensor would checkpoint for offline replay / incident reconstruction.

func LoadScenario

func LoadScenario(r io.Reader) (*Scenario, error)

LoadScenario decodes a Scenario from JSON, defending against oversized input and normalizing event order by Seq so replay is deterministic regardless of how the file was written.

type SeccompMeta

type SeccompMeta struct {
	GeneratedBy string `json:"generated_by"`
	RuleSet     string `json:"ruleset"`
	Image       string `json:"image"`
	Observed    int    `json:"observed_syscalls"`
}

SeccompMeta is non-standard provenance metadata (underscore-prefixed so tools ignore it) describing how the profile was produced.

type SeccompProfile

type SeccompProfile struct {
	DefaultAction string        `json:"defaultAction"`
	Architectures []string      `json:"architectures"`
	Syscalls      []SeccompRule `json:"syscalls"`
	// Meta records provenance so a reviewer knows this profile was machine-
	// generated from observed behavior, and from which rule pack.
	Meta SeccompMeta `json:"_meta"`
}

SeccompProfile is the subset of the OCI seccomp schema we emit. It marshals to a profile `docker run --security-opt seccomp=<file>` or a Kubernetes seccompProfile localhost file accepts directly.

func GenerateSeccompProfile

func GenerateSeccompProfile(b *Baseline, workloadKey string) *SeccompProfile

GenerateSeccompProfile builds a least-privilege profile for one workload from a baseline: default-deny, allow the observed syscalls plus the safety floor. It returns nil if the workload is unknown in the baseline. Architectures cover the two we build for (x86-64 and arm64) plus their 32-bit compat entries.

type SeccompRule

type SeccompRule struct {
	Names  []string `json:"names"`
	Action string   `json:"action"`
}

SeccompRule allows (or otherwise acts on) a set of syscalls.

type ShellKillEnforcer

type ShellKillEnforcer interface {
	ArmShellKill() error
}

ShellKillEnforcer is implemented by an EventSource that can enforce in-kernel: arming it makes the source SIGKILL a shell execing inside a container before it runs. Only the eBPF source implements it; the daemon arms it only in acknowledged enforce mode.

type Sink

type Sink interface {
	Emit(rec DetectionRecord) error
}

Sink consumes emitted records as they occur (for streaming output / SIEM). A nil sink is allowed — the daemon still returns the collected records.

type State

type State struct {
	// contains filtered or unexported fields
}

State is the memory the detector carries across events in a stream. Rules read it to reason about context ("has this container run this binary before?", "what is this process's lineage?") and update it as events flow. It is owned by a single Detector and touched from one goroutine, so it needs no locking.

type SyscallEvent

type SyscallEvent struct {
	Name   string            `json:"name"`
	Retval int               `json:"retval,omitempty"`
	Args   map[string]string `json:"args,omitempty"`
}

SyscallEvent describes a raw syscall of interest. Name is the syscall name ("mount", "setns", "bpf", "init_module"); Args carries the salient decoded arguments (e.g. bpf command, mount source/target) as strings for portability.

type SyslogSink

type SyslogSink struct {
	// contains filtered or unexported fields
}

SyslogSink forwards detection records to the local syslog daemon (LOG_AUTHPRIV), making them consumable by a SIEM or other log-aggregation pipeline.

func NewSyslogSink

func NewSyslogSink(tag string) (*SyslogSink, error)

NewSyslogSink dials the local syslog daemon using LOG_AUTHPRIV|LOG_WARNING and the given tag.

func (*SyslogSink) Close

func (s *SyslogSink) Close() error

Close closes the underlying syslog connection.

func (*SyslogSink) Emit

func (s *SyslogSink) Emit(rec DetectionRecord) error

Emit JSON-encodes rec and writes it to syslog at warning level.

type Technique

type Technique struct {
	ID     string // e.g. "T1611"
	Name   string // e.g. "Escape to Host"
	Tactic string // e.g. "Privilege Escalation"
	URL    string // canonical ATT&CK reference
}

Technique is a MITRE ATT&CK technique the sensor maps detections to. Mapping every rule to ATT&CK is what turns a pile of alerts into a coverage story an analyst (or an agent) can reason about.

type WebhookSink

type WebhookSink struct {
	URL    string
	Client *http.Client
}

WebhookSink delivers detection records to a human (or human-facing system) by POSTing a JSON payload to a configured URL — e.g. a Slack/Teams webhook or an incident-management endpoint. It is the "alert-to-human" sink.

func (*WebhookSink) Emit

func (s *WebhookSink) Emit(rec DetectionRecord) error

Emit JSON-encodes rec and POSTs it to s.URL. A non-2xx response is treated as a delivery failure.

type WorkloadProfile

type WorkloadProfile struct {
	Image        string   `json:"image"`
	Syscalls     []string `json:"syscalls,omitempty"`
	Exes         []string `json:"exes,omitempty"`
	Endpoints    []string `json:"endpoints,omitempty"` // "ip:port" or domain
	Capabilities []string `json:"capabilities,omitempty"`
	FileReads    []string `json:"file_reads,omitempty"`
	FileWrites   []string `json:"file_writes,omitempty"`
	FileExecs    []string `json:"file_execs,omitempty"`
	Network      bool     `json:"network,omitempty"`
}

WorkloadProfile is the observed behavior of a single image/workload. Sets are stored as sorted slices for stable, diffable serialization.

It carries more than seccomp needs on purpose: the capability and file-access fields make it a complete least-privilege input, so the Phase 7 hardening generator (AppArmor/seccomp) can be fed by a trivial field-copy adapter into its harden.Observation type — see NOTES.md.

Jump to

Keyboard shortcuts

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