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
- func EnsureCachedRules(cfg *Config, logf func(string, ...any)) error
- func ResolveEffortLevel(headerVal int, headerSet bool, envDefault, effortMax int) int
- func StreamDedupKey(b []byte) [16]byte
- type Cache
- type Config
- type EffortProfile
- type ExtractMetrics
- type FetchResult
- type Match
- type MatchCount
- type ReloadMetrics
- type RuleSource
- type RulesManifest
- type ScanEngine
- type ScanMeta
- type Scanner
- func (s *Scanner) BigFileScans() uint64
- func (s *Scanner) BigFileStreamScans() uint64
- func (s *Scanner) Close()
- func (s *Scanner) ExtractMetrics() ExtractMetrics
- func (s *Scanner) FeodoMetrics() feodo.Metrics
- func (s *Scanner) Fingerprint() string
- func (s *Scanner) MBazaarMetrics() mbazaar.Metrics
- func (s *Scanner) MarkerChannelScans() uint64
- func (s *Scanner) RawChannelScans() uint64
- func (s *Scanner) RawScanErrs() uint64
- func (s *Scanner) Reload() error
- func (s *Scanner) ReloadDenylist()
- func (s *Scanner) ReloadMetrics() ReloadMetrics
- func (s *Scanner) RuleCount() int64
- func (s *Scanner) Scan(buf []byte, meta ScanMeta) ([]Match, error)
- func (s *Scanner) StreamChannelScans() uint64
- func (s *Scanner) ThreatFoxMetrics() threatfox.Metrics
- func (s *Scanner) TopMatches(n int) []MatchCount
- func (s *Scanner) URLhausMetrics() urlhaus.Metrics
- type Server
- func (s *Server) FlushCache()
- func (s *Server) ListenAndServe() error
- func (s *Server) ListenAndServeICAP(ctx context.Context) error
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (s *Server) Shutdown(ctx context.Context) error
- func (s *Server) ShutdownICAP(ctx context.Context)
Constants ¶
const License = "MIT"
License is yarad's SPDX license id, surfaced by `yarad info`.
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 ¶
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 ¶
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 ¶
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(key string) ([]Match, bool)
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 SHA256(body). 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.
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
// 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)
// Feodo Tracker IP blocklist (abuse.ch). Public feed of botnet C&C server IPs.
// Matches URLs with raw IP hosts against the blocklist. No auth required;
// set MAILSTRIX_FEODO=1 to enable.
FeodoEnabled bool // MAILSTRIX_FEODO (0/1, default 0 = disabled)
FeodoRefresh time.Duration // MAILSTRIX_FEODO_REFRESH (default 360m, floor 5m)
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)
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):
- GET manifest. Network error => no change.
- remote.Version <= local.Version => up to date, nothing downloaded.
- remote.Libyara != ourLibyara => refuse (skew), keep current.
- GET compiled.yac, verify size + sha256 against the manifest. Mismatch => discard, keep current.
- 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 ¶
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
// FeodoMetrics reports the Feodo Tracker checker state for /metrics.
FeodoMetrics() feodo.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
}
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 ¶
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 ¶
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 ¶
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 ¶
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) FeodoMetrics ¶
FeodoMetrics reports the Feodo Tracker checker's state for /metrics.
func (*Scanner) Fingerprint ¶
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. Allowlist is intentionally NOT folded: it only tags matches (mailstrix_allow=1), never adds or removes them, so the match-set identity is unchanged by it.
func (*Scanner) MBazaarMetrics ¶
MBazaarMetrics reports the MalwareBazaar checker's state for /metrics, or a disabled snapshot when no Auth-Key is configured.
func (*Scanner) MarkerChannelScans ¶
func (*Scanner) RawChannelScans ¶
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 ¶
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 ¶
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 ¶
RuleCount reports how many rules are in the active set (for /health and logs).
func (*Scanner) Scan ¶
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 (*Scanner) ThreatFoxMetrics ¶
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 ¶
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 ¶
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 ¶
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) Shutdown ¶
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 ¶
ShutdownICAP closes the ICAP listener and waits for in-flight connections to drain until ctx expires.