mailstrix

package
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Jun 30, 2026 License: MIT Imports: 37 Imported by: 0

Documentation

Overview

Package yarad is the out-of-process YARA scanner backend for rspamd. rspamd (as of 4.1.0) has no native YARA module, so this service plays the same role the gozer DCC/Razor/Pyzor backend does: rspamd's mailstrix.lua plugin POSTs a message (or a MIME part) over HTTP, yarad scans the bytes against a set of compiled YARA rules and returns the matching rule names as JSON. Scanning out of process keeps the rspamd event loop non-blocking and keeps libyara (a CGO dependency) out of the rspamd image.

Index

Constants

View Source
const HomeURL = "https://mailstrix.com"

HomeURL is the project's home page, logged at startup alongside RepoURL.

View Source
const License = "MIT"

License is yarad's SPDX license id, surfaced by `yarad info`.

View Source
const RepoURL = "https://github.com/eilandert/mailstrix"

RepoURL is the project's source, logged at startup when log-stdout is on.

Variables

This section is empty.

Functions

func EnsureCachedRules

func EnsureCachedRules(cfg *Config, logf func(string, ...any)) error

EnsureCachedRules implements the seed-on-startup / self-heal behaviour: when a writable CacheDir is configured, yarad serves its rules from CacheDir/compiled.yac, and that file is (re)seeded from the baked, read-only SeedRules whenever it is missing or unreadable. This makes a fresh deploy — or a wiped/cleared bindmount — always recover a known-good, image-tested ruleset with no network. A later step adds `--fetch-rules` to refresh the cache copy from a release.

It mutates cfg.RulesPath to point at the cache file so the existing scanner load path is unchanged. When CacheDir is empty the function is a no-op (the old behaviour: load RulesPath/RulesDir directly).

Seeding is best-effort but explicit: if the cache is empty and seeding fails (no seed, unwritable dir), it returns an error so the caller can fall back to the baked RulesPath rather than start with no rules.

func ResolveEffortLevel

func ResolveEffortLevel(headerVal int, headerSet bool, envDefault, effortMax int) int

ResolveEffortLevel applies the request-time resolution order:

header (if a valid 1..N int was sent) ?? envDefault

then clamps the result to [1, effortMax]. The clamp is the DoS guard: a caller (or an attacker who can set the X-MAILSTRIX-Effort header) can never drive effort above the operator's configured ceiling. A malformed/empty header falls back to the env default; a header below 1 or above effortMax is clamped, not rejected (fail-toward-configured, never error a scan over a header).

headerSet reports whether the header carried a usable integer (so the caller can distinguish "no header" from "header == envDefault" for metrics if wanted).

func StreamDedupKey

func StreamDedupKey(b []byte) [16]byte

streamDedupKey returns a 16-byte key for the per-stream dedup set inside Scanner.Scan. xxhash is non-cryptographic but collision odds across ≤256 streams of practical size are negligible (~2⁻⁶⁴ per pair), and it is orders of magnitude faster than SHA-256 on multi-MB buffers. Two independent 64-bit passes (second pass domain-separated with 0x01) give a 128-bit key so the map can use a [16]byte array — allocation-free and faster than a string key. StreamDedupKey is the exported form of streamDedupKey for callers outside the package (e.g. the CLI scan command) that need to precompute the raw-body key for ScanMeta.RawKey (PERF-22).

Types

type Cache

type Cache interface {
	// Get returns an immutable match slice owned by the cache. Callers must not
	// mutate the returned slice or its Match entries.
	Get(key string) ([]Match, bool)
	// Put stores matches by reference; callers must treat matches as immutable
	// after insertion.
	Put(key string, matches []Match)
	Flush()
	// Degraded returns a non-empty human-readable reason when the cache is
	// operating in a reduced capacity (e.g. the Redis circuit breaker is open).
	// An empty string means fully operational. Disabled caching (noopCache) is
	// not degraded — it is an intentional configuration.
	Degraded() string
}

Cache stores scan verdicts keyed by a Fingerprint-prefixed streamDedupKey: a non-cryptographic 128-bit xxhash of the body (scanner.go), NOT a crypto hash. This is a dedup key, not collision-resistant — random collision is negligible at 128 bits, but a deliberate collision against a pre-seeded clean entry could be served the clean verdict. Accepted threat model: the cache is process-local (or a trusted shared Redis), not attacker-writable. A YARA verdict is a pure function of the scanned bytes and the rule set, so unlike gozer's collaborative verdicts there is nothing to invalidate per-message — entries only expire by TTL. On a rules reload the whole cache is dropped (Flush) since old verdicts were computed against the previous rule set.

func NewCache

func NewCache(cfg *Config, logf func(string, ...any)) Cache

NewCache builds the verdict cache from cfg. TTL<=0 returns a noop cache. When RedisURL is set, a shared L2 is attached; a Redis that fails at runtime is treated as a miss (fail-open to scanning), never an error to the caller.

type Config

type Config struct {
	Host           string        // MAILSTRIX_HOST            (default 0.0.0.0)
	Port           int           // MAILSTRIX_PORT            (default 8079)
	BackendTimeout time.Duration // MAILSTRIX_BACKEND_TIMEOUT (default 1s)
	MaxConcurrent  int           // MAILSTRIX_MAX_CONCURRENT  (default "auto" = CPU count)
	MaxInflight    int           // MAILSTRIX_MAX_INFLIGHT    (default 2×MaxConcurrent); admission gate
	MaxBody        int64         // MAILSTRIX_MAX_BODY bytes  (default 8 MiB)
	Token          string        // MAILSTRIX_TOKEN[_FILE]    (required for /scan; comma-separated for rotation)
	TokenNext      string        // MAILSTRIX_TOKEN_NEXT[_FILE] (incoming rotation token; empty = no rotation)

	// RulesDir is the directory of *.yar / *.yara source files compiled at boot
	// and on SIGHUP. RulesPath, if set, is a single precompiled (.yac) ruleset
	// loaded instead of compiling sources (faster startup, used when the image
	// bakes a compiled bundle). RulesPath wins when both are set.
	RulesDir  string // MAILSTRIX_RULES_DIR  (default /rules)
	RulesPath string // MAILSTRIX_RULES      (optional precompiled bundle)

	// CacheDir is the writable directory yarad keeps its live, updatable rule
	// bundle in (and later the abuse.ch feed snapshots). SeedRules is the baked,
	// read-only compiled .yac shipped in the image. On startup, when SeedRules is
	// set and CacheDir/compiled.yac is missing or unreadable, the seed is copied
	// into the cache and loaded from there — so a fresh deploy (or a wiped
	// bindmount) always self-heals to a known-good tested ruleset with no network.
	// `--fetch-rules` (a later step) refreshes the cache copy. Both empty keeps the
	// old behaviour (load RulesPath/RulesDir directly).
	CacheDir  string // MAILSTRIX_CACHE_DIR  (e.g. /var/cache/mailstrix; empty = disabled)
	SeedRules string // MAILSTRIX_SEED_RULES (baked read-only .yac to seed the cache from)

	// RulesMaxAge flags the loaded ruleset as STALE once its on-disk mtime is
	// older than this. The image bakes rules and a daily rebuild refreshes them;
	// if that rebuild silently breaks (fetch failed, image not redeployed) the
	// running container keeps serving old rules with no error. When set (>0) and
	// exceeded, /ready reports "stale" (503) so an orchestrator/alert notices —
	// but /health stays OK and scanning continues (fail-open: old rules still
	// catch most malware; a hard-down scanner is worse). 0 disables the check.
	RulesMaxAge time.Duration // MAILSTRIX_RULES_MAX_AGE (seconds; default 0 = off)

	// ScanTimeout bounds a single libyara scan so a pathological rule/input
	// cannot stall a worker (YARA's own internal timeout, seconds).
	ScanTimeout time.Duration // MAILSTRIX_SCAN_TIMEOUT (default 8s)

	// BigFileThreshold gates an oversized-buffer cost defence. A full-ruleset scan
	// of a multi-MB buffer is inherently unbounded (size × ~12k rules) and can
	// time out even at a large ScanTimeout, so the scanner fail-opens and a padded
	// dropper is MISSED. When a scanned buffer is larger than this threshold, the
	// scanner runs the small, high-signal "big-file" ruleset (BigFileRules — our
	// in-repo local rules only) INSTEAD of the full bundle, so the scan completes
	// fast and the local heuristics still fire. Below it, behaviour is unchanged
	// (full ruleset). Default 6 MiB (below the 8 MiB MaxBody default so a file
	// padded toward the body cap hits the gate). 0 disables the gate entirely.
	BigFileThreshold int64 // MAILSTRIX_BIGFILE_THRESHOLD bytes (default 6 MiB; 0 = off)

	// BigFileRules is the targeted ruleset used by the oversized-buffer gate. It is
	// a path to either a precompiled .yac bundle (loaded directly) or a directory
	// of *.yar/*.yara source files (compiled at boot, like RulesDir). It should
	// hold ONLY the in-repo high-signal local rules so the gated scan is cheap.
	// Empty disables the gate even if BigFileThreshold>0 — Scan then falls back to
	// the full ruleset for oversized buffers (logged once), never crashing.
	BigFileRules string // MAILSTRIX_BIGFILE_RULES (default = baked local.yac seed)

	// Verdict cache. At high volume mail is heavily duplicated (bulk campaigns,
	// one body to N recipients, MTA retries), so caching SHA256(body) -> matches
	// turns most scans into a microsecond lookup. The in-process LRU is always
	// on; RedisURL adds a shared layer across replicas (empty => LRU only).
	CacheTTL    time.Duration // MAILSTRIX_CACHE_TTL    (default 3600s; 0 disables caching)
	CacheSize   int           // MAILSTRIX_CACHE_SIZE   (default 65536 in-memory entries)
	RedisURL    string        // MAILSTRIX_REDIS_URL    (empty -> in-process LRU only)
	RedisPrefix string        // MAILSTRIX_REDIS_PREFIX (default yara:scan:)

	Verbose     bool // MAILSTRIX_VERBOSE
	LogStdout   bool // MAILSTRIX_LOG_STDOUT — info/access to stdout; errors stay stderr
	MetricsAuth bool // MAILSTRIX_METRICS_AUTH — require the token for /metrics and /version
	Pprof       bool // MAILSTRIX_PPROF — enable /debug/pprof (off by default, ops-only)
	Canary      bool // MAILSTRIX_CANARY — shadow/observe-only: tag ALL matches mailstrix_canary=1

	// Password-protected archive decryption (opt-in, default OFF). When OFF the
	// service behaves exactly as before — an encrypted zip/7z/rar member emits the
	// ARCHIVE-ENCRYPTED marker and is never decrypted. When ON, the extractor tries
	// a bounded candidate-password list against encrypted members and, on a hit,
	// feeds the plaintext through the normal child-scan path. The brute loop is
	// hard-capped (attempt budget + per-attempt deadline + candidate-list size cap);
	// see internal/extract for the bounds. Candidates come from the built-in list,
	// the optional wordlist file, the attachment filename, and (if the rspamd plugin
	// sends them) the mail subject/body.
	ArchivePW bool // MAILSTRIX_ARCHIVE_PW — enable encrypted-archive decrypt (default false)
	// ArchivePWords is the boot-loaded wordlist from MAILSTRIX_ARCHIVE_PW_FILE,
	// merged with the built-in defaults. Empty if the env is unset or the file is
	// unreadable (fail-open + a logged warning). Capped at load time
	// (archivePWFileMaxLines lines, archivePWWordMaxLen bytes/line, deduped).
	ArchivePWords []string // from MAILSTRIX_ARCHIVE_PW_FILE (optional)

	// DenylistFile is an optional path to a file of rule names (one per line,
	// # comments, case-insensitive) merged with the env-based RuleDenylist.
	// Re-read on every SIGHUP so rules can be suppressed without a restart.
	// If the file doesn't exist or is unreadable, a warning is logged and
	// scanning continues with the env-only denylist (fail-open).
	DenylistFile string // MAILSTRIX_DENYLIST_FILE (default empty = disabled)

	// URLhaus malware-URL lookup. Disabled unless an abuse.ch Auth-Key is set.
	URLhausKey     string        // MAILSTRIX_URLHAUS_KEY[_FILE] — abuse.ch Auth-Key
	URLhausRefresh time.Duration // MAILSTRIX_URLHAUS_REFRESH (default 360m, floor 5m)
	URLhausMaxURLs int           // MAILSTRIX_URLHAUS_MAX_URLS  (per message, default 64)

	// MalwareBazaar attachment-hash lookup (abuse.ch). The SHA256 of each scanned
	// buffer is matched against a cached set of known-malware sample hashes.
	// Disabled unless an Auth-Key is set (the SAME abuse.ch key as URLhaus).
	MBazaarKey     string        // MAILSTRIX_MBAZAAR_KEY[_FILE] — abuse.ch Auth-Key
	MBazaarRefresh time.Duration // MAILSTRIX_MBAZAAR_REFRESH (default 24h, floor 5m)
	MBazaarFeed    string        // MAILSTRIX_MBAZAAR_FEED (URL override; default full dump)

	// ThreatFox IOC lookup (abuse.ch). URL and domain IOCs from the ThreatFox
	// recent-IOC CSV are matched against URLs extracted from scanned buffers.
	// Complements URLhaus (delivery URLs) with botnet C&C indicators.
	// Disabled unless an Auth-Key is set (same abuse.ch key as URLhaus).
	ThreatFoxKey     string        // MAILSTRIX_THREATFOX_KEY[_FILE] — abuse.ch Auth-Key
	ThreatFoxRefresh time.Duration // MAILSTRIX_THREATFOX_REFRESH (default 360m, floor 5m)
	ThreatFoxMaxURLs int           // MAILSTRIX_THREATFOX_MAX_URLS (per message, default 64)

	ICAPAddr string // MAILSTRIX_ICAP_ADDR (empty = disabled; e.g. ":1344")

	// RuleDenylist suppresses matches for these rule names (case-insensitive).
	// Public rulesets ship demo/noise rules that are pure false positives for
	// mail — e.g. Didier Stevens' `http` rule (rtf.yara) is `$="http" nocase`,
	// so it fires on virtually every message. Defaults to "http"; override with a
	// comma-separated list, or set the var empty to disable filtering entirely.
	RuleDenylist map[string]struct{} // MAILSTRIX_RULE_DENYLIST (comma-sep, default "http")

	// RuleAllowlist names rules whose matches are KEPT but tagged log-only
	// (case-insensitive): yarad still reports them (so they show in the mail
	// history) but adds meta `mailstrix_allow=1`, and the rspamd plugin routes those
	// to a 0-weight symbol. This force-demotes a known-FP rule without dropping
	// its visibility (denylist) and without patching the upstream source. Empty
	// by default. A name in BOTH lists is denied (drop wins over demote).
	RuleAllowlist map[string]struct{} // MAILSTRIX_RULE_ALLOWLIST (comma-sep, default empty)

	// Effort tiers (EFFORT-1). A single 1..EffortMax dial scales every bounded
	// extraction/scan cap so one binary serves both a latency-tight front
	// (rspamd, pre-queue) and a deeper backend (LDA/sieve), and can shed work
	// under load. Level 1 = raw + shallowest extraction, EffortMax = everything.
	//
	// Resolution order per request (see ResolveEffort / scanMetaFromRequest):
	// X-MAILSTRIX-Effort header ?? Effort (env default), clamped to [1, EffortMax].
	// EffortMax is the DoS ceiling — an attacker-set header can never raise effort
	// above it. The resolved level folds into the verdict-cache key (the same
	// bytes scanned at effort 2 vs 9 can yield different verdicts).
	//
	// EFFORT-4 made the dial LIVE: a level now resolves to a real cap profile
	// (EffortProfileFor) that scales the MSD decode depth/iterations, the PDF
	// structural-indicator pass, and whether the URLhaus/MalwareBazaar reputation
	// feeds run. A low level is cheaper and shallower; EffortMax runs everything.
	Effort     int  // MAILSTRIX_EFFORT     (default = EffortMax; the env/default level)
	EffortMax  int  // MAILSTRIX_EFFORT_MAX (default 10; hard ceiling for header override)
	EffortAuto bool // MAILSTRIX_EFFORT=auto (EFFORT-2): derive the per-request level from

	Version string // build version string, set by main (not from env); for /version
	// contains filtered or unexported fields
}

Config is yarad's runtime configuration, populated from the environment by LoadConfig. Field comments name the env var each value comes from. The env-helper style mirrors gozer so the two backends configure identically.

func LoadConfig

func LoadConfig() *Config

LoadConfig reads the environment into a Config, applying documented defaults, then sanitizes invalid numeric values.

func (*Config) Finalize

func (c *Config) Finalize()

Finalize re-applies the same clamps as the initial load, so any CLI flag overlay that sets a non-positive value (e.g. -scan-timeout=0) is caught and reset to its safe default before the scanner or server is constructed. Calling it more than once is safe (idempotent: clamping an already-valid value leaves it unchanged).

type EffortProfile

type EffortProfile struct {
	// Level is the resolved effort (1..EffortMax) this profile was built for. It
	// is what folds into the verdict-cache key, so two scans of the same bytes at
	// different effort can hold distinct verdicts.
	Level int

	// DecodeDepth caps the MSD multi-layer static-decode recursion (decode.go
	// maxDecodeDepth). PDFDeepen enables the PDF action/JS indicator pass
	// (pdf.go fromPDFIndicators). ReputationFeeds enables the URLhaus/MalwareBazaar
	// lookups. These are the first caps EFFORT-4 wires; more (XLM formula/sheet
	// caps, fold/carve clamps, maxStreams) follow.
	DecodeDepth      int
	DecodeIterations int
	PDFDeepen        bool
	ReputationFeeds  bool

	// ScanTimeout is the per-request libyara wall-clock budget scaled by effort
	// level. At level 1 it is 50% of the base; at the ceiling it equals the base.
	// A zero base (no limit) keeps ScanTimeout at 0 (no limit).
	ScanTimeout time.Duration

	// XLMFoldSheets caps the number of macrosheets scanned per document by the
	// XLM constant-fold pass. Scaled linearly from 4 at level 1 to 64 at the
	// ceiling (EFFORT-4-XLMCAPS).
	XLMFoldSheets int
	// XLMFoldFormulas caps the number of formulas processed per macrosheet by the
	// XLM constant-fold pass. Scaled linearly from 256 at level 1 to 4096 at the
	// ceiling (EFFORT-4-XLMCAPS).
	XLMFoldFormulas int
}

EffortProfile is the resolved set of caps for one scan's effort level. As of EFFORT-4 the fields are LIVE: ExtractOptions maps DecodeDepth/DecodeIterations/ PDFDeepen into the extract package's per-request caps, and the scanner gates the reputation-feed lookups on ReputationFeeds. The level still folds into the verdict-cache key so two scans of the same bytes at different effort can hold distinct verdicts.

func EffortProfileFor

func EffortProfileFor(level, effortMax int, baseScanTimeout time.Duration) EffortProfile

func (EffortProfile) ExtractOptions

func (p EffortProfile) ExtractOptions(deadline time.Time) *extract.Options

ExtractOptions builds the extract package's per-request Options from this resolved profile plus the scan deadline. It is the single mapping point between the yarad-side effort profile and the extract-side caps (EFFORT-4).

type ExtractMetrics

type ExtractMetrics struct {
	Docs             uint64 // buffers recognised as an OLE2/OOXML container
	Streams          uint64 // decompressed macro blobs scanned (sum across docs)
	MacroDocs        uint64 // documents that yielded >=1 macro stream
	Failed           uint64 // container parse attempts that errored
	Panicked         uint64 // parser panics recovered (subset of Failed)
	Encrypted        uint64 // ECMA-376 encrypted OOXML (not decrypted)
	MSI              uint64 // OLE2 buffers recognised as MSI installers (streams dumped)
	MSG              uint64 // OLE2 buffers recognised as Outlook .msg (attachments extracted)
	OneNote          uint64 // buffers recognised as OneNote .one (embedded files carved)
	Archive          uint64 // buffers recognised as an archive (zip/gz/7z/rar/tar; members unpacked)
	ArchiveDecrypted uint64 // archives with >=1 password-protected member decrypted via a candidate
	OLEPackage       uint64 // OLE2 docs with an embedded OLE Package object (Ole10Native carved)
	LNK              uint64 // Windows shell links (.lnk) with StringData surfaced
	PDF              uint64 // PDFs with FlateDecode object streams inflated
	RTF              uint64 // RTF docs with \objdata embedded objects carved
	SLK              uint64 // SYLK (.slk) spreadsheets with XLM/DDE formulas scanned
	EncScript        uint64 // buffers with >=1 decoded MS-Script-Encoder (VBE/JSE) block
	Decoded          uint64 // buffers with >=1 base64/hex/reversed blob from the static decode pass
	// StreamMatches counts rule hits attributable ONLY to an extracted stream
	// (macro/MSI/VBE), i.e. rules that did NOT already fire on the raw bytes —
	// the direct measure of what pre-extraction adds over a raw-only scan.
	StreamMatches uint64
	// Deduped counts extracted streams skipped before YARA scanning because their
	// SHA256 matched a previously scanned stream (or the raw input buffer itself).
	Deduped     uint64
	DocProps    uint64 // documents with doc-property strings extracted
	XLMFold     uint64 // documents with XLM formula constant-folding applied
	ExtMismatch uint64 // attachments whose real container type contradicts a benign-looking extension (renamed dropper)
}

ExtractMetrics is a snapshot of the document pre-extraction counters, surfaced on /metrics so the new code path is observable: how many attachments were OLE/OOXML, how many carried macros, how often the parser failed/panicked, and how many were encrypted (and thus not decryptable here).

type FetchResult

type FetchResult struct {
	Updated      bool // a new bundle was downloaded and swapped in
	LocalVersion int  // version before the run
	NewVersion   int  // version after (== LocalVersion when not updated)
	Reason       string
}

FetchResult reports what FetchRules did, for logging and the CLI exit code.

func FetchRules

func FetchRules(ctx context.Context, baseURL, cacheDir, ourLibyara string, hc *http.Client) (FetchResult, error)

FetchRules implements the manifest-driven update: fetch the remote manifest, decide from it, and (only when warranted) download + verify + atomically swap the compiled bundle in the cache, keeping one backup.

baseURL    the directory URL holding compiled.yac + its manifest
cacheDir   where the live bundle lives (compiled.yac [+ .bak] + manifest)
ourLibyara the libyara version yarad links (empty disables the skew check)

Order (each step keeps the current bundle on failure — fail to last-good):

  1. GET manifest. Network error => no change.
  2. remote.Version <= local.Version => up to date, nothing downloaded.
  3. remote.Libyara != ourLibyara => refuse (skew), keep current.
  4. GET compiled.yac, verify size + sha256 against the manifest. Mismatch => discard, keep current.
  5. Back up the live bundle (one copy), atomically rename the new one in, write the manifest. On a post-swap load failure the caller can restore .bak.

type Match

type Match struct {
	Rule string `json:"rule"`
	// Namespace is the libyara namespace the rule was compiled into. yarad
	// compiles each rule file into a namespace named after the file (see
	// compileDir / compile-rules.sh `ns:path`), so this is effectively the
	// source ruleset file (e.g. "sigbase-gen_url.yar") — surfaced so the rspamd
	// plugin can show WHICH ruleset fired, not just the (often generic) rule
	// name. Empty for synthetic matches (URLhaus) that aren't from a rule file.
	Namespace string            `json:"namespace,omitempty"`
	Tags      []string          `json:"tags,omitempty"`
	Meta      map[string]string `json:"meta,omitempty"`
}

Match is one matched YARA rule, reported back to the rspamd plugin. Tags and the "meta" map come straight from the rule definition so the plugin can score or branch on them without yarad knowing anything rule-specific.

type MatchCount

type MatchCount struct {
	Rule  string `json:"rule"`
	Count uint64 `json:"count"`
}

MatchCount is one entry in the top-matches list.

type ReloadMetrics

type ReloadMetrics struct {
	Attempts        uint64 // Reload() calls (includes the initial boot load)
	Successes       uint64
	Failures        uint64
	LastUnix        int64  // unix seconds of the last successful reload
	LastMillis      int64  // wall-clock duration of the last reload attempt
	Rules           int64  // rule count after the last successful reload
	ModUnix         int64  // mtime (unix seconds) of the loaded ruleset on disk; 0 if unknown
	PrevFingerprint string // fingerprint before the last reload ("" on first load)
}

ReloadMetrics is a snapshot of rule-reload activity, surfaced on /metrics so a SIGHUP that silently fails (e.g. a bad rule edit) is visible to alerting instead of only appearing in logs.

type RuleSource

type RuleSource struct {
	Name    string `json:"name"`
	Repo    string `json:"repo"`
	License string `json:"license"`
	Ref     string `json:"ref"`
	Set     string `json:"set,omitempty"` // only yaraforge (core/extended/full)
}

RuleSource records where one ruleset came from: its repo URL, license, and git ref, so `strixd info` and /version can show provenance at a glance.

func LoadSources

func LoadSources(dir string) []RuleSource

LoadSources reads the baked sources.json from dir (typically /usr/share/mailstrix). Returns nil when none exists or it cannot be parsed — callers must treat nil as "provenance unknown" rather than an error (the scanner still works fine).

type RulesManifest

type RulesManifest struct {
	Version   int          `json:"version"`           // monotonic; an update exists iff remote > local
	Generated string       `json:"generated"`         // RFC3339 UTC, display/audit
	Checksum  string       `json:"checksum"`          // "sha256:<hex>" of compiled.yac
	Libyara   string       `json:"libyara"`           // libyara version that compiled it (skew guard)
	Rules     int          `json:"rules"`             // rule count (display)
	Size      int64        `json:"size"`              // compiled.yac bytes (sanity)
	Sources   []RuleSource `json:"sources,omitempty"` // per-ruleset provenance
}

RulesManifest is the small JSON file `fetch-rules` reads first to decide whether to update. It is published next to compiled.yac on the rolling release and is also stored alongside the cached bundle as the local record.

func LoadManifest

func LoadManifest(cacheDir string) (RulesManifest, bool)

LoadManifest returns the rules manifest stored alongside the cached bundle in cacheDir, and whether one was found. Used by `strixd info` / `/version` to report which rule version is loaded. A zero-value manifest + false means none present.

type ScanEngine

type ScanEngine interface {
	Scan(buf []byte, meta ScanMeta) ([]Match, error)
	RuleCount() int64
	// BigFileScans reports how many oversized buffers were scanned against the
	// targeted big-file ruleset (MAILSTRIX_BIGFILE_THRESHOLD gate), for /metrics.
	BigFileScans() uint64
	// BigFileStreamScans reports how many oversized extracted streams were scanned
	// against the targeted big-file ruleset instead of the full set, for /metrics.
	BigFileStreamScans() uint64
	// RawChannelScans / StreamChannelScans / MarkerChannelScans report per-channel
	// libyara scan counts (raw body / real-content stream / marker channel), for
	// /metrics (PERF-17).
	RawChannelScans() uint64
	StreamChannelScans() uint64
	MarkerChannelScans() uint64
	// RawScanErrs reports raw-scan failures that fell through to extraction
	// instead of aborting the request, for /metrics.
	RawScanErrs() uint64
	// Fingerprint identifies the active rule set; it is mixed into the cache key
	// so a reload that changes the rules invalidates old verdicts (L1 and Redis).
	Fingerprint() string
	// ExtractMetrics reports the OLE/OOXML pre-extraction counters for /metrics.
	ExtractMetrics() ExtractMetrics
	// ReloadMetrics reports rule-reload activity for /metrics.
	ReloadMetrics() ReloadMetrics
	// URLhausMetrics reports the URLhaus checker state for /metrics.
	URLhausMetrics() urlhaus.Metrics
	// MBazaarMetrics reports the MalwareBazaar checker state for /metrics.
	MBazaarMetrics() mbazaar.Metrics
	// ThreatFoxMetrics reports the ThreatFox checker state for /metrics.
	ThreatFoxMetrics() threatfox.Metrics
	// TopMatches returns the top n most-triggered rule names since last reload.
	TopMatches(n int) []MatchCount
}

ScanEngine is what the server dispatches a request to. *Scanner is the production implementation; tests inject a fake to exercise the HTTP layer without libyara.

type ScanMeta

type ScanMeta struct {
	Filename  string // sanitized basename, e.g. "invoice.exe" ("" if none)
	Extension string // lowercased extension WITH the leading dot, e.g. ".exe" (Loki/THOR convention); "" if none
	FileType  string // coarse optional type context for rules that use file_type, e.g. "outlook" for .msg/.oft
	// Effort is the resolved effort-tier level (1..EffortMax) for this scan
	// (EFFORT-1). It rides on ScanMeta so it is automatically part of the
	// verdict-cache key (see cacheKey) — the same bytes scanned at different
	// effort can yield different verdicts and must not share a cached one. Zero
	// means "unset" (legacy callers / internal scans); cacheKey treats 0 and the
	// resolved default identically only when both produce the same key string, so
	// resolution always fills a concrete level before Scan.
	Effort int
	// RawKey is the precomputed streamDedupKey([16]byte) of the raw request body,
	// set by the handler on the miss path so Scanner.Scan can seed the per-stream
	// dedup set without re-hashing the (potentially multi-MB) buffer (PERF-22).
	// Zero value signals "not precomputed"; Scan falls back to computing it inline.
	// The handler also uses string(RawKey[:]) as the body component of the
	// verdict-cache key (same domain as streamDedupKey, replaces bodyCacheHash).
	RawKey [16]byte
	// PWCandidates carries per-request candidate passwords (from the mail
	// subject/body via the rspamd plugin header) for the opt-in encrypted-archive
	// decrypt feature. Already capped+sanitized by the server header parse. These
	// change the verdict (a decrypted member yields different markers), so they
	// MUST fold into cacheKey — see cacheKey. Nil/empty when the feature is off or
	// no candidates were supplied; in that case cacheKey is byte-identical to the
	// pre-feature key (no cache split for the default-OFF path).
	PWCandidates []string
}

ScanMeta carries the per-message context the rspamd plugin knows but the raw bytes don't — the attachment filename — mapped onto YARA external variables (`filename`/`extension`, plus a narrow `file_type` hint for .msg/.oft) so name/type-keyed rules fire. The zero value leaves externals at their compile-time defaults, so a whole-rfc822 scan or unnamed part behaves as before.

func NewScanMeta

func NewScanMeta(name string) ScanMeta

NewScanMeta normalizes a raw (attacker-controlled) attachment name into the external-variable values: basename only (any path stripped), control bytes removed, length-capped, and the extension lowercased WITH its leading dot to match the Loki/THOR `extension == ".exe"` convention. A blank/garbage name yields the zero ScanMeta so the externals keep their empty defaults.

type Scanner

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

Scanner compiles a set of YARA rules once and scans message bytes against them. The compiled *yara.Rules is immutable once built, so reloads build a fresh set and swap the pointer atomically — in-flight scans keep using the old set until they finish, new scans pick up the new one. No scan ever holds a lock for its (potentially slow) duration.

func NewScanner

func NewScanner(cfg *Config, logf func(string, ...any)) (*Scanner, error)

NewScanner builds a scanner and performs the initial compile/load. It returns an error only when no rules at all could be loaded — a service with zero rules is a misconfiguration the operator must see at startup, not a silent "everything is clean".

func (*Scanner) BigFileScans

func (s *Scanner) BigFileScans() uint64

BigFileScans reports how many oversized buffers were scanned against the big-file (targeted) ruleset instead of the full set (BIGFILE gate). Surfaced on /metrics so the gate's firing rate is observable.

func (*Scanner) BigFileStreamScans

func (s *Scanner) BigFileStreamScans() uint64

BigFileStreamScans reports how many oversized EXTRACTED streams (archive/decode/ PDF/RTF/TNEF/VBA children over the threshold) were scanned against the big-file ruleset instead of the full set. Without this, a small raw container that expands into multi-MiB children could still spend the full-rules path on every child and exhaust the shared budget even though the raw body was protected. Surfaced on /metrics so the extracted-stream gate's firing rate is observable.

func (*Scanner) Close

func (s *Scanner) Close()

Close releases the scanner's background resources: it stops the abuse.ch feed refresher goroutines (URLhaus + MalwareBazaar) so they don't outlive a graceful shutdown. Both Close calls are nil-safe (no-op when the feed is disabled) and idempotent. Call after the HTTP server has drained.

func (*Scanner) ExtractMetrics

func (s *Scanner) ExtractMetrics() ExtractMetrics

ExtractMetrics returns the current pre-extraction counters.

func (*Scanner) Fingerprint

func (s *Scanner) Fingerprint() string

Fingerprint returns a short hash identifying the active rule set, prefixed with the extractor version. It is part of the verdict cache key, so a reload that changes the rules changes the fingerprint and old cached verdicts (in-process L1 and shared Redis L2) are no longer hit — they orphan and TTL-expire instead of serving a stale "clean". The extract.Version prefix folds the pre-extraction logic into that same invalidation: a verdict is now a function of BOTH the rules and how macros were decompressed, so an extractor bump must invalidate the cache (especially the Redis L2 that survives an image rebuild) exactly like a rule change does.

The effective denylist is also folded (#251-class fix): pre-disabling denied rules changes WHICH rules can fire, so two scanners with different denylists must never share a verdict-cache entry. denylistFP is a hash of the SORTED deny set (deterministic across replicas) updated on every Reload/ReloadDenylist.

Finally, the scoring policy is folded too. Allowlist and canary do not change which rules fire, but they DO change response metadata that shipped consumers use for scoring/blocking decisions. The verdict cache stores full Match values including Meta, and Redis L2 survives restarts, so scanners with different allowlist/canary policy must not share cached response metadata.

func (*Scanner) MBazaarMetrics

func (s *Scanner) MBazaarMetrics() mbazaar.Metrics

MBazaarMetrics reports the MalwareBazaar checker's state for /metrics, or a disabled snapshot when no Auth-Key is configured.

func (*Scanner) MarkerChannelScans

func (s *Scanner) MarkerChannelScans() uint64

func (*Scanner) RawChannelScans

func (s *Scanner) RawChannelScans() uint64

RawChannelScans, StreamChannelScans, and MarkerChannelScans report the number of libyara scans run on each channel (PERF-17): the raw body, real-content extracted streams, and the out-of-band marker channel. Surfaced on /metrics so an operator can see where scan cost goes — e.g. a container family that fans out into many streams shows a high stream:raw ratio. Totals include the big-file subset (a big-file-routed scan still counts on its channel), so bigfile_*_scans_total is a breakdown WITHIN these, not a separate bucket.

func (*Scanner) RawScanErrs

func (s *Scanner) RawScanErrs() uint64

RawScanErrs reports how many raw scans failed (timeout/libyara error) and fell through to extraction instead of aborting the request. Surfaced on /metrics so the fail-open-recovery path is observable.

func (*Scanner) Reload

func (s *Scanner) Reload() error

Reload (re)compiles the rule set and atomically swaps it in. A failure leaves the previous set active — a broken edit to the rules dir must never disarm a running scanner. Safe to call from a SIGHUP handler concurrently with scans.

func (*Scanner) ReloadDenylist

func (s *Scanner) ReloadDenylist()

ReloadDenylist re-reads the denylist file (if configured) and merges its entries with the immutable env-based denylist. Safe to call from the SIGHUP handler. If the file doesn't exist or is unreadable, a warning is logged and the scanner continues with only the env-based entries (fail-open).

func (*Scanner) ReloadMetrics

func (s *Scanner) ReloadMetrics() ReloadMetrics

ReloadMetrics returns the current reload counters.

func (*Scanner) RuleCount

func (s *Scanner) RuleCount() int64

RuleCount reports how many rules are in the active set (for /health and logs).

func (*Scanner) Scan

func (s *Scanner) Scan(buf []byte, meta ScanMeta) ([]Match, error)

Scan runs the active rule set over buf and returns the matched rules. It is safe for concurrent use. A scan failure (timeout, libyara error) returns the error; the server treats that as "no match" but logs it, so a scanner problem never blocks mail (fail-open, matching the gozer contract).

meta carries the attachment filename (if the plugin sent one): it is mapped to the `filename`/`extension`/`file_type` external variables for BOTH the raw scan and every extracted macro-stream scan, so name/type-keyed rules fire consistently on a document and its decompressed macros.

Beyond the raw bytes, Scan also matches against any plaintext hidden inside an OLE2/OOXML container — the decompressed VBA macro source — which the keyword rules cannot see in the compressed original. The raw bytes are scanned first (file-format/structural rules need them); each extracted macro stream is then scanned and its matches merged in. Extraction is best-effort and fail-open: for a non-document, or on any extract/sub-scan failure, the raw verdict stands and nothing is lost.

func (*Scanner) StreamChannelScans

func (s *Scanner) StreamChannelScans() uint64

func (*Scanner) ThreatFoxMetrics

func (s *Scanner) ThreatFoxMetrics() threatfox.Metrics

ThreatFoxMetrics reports the ThreatFox checker's state for /metrics.

func (*Scanner) TopMatches

func (s *Scanner) TopMatches(n int) []MatchCount

TopMatches returns the top n most-triggered rule names since the last reload, sorted descending by hit count. Surfaced on /version for operator visibility into which rules fire most (weight tuning, FP triage, coverage confirmation).

func (*Scanner) URLhausMetrics

func (s *Scanner) URLhausMetrics() urlhaus.Metrics

URLhausMetrics reports the URLhaus checker's state for /metrics, or a disabled snapshot when no Auth-Key is configured.

type Server

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

Server is the HTTP front-end: auth, body limits, the bounded-concurrency gate, and fail-open dispatch to the scanner. It mirrors gozer's server so the two backends behave identically to operators and to the rspamd plugins.

func NewServer

func NewServer(cfg *Config, engine ScanEngine) *Server

NewServer builds the server around an engine (the compiled scanner) and a verdict cache built from cfg. The scanner is also used to flush the cache on a rules reload when it supports it (see CacheFlusher).

func (*Server) FlushCache

func (s *Server) FlushCache()

FlushCache drops the verdict cache. main wires this to the SIGHUP reload so a new rule set never serves verdicts computed against the old rules.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe() error

ListenAndServe binds and serves until Shutdown is called (then it returns http.ErrServerClosed). The *http.Server is published so Shutdown can drain it.

func (*Server) ListenAndServeICAP

func (s *Server) ListenAndServeICAP(ctx context.Context) error

ListenAndServeICAP binds the ICAP TCP listener and serves until ctx is cancelled or the listener is closed. Safe to call concurrently with ListenAndServe. Returns nil when shut down cleanly.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown marks the server draining (so /ready starts returning 503 and load balancers stop sending new work) and gracefully drains in-flight requests until ctx expires. Safe to call before ListenAndServe has stored the server.

func (*Server) ShutdownICAP

func (s *Server) ShutdownICAP(ctx context.Context)

ShutdownICAP closes the ICAP listener and waits for in-flight connections to drain until ctx expires.

Jump to

Keyboard shortcuts

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