Detections¶
A detection is a Signalith query that runs on a schedule. Every row the query returns becomes a finding, unless the throttle says the same thing was already raised recently.
Where detections live¶
- Built-in detections ship inside the binary (
internal/detect/builtin). - Content pack detections ship inside the binary too, off until you turn them on (see Content packs).
- Custom and edited detections are YAML files in
detections.dir(default: adetectionsfolder next to the config file;/var/lib/signalith/detectionsfor the systemd unit and Docker image). One file per detection, named<id>.yaml. - A file whose
idmatches a built-in or pack detection overrides it. Delete the file, or click Revert to built-in, to go back. - Turning a built-in or pack detection on or off, or giving it
exceptions, without other changes only writes a small file to
detections/state/<id>.yaml, so the rule itself keeps coming from the binary and gets fixes on upgrade.
You can edit detections in the UI or drop YAML files into the folder and restart.
signalith validate checks every file and reports problems with the field to fix;
a broken file is skipped at startup and logged, so it never stops the server.
Format¶
id: auth-brute-force # 3-64 lowercase letters, digits, - or _
title: Brute force against $dest$ from $src$ # $field$ is filled from the result row
name: Brute force # optional: the name without $field$ tokens, for counts across hosts
description: One source failed to log on to a system many times.
query: |
datamodel=Authentication action=failure
| stats count, dc(user) as users by src, dest
| where count >= 10
schedule: 5m # how often it runs: s, m, h, d or w
lookback: 15m # how far back each run searches
severity: medium # informational, low, medium, high, critical
mitre: [T1110] # ATT&CK technique or sub-technique IDs
throttle:
fields: [src, dest] # one finding per distinct src and dest...
window: 1h # ...per hour
risk: # optional; see "Risk" below
score: 30
objects:
- {field: src, type: system}
enabled: true
tags: [authentication]
guidance: Check whether the source is internal and whether any attempt succeeded.
Rules of thumb:
- Do not put
earliest=orlatest=in the query;lookbacksets the window. - Make
lookbackat least as long asschedule, or events between runs are missed. A little overlap is fine; throttling keeps overlapping runs from raising duplicates. - Without
throttle.fields, the whole result row is the throttle key. - Views that count a detection across many findings, such as Top detections firing
on the posture dashboard, show
name. Without one, they showtitlewith each$field$and the word that leads to it left out ("Brute force against $dest$ from $src$" reads "Brute force"). Setnamewhen that reads badly, as for a title that starts with a$field$. - Use
stats ... byto raise one finding per entity instead of one per event. - Up to four detections run at once. A run is stopped when it takes longer than its
schedule (at least 1 minute, at most 5); the Detections page then shows Timed out,
and the next run starts on schedule. Narrow the query or shorten
lookbackso it finishes in time. - When every worker is busy, a due detection waits. Its next run then searches on from
where its last successful run stopped, not just its
lookback, so the events in between are still searched; failed and timed-out runs are made up the same way. It catches up at most ten lookbacks back (at least 1 hour, at most 24 hours); anything older is counted as not searched. The Detections page shows late runs, timeouts and time not searched under Behind schedule, and the Health page warns while a detection waits longer than its schedule. Where a detection stopped is kept in memory, so after a restart, or once it is turned back on, it searches itslookback.
Risk¶
A risk section makes the detection add risk to the users and systems in each
result row:
| Key | Meaning |
|---|---|
score |
Base risk, 1 to 1000. Multiplied by the object's priority from the asset and identity lists (critical ×2, high ×1.5, medium ×1, low ×0.5; a watchlisted user at least ×1.5) |
objects |
Result fields that hold a risk object, each with type user, system or other. Empty or missing values are skipped |
only |
true records risk without raising a finding. Throttling still applies, per risk object row |
weight_field |
A result column with a number from 0 to 10 that multiplies score, such as threat_weight from threatmatch |
Each object gets one risk event in the Risk data model with risk_object,
risk_object_type, risk_score, risk_base_score, risk_priority,
risk_message (the rendered title), detection_id, mitre and mitre_tactic,
plus the row's entity fields. Search them with datamodel=Risk.
Ten built-in detections are risk-only. Two risk incident detections (tagged
risk-incident) read the Risk data model and raise findings:
risk-threshold-exceeded: total risk of 100 or more for one object in 24 hours.risk-multiple-tactics: three or more ATT&CK tactics from three or more detections on one object in 7 days.
The threat intel match detection (threat-activity, tagged threat-intel)
runs * | threatmatch every 5 minutes over the last 10, raises one finding per
indicator and target system per day, and adds 60 risk times the indicator's weight
to threat_target and user. See threat-intel.md.
Write your own the same way, for example
datamodel=Risk risk_object_type=user | stats sum(risk_score) as risk_score by risk_object | where risk_score >= 200.
Exceptions¶
An exception tunes a detection without editing its query, like a suppression in Splunk Enterprise Security: results that match it raise no finding and add no risk, and every other result still alerts. Use one for a vulnerability scanner that trips a brute force rule, an admin script that looks like an attack, or a maintenance window.
- From a finding: admins open the finding, choose More actions (⋯) > Don't alert on this again, tick the values to match (the finding's host, user and so on), pick how long (24 hours, 7 days, 30 days or until removed), and add a comment. The finding can be closed in the same step, as a false positive by default, and its activity records what was suppressed.
- On the detection: the Exceptions section of a detection lists each
exception with who added it, when and why, and how long it lasts. Admins remove
one with its bin icon, or add one by hand with Add: one
field=valueper line, where*and?are wildcards and case is ignored. Leave the values empty, with a start time and how long, to silence every result for a maintenance window.
Each run counts the results exceptions matched; Last run on the Detections page shows them. An exception that has ended stays listed, greyed out, until it is removed. A detection can have up to 500 exceptions, each matching up to 8 fields.
In YAML (a custom detection keeps them in its own file; a built-in or pack detection
in detections/state/<id>.yaml):
exceptions:
- id: ex-3f9a1c2b7d10
match: {src: 203.0.113.9} # every field must match; * and ? are wildcards
until: 2026-11-01T00:00:00Z # optional: without it, until removed
comment: Quarterly vulnerability scan
by: admin
added: 2026-10-13T21:00:00Z
- id: ex-maintenance
from: 2026-10-18T02:00:00Z # no match: every result, so until is required
until: 2026-10-18T06:00:00Z
comment: Patch window
Exceptions are checked against the fields of each result row, so match on fields the
query returns: after stats ... by src, dest, only src, dest and the
aggregates exist.
Antivirus detections¶
Eight built-in detections read the Malware data model (see antivirus.md for what each means and what to do). All are on by default and raise findings:
| ID | Severity | Fires when |
|---|---|---|
malware-not-removed |
high | A threat was allowed, failed to clean or was restored from quarantine |
malware-left-clamav |
high | ClamAV found malware and only reported it, so the file is still there |
malware-detected |
low | A named threat was found (ransomware has its own finding) |
malware-outbreak |
high | The same threat was found on three or more hosts in a day (not test files, adware or unwanted apps) |
malware-ransomware |
critical | A threat in the ransomware category was found |
av-protection-disabled |
high | Protection or tamper protection was turned off |
av-tamper-blocked |
medium | Tamper protection blocked a change to the antivirus |
av-exclusion-added |
medium | An antivirus exclusion was added |
av-definitions-failing |
low | Definitions failed to update twice in a day, or are a week old |
av-rule-block |
medium | An attack surface reduction or controlled folder access rule blocked something unusual |
What a finding contains¶
The result row, the entities found in it (user, src_user, src, src_host,
dest, dest_host, host, process_name, object, member), the detection's
severity and ATT&CK techniques, a drilldown search, the asset and identity context
of each entity, and an urgency that combines severity with that context. For stats and top
detections the drilldown is the detection's filter plus the row's group-by values,
so View contributing events shows exactly the events behind that row.
Findings move through new, in_progress, pending, resolved and closed, with
an owner, a disposition (true_positive, benign_true_positive, false_positive,
undetermined), comments, and a history of every change. They are stored in the
OpenSearch index <index_prefix>_findings, which event searches never include.
Content packs¶
Content packs are rule sets from other open source projects, shipped unchanged inside Signalith and converted when it starts. Every pack detection is off until an admin turns it on.
SigmaHQ community rules (internal/detect/packs/sigmahq) holds 1,695 Windows
rules from SigmaHQ, pinned to one commit, for
these Sysmon, Security and PowerShell log sources:
| Sigma log source | Windows events | Data model |
|---|---|---|
process_creation |
Sysmon 1, Security 4688 | Endpoint |
network_connection |
Sysmon 3 | Network_Traffic |
process_access, create_remote_thread |
Sysmon 10, 8 | Endpoint |
image_load, driver_load |
Sysmon 7, 6 | Image_Load |
file_event, file_delete |
Sysmon 11; 23 and 26 | Filesystem |
registry_add, registry_delete, registry_set, registry_event |
Sysmon 12; 12; 13; 12, 13 and 14 | Registry |
dns_query |
Sysmon 22 | Network_Resolution |
ps_script |
PowerShell 4104 | Script |
Each pack rule only searches its own log source's event IDs, since several kinds of event share a data model (a process creation and a process access are both Endpoint). Sysmon only records the events its configuration asks for, so a rule fires only when your Sysmon configuration logs that kind of event.
PowerShell script blocks¶
The ps_script rules read event 4104, which Windows PowerShell writes for each script
it runs once script block logging is on (it is off by default). Turn it on with
Group Policy (Administrative Templates > Windows Components > Windows PowerShell >
Turn on PowerShell Script Block Logging) or, in an elevated PowerShell:
$k = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $k -Force | Out-Null
Set-ItemProperty -Path $k -Name EnableScriptBlockLogging -Value 1
Then collect the Microsoft-Windows-PowerShell/Operational channel: Include
PowerShell on the Add data page adds it to the Windows agent and the OpenTelemetry
Collector. PowerShell 7 logs to its own PowerShellCore/Operational channel, which
Signalith does not read yet.
Script blocks are long (up to about 20,000 characters, and a longer script arrives in
parts with script_part and script_parts), so script_block_text is a wildcard
field, which finds text anywhere in a value of any length. That field type needs
OpenSearch 2.15 or later. On older versions Signalith maps it as a keyword instead,
and then the text of a script block longer than 8,191 characters cannot be searched.
The type applies to indices created after an upgrade, so from the next day's index
on. Rules that would list more script snippets than OpenSearch allows in one search
(1,024 clauses by default) are left out of the pack.
A rule is only shipped when:
- its status is
stableortest(notexperimentalordeprecated), and its level is notinformational; - it converts exactly: every field maps to a Signalith field and every modifier is supported (strict mode, below);
- it fires on its test events and stays quiet on its near misses. Test events are made from the rule itself (every way the rule can match, plus events with each field removed or changed and events hitting each exclusion), and SigmaHQ's own recorded events are added where they exist. Each event goes through the real Windows parser, and two independent readings must agree: a Sigma evaluator on the raw event, and the converted query on the stored event.
internal/detect/packs/sigmahq/REPORT.md lists every rule left out and why.
CI runs the same checks on every change (TestPackRulesFireOnTheirTests) and
repeats them against a real OpenSearch (TestPackRulesAgainstOpenSearch), so a
change to the parser, the field mappings or the query compiler that breaks a
shipped rule fails the build.
Pack detections get these defaults: the title names the host, at most one finding
per host, user and command line a day (per host, program, destination and port for
network rules), and risk on the user and the host by Sigma level (low 10, medium 25,
high 50, critical 80). Low level rules only add risk and raise no finding, so they
feed risk-based alerting without flooding the queue. Each is tagged
sigma, the pack name, the product and the log source.
On the Detections page, the pack card shows how many rules are on. Turn on N high and critical rules is the suggested start; Browse rules lists the pack so you can filter, read a rule's source and turn rules on or off one at a time or all shown at once (with Undo). Pack rules cannot be deleted, only turned off; editing one saves an override that keeps the credit.
Licence: the SigmaHQ rules are under the Detection Rule License 1.1
(full text in the pack folder). As it requires, every finding from a pack rule
carries a credit line naming the rule's author, the pack and the licence, shown on
the finding and stored as its credit field.
To refresh the pack from a newer SigmaHQ commit:
git clone https://github.com/SigmaHQ/sigma /tmp/sigma
go run ./internal/detect/packgen -sigma /tmp/sigma
go test ./internal/detect/
The generator keeps each rule's Signalith ID across runs, rewrites rules/,
tests/, pack.yaml and REPORT.md, and only lists rules whose checks pass.
Splunk Security Content¶
Splunk Security Content (internal/detect/packs/splunk) holds 669 Windows
detections from Splunk's Threat Research Team
(security_content), pinned to one
commit. They are the production TTP and anomaly rules that search the CIM
Endpoint.Processes, Endpoint.Filesystem and Endpoint.Registry nodes with
tstats, plus the PowerShell script block rules. Signalith uses the same CIM field
names, so each rule's where clause becomes a Signalith filter field for field:
| Splunk node | Windows events | Data model |
|---|---|---|
Endpoint.Processes |
Sysmon 1, Security 4688 | Endpoint |
Endpoint.Filesystem |
Sysmon 11, 23 and 26 | Filesystem |
Endpoint.Registry |
Sysmon 12, 13 and 14 | Registry |
`powershell` EventCode=4104 (raw search) |
PowerShell 4104 | Script |
The rule files and the macros they use ship unchanged; Signalith converts them when
it starts, reading the search the way Splunk does: * is the only wildcard (? is
literal, so rules with one are left out), case is ignored, and != only matches
when the field is present. TTP rules raise findings and add risk; anomaly rules only
add risk, as in Splunk ES risk-based alerting. Severity follows the rule's risk score
(TTP: 80 and up critical, 64 high, 30 medium; anomalies low). Each is tagged
splunk, the pack name, ttp or anomaly, and the node, and keeps the rule's
known false positives as triage guidance.
PowerShell rules search the script block events themselves rather than a data
model: their ScriptBlockText terms become script_block_text filters, and their
fillnull | stats stages only count the events. They need script block logging
turned on (Include PowerShell on Add data).
Registry values that Sysmon writes as DWORD (0x00000001) compare as 0x00000001,
the way Splunk's Sysmon add-on reads them.
A rule is shipped only when its whole search converts exactly (a tstats search
whose later stages only rename fields, format times, keep rows that have a field
(where isnotnull(...)) or apply the rule's empty _filter macro) and, on test events made from its where clause and passed through
the real Windows parser, the converted detection agrees with Splunk's reading of the
clause on every event. Searches with eval, rex, regex, lookup, and raw searches other than
PowerShell's, are left out for now; internal/detect/packs/splunk/REPORT.md lists
every rule left out and why. The same CI checks as the SigmaHQ pack run on it.
On the pack card, Turn on N TTP rules is the suggested start.
Licence: the rules are © Splunk Inc. under the
Apache License 2.0 (the licence text
is in the pack folder and the attribution in NOTICE). Findings credit the rule's
authors and link to the rule at the pinned commit. Signalith is not affiliated with
or endorsed by Splunk.
To refresh the pack from a newer commit:
git clone https://github.com/splunk/security_content /tmp/security_content
go run ./internal/detect/splunkgen -splunk /tmp/security_content
go test ./internal/detect/ ./internal/splcheck/
Content updates¶
Pack rules can be updated without a new Signalith release. Every week the Signalith project rebuilds both packs from the newest SigmaHQ and Splunk rules, runs every check above on them (in memory and against a real OpenSearch), and publishes the ones that pass as one signed bundle. A server only installs a bundle that:
- is signed by a key built into Signalith (Ed25519; the keys are in
internal/content/trusted.keys), so a copy from anywhere else is as good as the original, and a changed byte is refused; - is newer than the rules it uses, so an old bundle cannot roll rules back;
- this version of Signalith can read. A rule for something it does not know yet, such as a new log source, is left out and counted on the Detections page.
Under the pack cards on the Detections page, the line says how new the rules are. Admins can:
- Check for updates: download and install the newest bundle.
- Upload update…: for a server without internet access. Download
signalith-content.tar.gzandsignalith-content.tar.gz.sigfrom the content release on another computer, then choose both files together. - Use built-in rules: go back to the rules that came with this version of Signalith.
Rules you turned on stay on, exceptions stay, and new rules start off. A rule that an update drops stops running; your choices for it come back if it returns.
The Content pack updates setting (detections.content_updates) can be Daily:
the server then checks once a day and installs what it finds. Content update address
(detections.content_url) points servers at a copy on your own web server; the bundle's
signature is read from the same address plus .sig. Each server keeps its update in
content/ in its detections folder. Upgrading Signalith to a version with newer rules
built in uses those instead of an older update.
From the command line (as the service, with -c for the config file):
signalith content status
signalith content install signalith-content.tar.gz # reads signalith-content.tar.gz.sig too
signalith content remove
Restart Signalith after install or remove so a running server uses the change, or
upload on the Detections page instead.
Sigma rules¶
Import Sigma rule on the Detections page (or POST /api/detections/sigma)
converts a rule and shows the result for review. Supported:
- Log sources
process_creation(Endpoint),network_connectionandfirewall(Network_Traffic),authenticationand Linuxauth(Authentication), andantivirus(Malware, whereSignaturemaps tosignatureandFilenametofile_path), plus the Sysmon and PowerShell log sources in the table above. Other log sources search all data and say so.product: windowslimits the search to Windows event logs (and, for the log sources in the table, to their event IDs), andlinuxormacosleave them out. - For
ps_script,ScriptBlockTextmaps toscript_block_text,ScriptBlockIdtoscript_block_idandPathtoscript_path. - For the Sysmon log sources other than process creation, fields map by log source:
TargetFilenametofile_path;TargetObjecttoregistry_pathandDetailstoregistry_value_data;QueryNametoqueryandQueryResultstoanswer;ImageLoadedtoloaded_file_pathwithSigned,SignatureandSignatureStatus;SourceImage,TargetImage,GrantedAccess,CallTrace,StartAddress,StartModuleandStartFunctionfor process access and remote threads. A field not listed for its log source (such asDescriptioninimage_load, which describes the DLL there, not the process) has no mapping. - Sysmon and Windows Security field names, mapped to Signalith fields (
Imagetoprocess_path, orprocess_nameforendswitha file name;CommandLinetoprocess;OriginalFileNametooriginal_file_name;ParentUser,IntegrityLevel,CurrentDirectory,Description,Product,Company,FileVersion,LogonId,ProcessGuid,SourceHostname,Initiatedand more;TargetUserNametouser;IpAddresstosrc; and so on). Unmapped fields are searched as-is and listed as warnings. - Modifiers
contains,startswith,endswith,all,windash,exists,base64,base64offset,utf16le,utf16be,utf16,wideandcidr(IPv4); lists,nulland empty values, keyword lists; conditions withand,or,not, parentheses,1 of,all of,themandselection_*patterns.
Not supported, and reported as an error: re, IPv6 cidr and other modifiers,
aggregation conditions (| count() > 10), and escaped wildcards.
Strict mode, used for content packs, also refuses anything that would only be
approximated: unmapped fields, keyword (free text) searches, rules with no data
model, and cased.
Imported rules start disabled with a 5 minute schedule and 10 minute lookback.
API¶
| Method and path | Purpose |
|---|---|
GET /api/detections |
All detections with last-run state |
POST /api/detections |
Create (400 lists every problem) |
GET, PUT, DELETE /api/detections/{id} |
Read, save, delete or revert |
GET /api/detections/{id}/yaml |
Export as YAML |
POST /api/detections/test |
Run an unsaved detection; returns rows and titles, raises nothing |
POST /api/detections/{id}/run |
Run now and raise findings |
POST /api/detections/sigma |
Convert a Sigma rule (YAML body, or JSON {"rule": "..."}) |
GET /api/detections/packs |
Content packs with their rule IDs, how many are on, and counts by severity |
GET /api/detections/content |
Content update status: the version in use, whether it is an update, the last check and the content_updates mode |
POST /api/detections/content/check |
Download and install a newer content update (admin; 502 with the reason when it fails) |
POST /api/detections/content/upload |
Install an update from a form with the files bundle and signature (admin; 409 when it is not newer) |
DELETE /api/detections/content |
Remove the update and use the rules built into Signalith (admin) |
POST /api/detections/{id}/exceptions |
Add an exception: {"match": {"host": "web*"}, "for": "7d", "from", "until", "comment"} (admin) |
DELETE /api/detections/{id}/exceptions/{exception} |
Remove an exception (admin) |
POST /api/findings/{id}/suppress |
Don't alert on this again: {"fields": ["host", "user"], "for": "30d", "comment", "update": {"status": "closed", "disposition": "false_positive"}} adds an exception for the finding's values of those fields and can update the finding (admin) |
POST /api/detections/enable |
{"ids": [...], "enabled": true}: turn up to 5,000 detections on or off at once (404 and no change if any ID is unknown) |
GET /api/coverage |
ATT&CK matrix with coverage |
GET /api/findings?status=&severity=&urgency=&owner=&detection=&q=&since=7d&limit= |
Queue |
GET, PATCH /api/findings/{id} |
Read; update status, owner, severity, disposition, comment |
POST /api/findings/bulk |
{"ids": [...], "update": {...}} |
POST /api/findings |
Raise a finding by hand |
GET /api/findings/summary |
Open counts by status, severity and urgency |
GET /api/cases?status=&owner=&q=&finding=&limit= |
Cases, most recently updated first, without timelines |
POST /api/cases |
{"title", "description", "severity", "owner", "findings": [...]}; the title defaults to the first finding's |
GET, PATCH /api/cases/{id} |
Read with linked findings; update title, description, status, severity, owner, resolution, comment, and close_findings with disposition |
POST /api/cases/{id}/findings |
{"ids": [...]}: add findings, moving them from another case |
DELETE /api/cases/{id}/findings/{finding} |
Remove a finding |
POST /api/cases/{id}/entries |
Add {"kind": "note", "text"}, {"kind": "search", "query", "earliest", "latest"} (runs it and keeps 50 rows) or {"kind": "event", "event": {...}} |
DELETE /api/cases/{id}/entries/{entry} |
Remove a timeline entry |
POST /api/cases/{id}/entities |
{"field", "value"} |
GET /api/cases/summary |
Open cases, and open cases you own |
GET /api/risk?since=24h&type= |
Risk objects by total score, with context |
GET /api/risk/object?object=&type=&since=7d |
Risk events for one object |
GET, PUT /api/lookups/assets, /api/lookups/identities |
List or replace a list (CSV body of up to 16 MB and 1,000,000 rows; 400 lists every bad line) |
GET /api/lookups/assets.csv, /api/lookups/identities.csv |
Download the current CSV |
GET /api/lookups/context?field=&value= |
Context for one value |