Skip to content

Commit 53e2625

Browse files
DavidJBiancoclaude
andcommitted
Fix snort emitter crash and remove 'attacker' special actor
Snort emitter: skip events lacking IDS alert fields (sid, message) instead of crashing on None timestamp. Add Zeek-to-Snort field name mapping as fallbacks. Add None guard in base emitter threaded path. Scenario skill: enforce one question per message, add realism review step, improve threat actor modeling guidance (compromised accounts instead of 'attacker' username), require realistic naming for attacker infrastructure. Validation: remove 'attacker' as special-cased valid actor — storyline actors must now be defined users. Update tests accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d8e2c64 commit 53e2625

6 files changed

Lines changed: 85 additions & 34 deletions

File tree

commands/eforge/scenario.md

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Your job is to understand what the user wants, ask smart questions to fill gaps,
2929

3030
Use a hybrid approach: let the user describe their idea first, then ask targeted follow-up questions to fill gaps. Don't present a checklist — have a conversation.
3131

32+
**Ask exactly ONE question per message.** Never bundle multiple questions in a single turn — it's overwhelming and users tend to only answer the first one. Use the `AskUserQuestion` tool if it's available to you; fall back to a conversational question if not. Either way, one question at a time. After the user answers, acknowledge briefly (one sentence max) and move to the next topic.
33+
3234
If the user gives a rich description up front, extract as much as you can from it before asking questions. If they're vague ("I need some attack data"), guide them through the key decisions.
3335

3436
### Key Topics to Cover
@@ -82,7 +84,33 @@ The username format should follow a consistent convention for the organization (
8284

8385
**Service/system accounts** are the exception — names like `svc_backup`, `sql_agent`, or `ftp_service` are fine when needed for the story or environment realism. Only add these when they're needed.
8486

85-
**External threat actors** should use the username `attacker` (or `attacker1`, `attacker2` for multiple). These are excluded from the ENVIRONMENT.md document given to students.
87+
### Modeling Threat Actors
88+
89+
**External attackers do NOT have their own accounts in the victim organization.** Never create a user called `attacker`, `hacker`, `threat_actor`, or anything obviously malicious. Real attackers operate by:
90+
91+
- **Compromising legitimate accounts** — The attacker gains credentials for an existing user (via phishing, credential stuffing, password spraying, etc.) and uses that account. The storyline `actor` field is the compromised user's username. This is the most common case.
92+
- **Operating at the system level** — Some attacks don't involve user accounts at all (e.g., exploiting a vulnerable service). The actor can be a system account like `SYSTEM`, `NT AUTHORITY\SYSTEM`, `root`, or the service account running the exploited application.
93+
- **Creating new accounts (rare)** — If the attacker creates accounts for persistence, those accounts must have blending-in names like `svc_sqlbackup`, `admin.temp`, or `backup.service` — never `attacker1` or `evil_admin`.
94+
95+
**Insider threats** use their own legitimate account — they're already in the users list with a normal name.
96+
97+
### Realistic Naming for Attacker Infrastructure and Tools
98+
99+
Everything the attacker controls should look plausible at first glance. The whole point of threat hunting training is that the data looks realistic — obvious names are a dead giveaway that defeats the exercise.
100+
101+
**C2 servers and malicious domains:**
102+
- Good: `cdn-assets-update.com`, `analytics-service.net`, `img-hosting-cdn.com`, `graph-api-auth.com`
103+
- Bad: `evil-c2.com`, `malware-server.net`, `attacker-infra.io`, `hack.evil.com`
104+
105+
**Malicious files and processes:**
106+
- Good: `svchost_helper.exe`, `update-agent.bin`, `chromium_updater.sh`, `ms-index-service.exe`
107+
- Bad: `my_password_dumper.exe`, `evil_payload.ps1`, `hack_tool.bat`, `malware.exe`
108+
109+
**Attacker email addresses** (for phishing "From:" lines, etc.):
110+
- Good: `support@accounts-verify.com`, `noreply@hr-benefits-portal.net`, `j.martinez@consulting-group.com`
111+
- Bad: `attacker@external`, `hacker@evil.com`, `phishing@malicious.net`
112+
113+
**Exception — real tool names:** When the scenario uses a well-known attack tool, use its real name. `mimikatz.exe` is mimikatz. `PsExec.exe` is PsExec. `nmap`, `Rubeus.exe`, `SharpHound.exe`, `Cobalt Strike` — all fine. The rule is: don't *invent* names that scream "malicious", but don't rename real tools either.
86114

87115
## Scenario YAML Schema
88116

@@ -158,7 +186,7 @@ baseline_activity:
158186

159187
storyline: # The attack events to bury in the data
160188
- time: "+2h" # Relative offset from start, or absolute ISO 8601
161-
actor: attacker # Username or "attacker"
189+
actor: marcus.chen # Username of compromised account (or system account)
162190
system: WS-DEV-01 # Must reference existing hostname
163191
activity: "Description of what happens"
164192
details: # Flexible dict — activity-specific fields
@@ -191,7 +219,7 @@ The scenario is validated before generation. Common issues to avoid:
191219
- Every `user.persona` must match a persona name (from inline personas or pre-built library)
192220
- Every `user.primary_system` must match a system hostname
193221
- Every `system.assigned_user` must match a username
194-
- Every storyline `actor` must be a username or "attacker"
222+
- Every storyline `actor` must be a username defined in the users list (or a system account like `SYSTEM`/`root`)
195223
- Every storyline `system` must match a system hostname
196224
- Usernames, hostnames, and IPs must all be unique
197225
- Network segment `systems` must reference existing hostnames
@@ -332,7 +360,8 @@ After generating the scenario YAML, also create an `ENVIRONMENT.md` file in the
332360
- Include ALL users who appear in the storyline (their accounts show up in the attack data, so students need to be able to look them up)
333361
- Add 5–15 additional users from the background population, mixed in with the storyline users
334362
- For very large scenarios (50+ users), include a representative subset — not all of them
335-
- **Exclude all external threat actors** (username "attacker", "attacker1", etc.) — these should never appear in the document
363+
- **Exclude any accounts the attacker created** during the attack (e.g., persistence accounts like `svc_sqlbackup`) — these wouldn't exist in the org's directory beforehand
364+
- **Include every legitimate user whose account gets compromised** — students will see activity under that username and need to look them up
336365
- Use natural role names, not raw persona codes. Persona "hr" becomes "Human Resources", "sysadmin" becomes "System Administrator", "developer" becomes "Software Engineer", etc.
337366

338367
**Timezone:**
@@ -354,9 +383,17 @@ After the interview, generate both files:
354383

355384
1. **Scenario YAML** — Write to the user's chosen path (default: `scenarios/<scenario-name>.yaml`)
356385
2. **ENVIRONMENT.md** — Write alongside the scenario YAML
357-
3. **Validate** — Run `uv run eforge validate <scenario-file>` to check schema and cross-references
358-
4. If validation fails, fix the issues and re-validate
359-
5. **Summarize** what was created: environment size, time window, attack narrative overview, log formats
386+
3. **Realism Review** — Before validating, review the entire scenario as a tough-but-fair devil's advocate. Check:
387+
- **Attack realism**: Does the attack chain make sense? Would a real attacker do this in this order? Are there missing steps (e.g., no reconnaissance before lateral movement, no persistence after initial access)?
388+
- **Technical accuracy**: Are command lines correct for the target OS? Are process paths right? Do the MITRE ATT&CK technique IDs match what's actually happening?
389+
- **Naming realism**: Are all attacker-controlled artifacts (domains, files, processes, created accounts) plausibly named? Would any name immediately tip off a defender? Check for names like `attacker`, `evil.com`, `malware.exe`, `@external`, or anything that screams "malicious".
390+
- **Environmental consistency**: Do the users, systems, and network make sense together? Would this org realistically have this infrastructure?
391+
- **Timing realism**: Are attack events spaced realistically? (Not crammed into 30 seconds, not dragged over days with no activity)
392+
- **Detection opportunity**: Is there enough signal for a hunter to find the attack while still requiring genuine effort?
393+
If you find issues, fix them. Tell the user what you changed and why.
394+
4. **Validate** — Run `uv run eforge validate <scenario-file>` to check schema and cross-references
395+
5. If validation fails, fix the issues and re-validate
396+
6. **Summarize** what was created: environment size, time window, attack narrative overview, log formats
360397

361398
If the user wants to immediately generate logs, suggest using `/eforge generate` or running `uv run eforge generate <scenario-file>`.
362399

src/evidenceforge/generation/emitters/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ def _run(self) -> None:
112112
# Try to get event from queue with timeout
113113
event_data = self._event_queue.get(timeout=0.1)
114114

115-
# Render and buffer the event
115+
# Render and buffer the event (None means skip)
116116
rendered = self._render_event(event_data)
117-
self._buffer_event(rendered)
117+
if rendered is not None:
118+
self._buffer_event(rendered)
118119

119120
# Mark task as done
120121
self._event_queue.task_done()

src/evidenceforge/generation/emitters/snort.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,37 @@ def emit_event(self, event_data: dict[str, Any]) -> None:
1616
self._emit_threaded(event_data)
1717
else:
1818
rendered = self._render_event(event_data)
19-
self._buffer_event(rendered)
19+
if rendered is not None:
20+
self._buffer_event(rendered)
2021

21-
def _render_event(self, event_data: dict[str, Any]) -> str:
22+
def _render_event(self, event_data: dict[str, Any]) -> str | None:
2223
"""Render Snort/Suricata alert to fast alert format.
2324
25+
Returns None if the event lacks required IDS alert fields (sid, message,
26+
classification), which means it's a plain connection event that should
27+
not generate an IDS alert.
28+
2429
Format: <timestamp> [**] [<sid>:1:1] <message> [**] [Classification: <class>] [Priority: <pri>] {<proto>} <src_ip>:<src_port> -> <dst_ip>:<dst_port>
2530
"""
31+
# Skip events that lack IDS-specific fields — normal network connections
32+
# routed here by network_visibility should not produce alerts
33+
if not event_data.get('sid') and not event_data.get('message'):
34+
return None
35+
36+
# Map Zeek field names to Snort field names as fallbacks
37+
proto = event_data.get('protocol') or event_data.get('proto')
38+
2639
context = {
27-
'timestamp': event_data.get('timestamp'),
40+
'timestamp': event_data.get('timestamp') or event_data.get('ts'),
2841
'sid': event_data.get('sid'),
2942
'classification': event_data.get('classification'),
3043
'priority': event_data.get('priority'),
31-
'protocol': event_data.get('protocol'),
32-
'src_ip': event_data.get('src_ip'),
33-
'src_port': event_data.get('src_port'),
34-
'dst_ip': event_data.get('dst_ip'),
35-
'dst_port': event_data.get('dst_port'),
36-
'message': event_data.get('message')
44+
'protocol': proto.upper() if proto else None,
45+
'src_ip': event_data.get('src_ip') or event_data.get('id.orig_h'),
46+
'src_port': event_data.get('src_port') or event_data.get('id.orig_p'),
47+
'dst_ip': event_data.get('dst_ip') or event_data.get('id.resp_h'),
48+
'dst_port': event_data.get('dst_port') or event_data.get('id.resp_p'),
49+
'message': event_data.get('message'),
3750
}
3851

3952
# Render template

src/evidenceforge/models/scenario.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ class StorylineEvent(BaseModel):
245245
246246
Attributes:
247247
time: Event time (ISO 8601, relative offset like "+2h30m", or seconds "+7200")
248-
actor: Username or "attacker" for external actor
248+
actor: Username of the account performing the action (compromised account or system account)
249249
system: Target system hostname
250250
activity: Natural language activity description
251251
details: Optional activity-specific details (flexible dict)

src/evidenceforge/validation/schema.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,14 @@ def _validate_storyline_references(self) -> None:
157157
return
158158

159159
for idx, event in enumerate(self.scenario.storyline):
160-
# Validate actor (must be user or "attacker")
161-
if event.actor not in self.usernames and event.actor != "attacker":
160+
# Validate actor (must be a defined username)
161+
if event.actor not in self.usernames:
162162
self.issues.append(
163163
ValidationIssue(
164164
severity="error",
165165
field_path=f"storyline.{idx}.actor",
166166
message=f"Storyline event references undefined actor '{event.actor}'",
167-
suggestion=f"Available users: {', '.join(sorted(self.usernames))}, or use 'attacker'",
167+
suggestion=f"Available users: {', '.join(sorted(self.usernames))}",
168168
)
169169
)
170170

tests/unit/test_validation.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def test_invalid_group_member(self):
255255
assert "testuser" in issues[0].suggestion
256256

257257
def test_invalid_storyline_actor(self):
258-
"""Storyline actor not in users and not 'attacker' should error."""
258+
"""Storyline actor not in users list should error."""
259259
scenario = Scenario(
260260
version="1.0",
261261
name="test",
@@ -311,10 +311,9 @@ def test_invalid_storyline_actor(self):
311311
assert issues[0].field_path == "storyline.0.actor"
312312
assert "nonexistent_actor" in issues[0].message
313313
assert "testuser" in issues[0].suggestion
314-
assert "attacker" in issues[0].suggestion
315314

316-
def test_valid_attacker_actor(self):
317-
"""Storyline actor 'attacker' should be valid."""
315+
def test_actor_must_be_in_users_list(self):
316+
"""Storyline actor must be a defined user, even if named 'attacker'."""
318317
scenario = Scenario(
319318
version="1.0",
320319
name="test",
@@ -349,7 +348,7 @@ def test_valid_attacker_actor(self):
349348
storyline=[
350349
StorylineEvent(
351350
time="2024-01-15T10:30:00Z",
352-
actor="attacker", # Valid special actor
351+
actor="attacker", # Not in users list — should fail
353352
system="TEST-01",
354353
activity="malicious activity",
355354
details={}
@@ -365,9 +364,10 @@ def test_valid_attacker_actor(self):
365364
validator = ScenarioValidator(scenario)
366365
issues = validator.validate()
367366

368-
# Should only have 0 issues (attacker is valid)
369-
assert len(issues) == 0
370-
assert not validator.has_errors()
367+
# "attacker" is not in the users list, so it should be flagged
368+
assert len(issues) == 1
369+
assert issues[0].severity == "error"
370+
assert "attacker" in issues[0].message
371371

372372
def test_invalid_storyline_system(self):
373373
"""Storyline system not in systems list should error."""
@@ -405,7 +405,7 @@ def test_invalid_storyline_system(self):
405405
storyline=[
406406
StorylineEvent(
407407
time="2024-01-15T10:30:00Z",
408-
actor="attacker",
408+
actor="testuser",
409409
system="NONEXISTENT-01", # Invalid
410410
activity="malicious activity",
411411
details={}
@@ -904,7 +904,7 @@ def test_valid_event_sequence(self):
904904
storyline=[
905905
StorylineEvent(
906906
time="2024-01-15T10:30:00Z",
907-
actor="attacker",
907+
actor="testuser",
908908
system="TEST-01",
909909
activity="multi-step attack",
910910
event_sequence=[
@@ -940,7 +940,7 @@ def test_event_sequence_missing_sub_event_type(self):
940940
storyline=[
941941
StorylineEvent(
942942
time="2024-01-15T10:30:00Z",
943-
actor="attacker",
943+
actor="testuser",
944944
system="TEST-01",
945945
activity="multi-step attack",
946946
event_sequence=[
@@ -987,7 +987,7 @@ def test_none_optional_fields_no_issues(self):
987987
storyline=[
988988
StorylineEvent(
989989
time="2024-01-15T10:30:00Z",
990-
actor="attacker",
990+
actor="testuser",
991991
system="TEST-01",
992992
activity="simple attack",
993993
# event_sequence=None (default)

0 commit comments

Comments
 (0)