Response Plans
- The Shape of a Plan
- A Worked Plan: Suspected Credential Compromise
- Design Notes
- Staged Rollout
- Approval Gates
- Testing
A response plan is a sequence: gather, decide, contain, record, notify. Most teams either run it by hand from a wiki page, or buy a platform to run it for them.
There is a middle option. A response plan is a function, and BabySOARus runs functions.
The Shape of a Plan
# 1. Gather
context = enrich(alert)
# 2. Decide
decision = classify(context)
# 3. Act
if decision.contain:
isolate(context.host)
# 4. Record
case = open_case(context, decision)
# 5. Notify
notify(case, decision)
That is the whole thing, and it fits in an alert action.
A Worked Plan: Suspected Credential Compromise
Triggered by a scheduled search that finds a successful login following sustained failures.
import datetime
# --- configuration -------------------------------------------------------
IDENTITY_API = 'https://identity.example.internal/api/v1'
EDR_API = 'https://edr.example.internal/api/v3'
CASES_API = 'https://cases.example.internal/api/v1'
NEVER_DISABLE = {'svc_backup', 'svc_monitoring', 'breakglass'}
# Secrets come from Splunk's own store, never from the snippet.
def secret(name):
r = splunk_request(
'GET',
f'/servicesNS/nobody/babysoarus/storage/passwords/%3A{name}%3A?output_mode=json')
return json.loads(r['body'])['entry'][0]['content']['clear_password']
TOKENS = {'identity': secret('identity_token'), 'edr': secret('edr_token'),
'cases': secret('cases_token')}
def call(api, method, path, token, body=None, timeout=20):
return http_request(method, f'{api}{path}',
headers={'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'},
body=body, timeout=timeout)
# --- 1. gather -----------------------------------------------------------
timeline = []
for e in events:
user, src = e.get('user', ''), e.get('src_ip', '')
identity = {}
r = call(IDENTITY_API, 'GET', f'/users/{user}', TOKENS['identity'])
if r['status'] == 200:
identity = json.loads(r['body'])
e['department'] = identity.get('department', 'unknown')
e['privileged'] = bool(identity.get('privileged'))
e['manager'] = identity.get('manager_email', '')
# Where has this account been today?
r = splunk_request(
'GET',
'/services/search/jobs/export?output_mode=json&search='
f'search index=auth user={user} earliest=-24h '
'| stats dc(src_ip) as sources values(dest) as systems')
if r['status'] == 200:
for line in r['body'].decode().splitlines():
if line.strip():
try:
result = json.loads(line)['result']
e['sources_24h'] = int(result.get('sources', 0))
e['systems_touched'] = result.get('systems', [])
except (KeyError, ValueError):
pass
timeline.append(e)
# --- 2. decide -----------------------------------------------------------
for e in timeline:
reasons = []
severity = 'medium'
if e['privileged']:
severity = 'critical'
reasons.append('privileged account')
if int(e.get('preceding_failures', 0)) > 20:
reasons.append(f"{e['preceding_failures']} failures before success")
if int(e.get('sources_24h', 0)) > 5:
severity = 'critical'
reasons.append(f"{e['sources_24h']} distinct source addresses in 24h")
e['severity'] = severity
e['reasons'] = reasons
e['contain'] = severity == 'critical' and e['user'] not in NEVER_DISABLE
# --- 3. act --------------------------------------------------------------
for e in timeline:
e['actions_taken'] = []
if not e['contain']:
e['actions_taken'].append('no automatic containment')
continue
r = call(IDENTITY_API, 'POST', f"/users/{e['user']}/sessions/revoke",
TOKENS['identity'])
e['actions_taken'].append(
'sessions revoked' if r['status'] < 300 else f"session revoke failed ({r['status']})")
r = call(IDENTITY_API, 'POST', f"/users/{e['user']}/require-password-reset",
TOKENS['identity'])
e['actions_taken'].append(
'password reset required' if r['status'] < 300 else f"reset failed ({r['status']})")
for system in e.get('systems_touched', [])[:5]:
r = call(EDR_API, 'POST', f'/hosts/{system}/scan',
TOKENS['edr'], body={'type': 'quick'})
e['actions_taken'].append(
f'scan queued on {system}' if r['status'] < 300 else f'scan failed on {system}')
# --- 4. record -----------------------------------------------------------
r = call(CASES_API, 'POST', '/cases', TOKENS['cases'], body={
'title': f"Credential compromise: {', '.join(sorted({e['user'] for e in timeline}))}",
'severity': 'critical' if any(e['severity'] == 'critical' for e in timeline) else 'medium',
'opened': datetime.datetime.utcnow().isoformat() + 'Z',
'source': 'splunk/BabySOARus',
'splunk_sid': context.get('sid', ''),
'findings': [
{'user': e['user'], 'src_ip': e.get('src_ip'),
'severity': e['severity'], 'reasons': e['reasons'],
'actions': e['actions_taken']}
for e in timeline
],
})
case = json.loads(r['body']) if r['status'] < 300 else {'id': 'unfiled', 'url': ''}
# --- 5. notify -----------------------------------------------------------
critical = [e for e in timeline if e['severity'] == 'critical']
if critical:
http_request('POST', secret('pager_webhook'), body={
'severity': 'critical',
'summary': f"{len(critical)} account(s) contained: "
f"{', '.join(e['user'] for e in critical)}",
'links': [{'href': case.get('url', ''), 'text': 'case'}],
}, timeout=10)
for e in timeline:
e['case_id'] = case.get('id', '')
e['case_url'] = case.get('url', '')
events = timeline
Long, but every line is doing something an analyst would otherwise do by hand, and the whole plan is visible at once.
Design Notes
Never contain without an exclusion list. NEVER_DISABLE exists because
the first time an automated plan disables a service account, automation gets
switched off permanently. Make the list explicit and put it near the top.
Record what you tried, not what you intended. Every action appends its
real outcome to actions_taken, including failures. When someone asks what
happened at 3am, the answer is in the case.
Degrade rather than fail. An enrichment API being down should not stop containment. Check status codes, carry on with what you have.
Keep secrets out of the snippet. The secret() helper reads from Splunk's
password store using the search's own credentials, so the code is safe to
commit.
Staged Rollout
Automated containment is a trust exercise. Stage it.
Stage 1: observe. Run the plan with all actions replaced by logging. Compare its decisions against what analysts actually did.
DRY_RUN = True
def act(description, request):
if DRY_RUN:
return f'[dry run] would {description}'
r = request()
return description if r['status'] < 300 else f'{description} FAILED ({r["status"]})'
Stage 2: reversible actions only. Revoke sessions and require a password reset. Both are annoying and neither is destructive.
Stage 3: containment, with a whitelist. Isolate hosts, but only ones on a list you maintain deliberately.
Stage 4: containment by policy. Isolate anything meeting the criteria, with the exclusion list as the guard.
Most teams stop at stage 2 or 3, and that is a perfectly good place to stop.
Approval Gates
Some steps should need a human. prompt.ask asks and returns immediately --
the plan does not block waiting for an answer, and finishes as if it had not
asked at all:
from babysoarus_exec import prompt
def process(events):
# A resumed run carries the decision in the first event, not a fresh batch.
if events and 'prompt_response' in events[0]:
e = events[0]
approved = e['prompt_response'] == 'yes'
e['contained'] = approved
if approved:
http_request('POST', f'{PROXY}/api/isolate', body={'host': e['host']})
return [e]
for e in events:
if e['severity'] == 'critical' and not e['contain']:
prompt.ask(f"isolate {e['host']}?", ['yes', 'no'],
['soc-lead'], timeout=1800)
e['status'] = 'awaiting approval'
return events
Answering happens later, outside this run, from the Activity view any
Splunk user with BabySOARus access already has -- not a second callback endpoint
of your own. Once someone in approvers answers, this same function runs
again automatically, as a background task, with a fresh batch of one event
carrying prompt_response/prompt_responder. timeout (seconds; 0 means
none) refuses a late answer rather than accepting it.
This is a real approval gate, not a pattern you assemble from http_request
and a second saved search. approvers is a list of usernames (not yet roles
or capabilities), checked server side against who is actually answering, never
trusted from the browser. The full chain -- who was asked, who answered, when,
and what ran as a result -- is recorded and visible in Activity's own
Pending/History tabs, not something to build a dashboard for separately.
Testing
Test the plan the way you test a detection: give it known input and check what it decides.
| makeresults
| eval user="d.harrington", src_ip="185.199.110.9", preceding_failures="34"
| exec inline="DRY_RUN = True
..."
| table user severity contain actions_taken
With DRY_RUN = True the plan runs end to end, calls nothing, and prints what
it would have done. Run that on a schedule against synthetic input and you
have a regression test for your response process.