Response actions¶
A response action is something Signalith does outside itself when an analyst asks: block an IP on the firewall, disable a user in Active Directory, isolate a host, or start a workflow in a SOAR. Admins set the actions up on the Response actions page (under Configure). Analysts run them from a finding or a case with Respond.
Every run goes the same way:
- The analyst picks an action, and the target is filled in from the finding's or the case's entities with the right kind (an IP for a block action, a user for a disable action). They can change it.
- Actions marked disruptive need a reason. Every action that changes something is disruptive unless an admin says otherwise; "enable" (undo) actions are not by default.
- A confirm step says exactly what will happen, for example Disable the AD account jdoe in corp.example.com. Nothing runs before the analyst confirms.
- The server checks the target strictly for its kind, writes the run to the audit log, runs the action, and shows the result and output.
- The run is noted on the case timeline (where it cannot be removed) or in the finding's activity, and kept in the audit log with who ran it, why, on what, and what came back.
Signalith does not run actions by itself. Playbooks, which will run actions
automatically, are planned; they will call the same code and be recorded the same
way, as playbook <name>.
Who can do what¶
| Role | Can |
|---|---|
| Viewer | See the timeline entries of runs on a case. |
| Analyst | Run actions from a finding or a case, see past runs. |
| Admin | Add, change, test and delete actions on the Response actions page. |
Each user may start at most 30 runs a minute; more are refused with
429 Too Many Requests until the minute is over.
Targets¶
An action acts on one kind of target, or on any:
| Kind | Accepted | Refused |
|---|---|---|
ip |
One IPv4 or IPv6 address (IPv4-mapped IPv6 is turned into IPv4) | Ranges, zones (fe80::1%eth0), 0.0.0.0 and ::, loopback, multicast, broadcast |
domain |
Two or more DNS labels: letters, digits and hyphens, up to 253 characters; lower-cased, trailing dot removed | Internationalized names (use their xn-- form), a last label that is a number |
host |
A host name (one or more labels as above) or an IP address | The same as domain and ip |
user |
Letters and digits of any script, . _ - @ \ and single spaces, up to 256 characters; one @ and one \ at most (jdoe@corp.example.com, CORP\jdoe) |
A leading - or ., quotes, control characters, shell or template syntax |
any |
Any of the above. The kind is the one of the entity the analyst picked, else guessed: an address is ip, a value with @, \ or a space is user, one with a dot is domain, anything else is host. |
These rules apply to every action, whatever it does with the target, so a value can
never be read as an option (-rf), a second argument or a template.
Connector kinds¶
Send a webhook¶
Kind http. Sends one HTTP request: a method, a URL, headers, and a body built
from a template.
- Placeholders:
{{.ip}},{{.domain}},{{.user}},{{.host}}(the target, under its kind),{{.target}}and{{.target_kind}},{{.finding_id}},{{.case_id}},{{.actor}}(who ran it) and{{.reason}}. A placeholder of another kind is empty:{{.ip}}is empty when the target is a user. - No template injection: the text is read once and each placeholder is replaced by its value escaped for where it stands. The result is never read again, so a value can never add placeholders, template code, JSON or URL syntax.
- In the URL, values are path-escaped before the
?and query-escaped after it. Placeholders cannot stand in the scheme, host or port, so a target can't send the request elsewhere. - In a JSON body, values are escaped as the inside of a JSON string; put
placeholders between quotes:
{"ip": "{{.ip}}"}. The body must be valid JSON once filled in, which is checked when you save. - A form body query-escapes values; a text body takes them as they are.
- Headers are saved encrypted and never shown again. Use
Authorization: Bearer <token>for a token, or the user name and password fields for Basic sign-in (not both). Signalith setsHost,Content-Length,Content-Type,Transfer-EncodingandConnectionitself. - The request must use the target, in the URL or the body.
- Success is any 2xx answer. The answer's first 64 KB are kept as the run's
output, with every saved secret replaced by
[hidden].
Outbound safety rules, for every request (tests too):
- A 10-second timeout, no proxy, TLS certificates always checked. An action may trust one more CA: paste its PEM under Advanced.
- Redirects are followed (up to three) only on the same scheme, host and port, so a redirect cannot carry the headers to another server.
- Private networks (10/8, 172.16/12, 192.168/16, fc00::/7) are allowed: firewalls and automation servers live there. The server's own addresses (loopback) are refused unless Allow this server's own addresses is ticked, and cloud metadata addresses (169.254.169.254 and the like) and OpenSearch's own address are always refused. Addresses are checked after the name is looked up, on the connection itself, so DNS cannot be used to get around it.
Block an IP on OPNsense or pfSense¶
The Block IP on OPNsense/pfSense template adds the IP to a firewall alias:
- In OPNsense, Firewall › Aliases: add a Host(s) alias named
signalith_block, empty. - Firewall › Rules on the WAN (and LAN, for outbound): add a rule that blocks
source (or destination)
signalith_block, at the top. - System › Access › Users: make a user with only the Firewall: Alias: Edit privilege, then add an API key for it. The key and secret download as a file.
- In Signalith, pick the template, set the URL to your firewall
(
https://fw.example.lan/api/firewall/alias_util/add/signalith_block), and paste the key as API key and the secret as API secret. - Add the Unblock IP on OPNsense (undo) template the same way, so analysts can take a block back.
On pfSense, install the REST API package (pfSense-pkg-RESTAPI) and point the URL
at its firewall alias endpoint, with its body format; the template is a starting
point.
Shuffle¶
Shuffle is an open-source SOAR. Signalith doesn't include it: run it as its own program, on its own server, under its own license (AGPL). To start a workflow from Signalith:
- In Shuffle, add a Webhook trigger to the workflow, start it, and copy its URL.
- On the Response actions page, add the Shuffle workflow webhook template
and paste the URL. It sends the target, its kind, the finding, the case, who ran
it and why as JSON; use them in the workflow as
$exec.targetand so on.
The webhook answers at once; what the workflow does later shows in Shuffle, not in the run's output.
Run a script¶
Kind script. Runs a program on the Signalith server that handled the request.
- Only programs in the scripts folder can run. It is
<state>/scripts(/var/lib/signalith/scriptson Linux,C:\ProgramData\Signalith\scriptson Windows), orresponse.scripts_dirin signalith.yaml, or theSIGNALITH_RESPONSE_SCRIPTS_DIRenvironment variable. It cannot be changed on a page. That is the point: an admin who can change actions on the page still can't make Signalith run any program they like, only the ones someone with access to the server's files put in that folder. - The program is a path inside that folder, such as
isolate-host.shoredr/isolate.ps1. It is resolved with every link followed, and must still be inside the folder (a symlink to/bin/shis refused), a regular file, and on Linux executable and not writable by everyone. - No shell. The program gets the target as its one argument. It also gets
SIGNALITH_TARGET,SIGNALITH_TARGET_KIND,SIGNALITH_FINDING_ID,SIGNALITH_CASE_ID,SIGNALITH_ACTORandSIGNALITH_RUN_IDin a small environment:PATHand the few variables programs need (HOME,USER,LANG,TZ,TMPDIR; on WindowsSystemRoot,TEMP,PSModulePathand the like). None of Signalith's own environment (keys, passwords) is passed on. - It runs in its own folder, for 60 seconds unless the action says otherwise, and at most 10 minutes; then it is stopped, with any programs it started (on Linux, its whole process group). Exit code 0 means success.
- Its standard output and error, the first 16 KB, are the run's output.
- Windows:
.ps1scripts run withpowershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <script> <target>;.exefiles run directly..cmdand.batfiles are refused: cmd.exe reads its arguments again, so a target could not be passed safely.
Put scripts in the folder with an administrator account, and keep it writable only by administrators (the Security page checks it). Scripts run as the Signalith service account, so give that account only the rights the scripts need.
Example: isolate a Linux host¶
isolate-host.sh, for hosts you manage over SSH with a key the Signalith account
holds. It cuts the host off except for SSH from the management network:
#!/bin/sh
# isolate-host.sh <host>: firewall a Linux host off the network.
set -eu
host="$1"
case "$host" in -*|*[!A-Za-z0-9.:-]*) echo "bad host: $host" >&2; exit 2;; esac
ssh -o BatchMode=yes -o ConnectTimeout=10 "isolate@$host" -- sudo /usr/local/sbin/isolate-me
echo "Isolated $host (run $SIGNALITH_RUN_ID by $SIGNALITH_ACTOR)."
On each host, /usr/local/sbin/isolate-me (allowed for the isolate user in
sudoers, and nothing else) keeps SSH from the management network and drops the
rest:
#!/bin/sh
set -eu
nft -f - <<'EOF'
table inet isolate {
chain input { type filter hook input priority -10; policy drop;
iif lo accept; ct state established,related accept;
ip saddr 10.0.10.0/24 tcp dport 22 accept; }
chain output { type filter hook output priority -10; policy drop;
oif lo accept; ct state established,related accept; }
}
EOF
nft delete table inet isolate on the host undoes it.
Example: isolate a Windows host¶
isolate-host.ps1, for domain-joined hosts with PowerShell remoting, run by a
service account allowed to remote into them. It turns on Windows Firewall with
everything blocked except the management network:
# isolate-host.ps1 <host>: firewall a Windows host off the network.
param([Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9.-]+$')][string]$ComputerName)
$ErrorActionPreference = 'Stop'
Invoke-Command -ComputerName $ComputerName -ScriptBlock {
New-NetFirewallRule -DisplayName 'Signalith isolation: management' -Group 'Signalith isolation' `
-Direction Inbound -RemoteAddress 10.0.10.0/24 -Action Allow | Out-Null
New-NetFirewallRule -DisplayName 'Signalith isolation: management out' -Group 'Signalith isolation' `
-Direction Outbound -RemoteAddress 10.0.10.0/24 -Action Allow | Out-Null
Set-NetFirewallProfile -All -Enabled True -DefaultInboundAction Block -DefaultOutboundAction Block
}
"Isolated $ComputerName (run $env:SIGNALITH_RUN_ID by $env:SIGNALITH_ACTOR)."
To undo, set -DefaultOutboundAction Allow again and remove the rules of the
Signalith isolation group. If you have an EDR, its own isolate API (an http
action) is usually the better choice.
Velociraptor¶
Velociraptor can quarantine a host
(Windows.Remediation.Quarantine, Linux.Remediation.Quarantine) and collect
evidence from it. Like Shuffle it is a separate program you run yourself; Signalith
only calls it. Use its API from a script: give the Signalith account an API client
config (velociraptor config api_client) with the investigator role, and have
the script start the artifact on the client whose host name is the target with
velociraptor --api_config api.yaml query "SELECT collect_client(...) FROM ...".
Disable a directory account¶
Kind disable_user. Disables (or enables again) an account in the directory set
up under Users › Directory sign-in, by LDAP.
- Active Directory: it sets the
ACCOUNTDISABLEbit (0x2) ofuserAccountControl; enable clears it. Other bits are left as they are. The account is found bysAMAccountNameoruserPrincipalNameunder the user base DN, among people (objectCategory=person,objectClass=user), disabled ones included. An account already in the asked state is left alone and reported so. - Other LDAP directories (OpenLDAP, 389 Directory Server, FreeIPA): there is no
standard "disabled" flag, so the action names the attribute and value that lock an
account in yours, such as
nsAccountLock: TRUEon 389 DS and FreeIPA. Disable sets it; enable removes it. The account is found with the directory's user filter. - Writes need encryption: the directory must use
ldaps://or StartTLS, with certificates checked. Without it the action refuses to run, and the page says why. - The action refuses a name that matches more than one account, and refuses to change the account it signs in as.
It signs in as the directory sign-in account, or as an account of its own (bind DN and password on the action, saved encrypted). That account must be allowed to change users. In Active Directory, delegate only that:
- In Active Directory Users and Computers, right-click the OU that holds the users (not the domain, and not one that holds admins) and choose Delegate Control.
- Add the service account, choose Create a custom task to delegate, Only the following objects in the folder, tick User objects.
- Under permissions, tick Property-specific and then Read userAccountControl and Write userAccountControl (shown together as "Read/Write userAccountControl").
Accounts in protected groups (Domain Admins and the like) are guarded by AdminSDHolder and stay out of reach, which is what you want. If the account lacks the right, the run fails with the directory refused the change and says which right to delegate.
Test on the page signs in and looks the user up (jdoe, or what you type),
and changes nothing.
Templates¶
Add action offers templates; each fills in the form, and you change what you need:
| Template | Kind | Acts on |
|---|---|---|
| Block IP on OPNsense/pfSense (alias API) | http | ip |
| Unblock IP on OPNsense (undo) | http | ip |
| Block IP via generic webhook | http | ip |
| Shuffle workflow webhook | http | any |
| Isolate host with a script | script | host |
| Disable AD user | disable_user | user |
| Enable AD user (undo) | disable_user | user |
Testing an action¶
Test on the edit form checks an action without changing anything, as far as its
kind allows: an http action fills in its request (shown with secrets hidden) and
checks the server's address; a script action finds its program; a disable_user
action signs in and finds the user. Send test request really sends an http
action's request, after asking; it is recorded in the audit log like any run.
Audit log¶
Every run is a document in the <prefix>_actions OpenSearch index, created at
startup. It is written before the action runs and again when it ends; if it can't
be written, the action does not run. A run holds its ID (RUN-20261013-…), the
action, kind, target and its kind, who ran it (or the playbook, and who approved
it), the reason, the finding and case, the status (running, succeeded,
failed), a summary, the output, the error, the server that ran it, and start and
end times. A run left running by a server that stopped is shown as
interrupted: check the target, since it may or may not have happened.
Each run is also logged (response action started, then response action
succeeded or response action failed, with audit=true), and so are changes to the actions (response
actions changed) and tests.
Settings¶
Actions added on the page are saved in lookups/response.yaml, with passwords,
headers and bind passwords encrypted with the server's key. It is not meant to be
edited by hand; if it can't be read, the page says so and nothing runs. To move it,
set:
response:
file: /var/lib/signalith/lookups/response.yaml # or SIGNALITH_RESPONSE_FILE
scripts_dir: /var/lib/signalith/scripts # or SIGNALITH_RESPONSE_SCRIPTS_DIR
signalith rotate-master re-encrypts the file with the rest.
Several servers: each server reads its own response.yaml and its own scripts
folder, and an action runs on whichever server handled the analyst's request. Copy
the file (with the same master key) and the scripts to each server that serves the
web UI, or put them on a shared folder.
API¶
| Method and path | Role | |
|---|---|---|
GET /api/respond/actions |
analyst | The actions analysts can run, without their settings. |
POST /api/respond/run |
analyst | Run one: {"action", "target", "target_kind"?, "finding_id"?, "case_id"?, "reason"}. Returns the run. |
GET /api/respond/runs |
analyst | Past runs, newest first: ?finding_id=, ?case_id=, ?action=, ?limit= (50, at most 500). |
GET /api/respond/config |
admin | The actions with their settings (secrets show only as set or not), templates, scripts. |
PUT /api/respond/config |
admin | Replace the actions: {"actions": [...]}. A secret is {"value": "..."} to set it or {"keep": true} to keep the saved one. |
POST /api/respond/test |
admin | Test one action: {"action", "target", "send"}. |
A saved secret is kept only while the action still sends it to the same place:
the same scheme, host and port for an http action (and the same user name for
its password), the same bind DN for a disable_user action. Change the URL to
another server and the page asks for the secrets again, so whoever can change an
action can't have its key sent somewhere else.
A run refused before it starts answers 400 (with the field at fault), 404
(no such action, finding or case), 429 (too many runs) or 503 (the audit log
can't be written). A run that started always answers 200 with the run, whose
status says how it went.