netforensicai 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netforensicai/__init__.py +1 -0
- netforensicai/agents/__init__.py +27 -0
- netforensicai/agents/base.py +226 -0
- netforensicai/agents/coordinator.py +150 -0
- netforensicai/agents/roles.py +90 -0
- netforensicai/cli.py +2421 -0
- netforensicai/core/__init__.py +0 -0
- netforensicai/core/ai_assistant.py +534 -0
- netforensicai/core/attack.py +93 -0
- netforensicai/core/audit.py +157 -0
- netforensicai/core/capture.py +705 -0
- netforensicai/core/case.py +256 -0
- netforensicai/core/chat.py +560 -0
- netforensicai/core/config.py +179 -0
- netforensicai/core/correlation.py +275 -0
- netforensicai/core/ctf.py +398 -0
- netforensicai/core/detections.py +807 -0
- netforensicai/core/diagnostics.py +133 -0
- netforensicai/core/entities.py +122 -0
- netforensicai/core/event.py +152 -0
- netforensicai/core/evidence.py +220 -0
- netforensicai/core/export.py +167 -0
- netforensicai/core/finding.py +216 -0
- netforensicai/core/investigate.py +114 -0
- netforensicai/core/ioc.py +730 -0
- netforensicai/core/narrative.py +360 -0
- netforensicai/core/pipeline.py +115 -0
- netforensicai/core/report.py +653 -0
- netforensicai/core/search.py +350 -0
- netforensicai/core/store.py +901 -0
- netforensicai/core/streams.py +304 -0
- netforensicai/core/threat_intel.py +100 -0
- netforensicai/core/timeline.py +162 -0
- netforensicai/dashboard.py +41 -0
- netforensicai/integrations/__init__.py +9 -0
- netforensicai/integrations/wireshark.py +576 -0
- netforensicai/intel/__init__.py +0 -0
- netforensicai/intel/virustotal.py +101 -0
- netforensicai/parsers/__init__.py +38 -0
- netforensicai/parsers/base.py +70 -0
- netforensicai/parsers/credentials.py +254 -0
- netforensicai/parsers/evtx.py +243 -0
- netforensicai/parsers/generic.py +189 -0
- netforensicai/parsers/pcap.py +866 -0
- netforensicai/parsers/pcap_engine.py +204 -0
- netforensicai/parsers/pcap_tshark.py +804 -0
- netforensicai/parsers/suricata.py +232 -0
- netforensicai/web/__init__.py +0 -0
- netforensicai/web/app.py +1220 -0
- netforensicai/web/static/app.js +3627 -0
- netforensicai/web/static/index.html +63 -0
- netforensicai/web/static/style.css +751 -0
- netforensicai-0.3.0.dist-info/METADATA +418 -0
- netforensicai-0.3.0.dist-info/RECORD +58 -0
- netforensicai-0.3.0.dist-info/WHEEL +5 -0
- netforensicai-0.3.0.dist-info/entry_points.txt +2 -0
- netforensicai-0.3.0.dist-info/licenses/LICENSE +21 -0
- netforensicai-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The investigation-team agents (see docs/design/agent-team.md).
|
|
2
|
+
|
|
3
|
+
Each agent is one role - a mission, a scoped subset of the read-only case
|
|
4
|
+
tools, and a step budget - run over the same grounded loop the chat assistant
|
|
5
|
+
uses. Every finding must cite a tool result or it is dropped, so no role can
|
|
6
|
+
invent a finding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from netforensicai.agents.base import AgentError, AgentFinding, Role, RoleResult, run_role
|
|
10
|
+
from netforensicai.agents.coordinator import MergedFinding, TeamResult, investigate, merge_findings
|
|
11
|
+
from netforensicai.agents.roles import ROLES, all_roles, get_role, resolve_roles
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ROLES",
|
|
15
|
+
"AgentError",
|
|
16
|
+
"AgentFinding",
|
|
17
|
+
"MergedFinding",
|
|
18
|
+
"Role",
|
|
19
|
+
"RoleResult",
|
|
20
|
+
"TeamResult",
|
|
21
|
+
"all_roles",
|
|
22
|
+
"get_role",
|
|
23
|
+
"investigate",
|
|
24
|
+
"merge_findings",
|
|
25
|
+
"resolve_roles",
|
|
26
|
+
"run_role",
|
|
27
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Foundation for the investigation-team agents.
|
|
2
|
+
|
|
3
|
+
An agent is one *role*: a mission, a scoped subset of the read-only case tools
|
|
4
|
+
(core/chat.py's CaseTools), and a step budget. It runs over the same grounded
|
|
5
|
+
tool-loop the chat assistant uses, and produces **structured findings** instead
|
|
6
|
+
of free text. The grounding contract is unchanged and non-negotiable: every
|
|
7
|
+
finding must cite a tool result, and a finding that cites something no tool
|
|
8
|
+
returned is dropped - the citation ledger from core/chat.py enforces that
|
|
9
|
+
mechanically, so a role cannot invent a finding.
|
|
10
|
+
|
|
11
|
+
This module is the reusable substrate. The concrete roles (Network Forensics,
|
|
12
|
+
Host/DFIR, ...) live in roles.py; the coordinator that runs several and merges
|
|
13
|
+
their findings lives in coordinator.py.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ValidationError
|
|
21
|
+
|
|
22
|
+
from netforensicai.core import ai_assistant
|
|
23
|
+
from netforensicai.core.chat import (
|
|
24
|
+
TOOL_SPECS,
|
|
25
|
+
CaseTools,
|
|
26
|
+
Citation,
|
|
27
|
+
Ledger,
|
|
28
|
+
_render,
|
|
29
|
+
_run_tool,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_ROLE_STEPS = 6
|
|
33
|
+
|
|
34
|
+
SEVERITIES = ("High", "Medium", "Low", "Info")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentError(Exception):
|
|
38
|
+
"""Raised when a role cannot be run at all (bad configuration)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentFinding(BaseModel):
|
|
42
|
+
"""One structured, evidence-cited finding from a role. Shaped to drop
|
|
43
|
+
straight onto the investigator-owned Finding model for one-click
|
|
44
|
+
acceptance."""
|
|
45
|
+
|
|
46
|
+
title: str
|
|
47
|
+
severity: str = "Medium"
|
|
48
|
+
assessment: str
|
|
49
|
+
confidence: str = "medium" # high | medium | low
|
|
50
|
+
citations: List[Citation] = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Role:
|
|
55
|
+
"""A specialist analyst: a name, the mission that scopes its prompt, the
|
|
56
|
+
tool names it may call (a subset of TOOL_SPECS), and a step budget."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
slug: str
|
|
60
|
+
mission: str
|
|
61
|
+
tools: tuple
|
|
62
|
+
max_steps: int = DEFAULT_ROLE_STEPS
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
unknown = [t for t in self.tools if t not in TOOL_SPECS]
|
|
66
|
+
if unknown:
|
|
67
|
+
raise AgentError(f"Role {self.slug!r} lists unknown tools: {unknown}")
|
|
68
|
+
|
|
69
|
+
def catalogue(self):
|
|
70
|
+
return "\n".join(f"- {name}: {TOOL_SPECS[name]}" for name in self.tools)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RoleResult:
|
|
75
|
+
"""What a role hands back: its cited findings, the tool calls it made, and
|
|
76
|
+
a note when it found nothing or could not run."""
|
|
77
|
+
|
|
78
|
+
role: str
|
|
79
|
+
slug: str
|
|
80
|
+
findings: List[AgentFinding] = field(default_factory=list)
|
|
81
|
+
tool_calls: list = field(default_factory=list)
|
|
82
|
+
note: Optional[str] = None
|
|
83
|
+
|
|
84
|
+
def to_dict(self):
|
|
85
|
+
return {
|
|
86
|
+
"role": self.role,
|
|
87
|
+
"slug": self.slug,
|
|
88
|
+
"findings": [f.model_dump() for f in self.findings],
|
|
89
|
+
"tool_calls": self.tool_calls,
|
|
90
|
+
"note": self.note,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# The protocol the model must speak. Identical grounding rules and citation
|
|
95
|
+
# shape to core/chat.py's SYSTEM_PROMPT - the only difference is the answer
|
|
96
|
+
# form, which here is a list of findings instead of one free-text answer.
|
|
97
|
+
_PROTOCOL = """Reply with a single JSON object and nothing else. Two forms are allowed.
|
|
98
|
+
|
|
99
|
+
To call a tool:
|
|
100
|
+
{"action": "tool", "tool": "<tool name>", "arguments": {...}}
|
|
101
|
+
|
|
102
|
+
To report your findings (your final reply):
|
|
103
|
+
{"action": "findings", "findings": [
|
|
104
|
+
{"title": "<short>", "severity": "High|Medium|Low|Info", "confidence": "high|medium|low",
|
|
105
|
+
"assessment": "<what the evidence shows and what you infer, kept separate>",
|
|
106
|
+
"citations": [{"kind": "event|frame|stream|detection", "evidence_id": "EV-0001", "reference": "..."}]}
|
|
107
|
+
]}
|
|
108
|
+
|
|
109
|
+
Rules that decide whether a finding is usable:
|
|
110
|
+
- Cite ONLY facts a tool returned in this conversation. A finding citing an event, frame, stream or
|
|
111
|
+
detection that no tool returned is DROPPED. A finding with no citations is dropped.
|
|
112
|
+
- "reference" is the exact identifier from the tool output: an event_id for kind=event, a frame
|
|
113
|
+
number for kind=frame, a stream index for kind=stream, a rule id for kind=detection.
|
|
114
|
+
- Separate what the evidence shows from what you infer. Phrase inference as possibility
|
|
115
|
+
("may indicate", "is consistent with"), never as certainty.
|
|
116
|
+
- Prefer the case's own detections and entities; interpret them, do not invent new ones.
|
|
117
|
+
- If you find nothing in your scope, return {"action": "findings", "findings": []}. That is a
|
|
118
|
+
correct result, not a failure. Do not guess to fill the list.
|
|
119
|
+
- Retrieve before you report. You have a limited number of tool calls."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _system_prompt(role):
|
|
123
|
+
return (
|
|
124
|
+
f"You are the {role.name} analyst on a digital forensics and incident response (DFIR) "
|
|
125
|
+
f"investigation team examining ONE case. You cannot see the evidence directly; you retrieve "
|
|
126
|
+
f"it with tools and may only claim what those tools return.\n\n"
|
|
127
|
+
f"Your focus: {role.mission}\n\n"
|
|
128
|
+
f"{_PROTOCOL}\n\n"
|
|
129
|
+
f"You are not the investigator. Your findings are a starting point for their review."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate_findings(raw_findings, ledger):
|
|
134
|
+
"""Keep only well-formed findings whose citations were all actually
|
|
135
|
+
retrieved (checked against the ledger, exactly as chat.py checks an
|
|
136
|
+
answer). Returns (kept_findings, dropped_count)."""
|
|
137
|
+
kept, dropped = [], 0
|
|
138
|
+
for item in raw_findings or []:
|
|
139
|
+
try:
|
|
140
|
+
finding = AgentFinding.model_validate(item)
|
|
141
|
+
except ValidationError:
|
|
142
|
+
dropped += 1
|
|
143
|
+
continue
|
|
144
|
+
if not finding.citations:
|
|
145
|
+
dropped += 1
|
|
146
|
+
continue
|
|
147
|
+
if all(ledger.contains(c) for c in finding.citations):
|
|
148
|
+
kept.append(finding)
|
|
149
|
+
else:
|
|
150
|
+
dropped += 1
|
|
151
|
+
return kept, dropped
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_role(role, case_dir, provider="anthropic", api_key=None, model=None, base_url=None, call=None):
|
|
155
|
+
"""Run one role over `case_dir` and return its cited findings.
|
|
156
|
+
|
|
157
|
+
`call(system_prompt, user_prompt) -> dict` overrides the provider call, so
|
|
158
|
+
the loop can be tested against scripted model behaviour - including a model
|
|
159
|
+
that cites evidence no tool returned, which must be dropped.
|
|
160
|
+
"""
|
|
161
|
+
if call is None:
|
|
162
|
+
|
|
163
|
+
def call(system_prompt, user_prompt):
|
|
164
|
+
return ai_assistant.call_model(
|
|
165
|
+
system_prompt, user_prompt, provider=provider, api_key=api_key, model=model, base_url=base_url
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
ledger = Ledger()
|
|
169
|
+
tools = CaseTools(case_dir, ledger)
|
|
170
|
+
system_prompt = _system_prompt(role)
|
|
171
|
+
transcript = [f"Case directory: {case_dir}", f"Tools available to you:\n{role.catalogue()}"]
|
|
172
|
+
tool_calls = []
|
|
173
|
+
|
|
174
|
+
for step in range(role.max_steps):
|
|
175
|
+
remaining = role.max_steps - step
|
|
176
|
+
prompt = "\n\n".join(transcript) + (
|
|
177
|
+
f"\n\nYou have {remaining} tool call(s) left before you must report findings."
|
|
178
|
+
if remaining > 1
|
|
179
|
+
else "\n\nThis is your LAST turn. Report findings now from what you already retrieved."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
raw = call(system_prompt, prompt)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return RoleResult(role.name, role.slug, note=f"provider failed: {e}")
|
|
186
|
+
|
|
187
|
+
if not isinstance(raw, dict):
|
|
188
|
+
transcript.append('Your reply was not a JSON object. Reply with one JSON object.')
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
action = raw.get("action")
|
|
192
|
+
|
|
193
|
+
if action == "tool":
|
|
194
|
+
name = raw.get("tool")
|
|
195
|
+
arguments = raw.get("arguments") or {}
|
|
196
|
+
if name not in role.tools:
|
|
197
|
+
transcript.append(
|
|
198
|
+
f"Tool {name!r} is outside your scope. You may only call: {', '.join(role.tools)}."
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
result = _run_tool(tools, name, arguments)
|
|
203
|
+
rows = len(result) if isinstance(result, list) else 1
|
|
204
|
+
tool_calls.append({"tool": name, "arguments": arguments, "rows": rows})
|
|
205
|
+
transcript.append(
|
|
206
|
+
f"You called {name}({json.dumps(arguments, default=str)}).\nResult:\n{_render(result)}"
|
|
207
|
+
)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
tool_calls.append({"tool": str(name), "arguments": arguments, "error": str(e)})
|
|
210
|
+
transcript.append(f"You called {name}({json.dumps(arguments, default=str)}).\nError: {e}")
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if action == "findings":
|
|
214
|
+
kept, dropped = _validate_findings(raw.get("findings"), ledger)
|
|
215
|
+
note = None
|
|
216
|
+
if not kept:
|
|
217
|
+
note = "no findings in scope" if not dropped else "all proposed findings were unciteable and dropped"
|
|
218
|
+
elif dropped:
|
|
219
|
+
note = f"{dropped} proposed finding(s) dropped as unciteable"
|
|
220
|
+
return RoleResult(role.name, role.slug, findings=kept, tool_calls=tool_calls, note=note)
|
|
221
|
+
|
|
222
|
+
transcript.append(
|
|
223
|
+
'Your reply had no valid "action". Reply with {"action":"tool",...} or {"action":"findings",...}.'
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return RoleResult(role.name, role.slug, tool_calls=tool_calls, note="no findings within the step budget")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The Lead Investigator: run the specialist roles over one case and merge
|
|
2
|
+
their cited findings into a single, ranked investigation.
|
|
3
|
+
|
|
4
|
+
The coordinator does no analysis of its own - that would be an ungrounded
|
|
5
|
+
opinion on top of grounded findings. It only:
|
|
6
|
+
1. dispatches the requested roles (each already grounded, see base.run_role),
|
|
7
|
+
2. merges findings that rest on the same evidence (two roles flagging the same
|
|
8
|
+
event become one, corroborated finding), and
|
|
9
|
+
3. ranks the result by severity and corroboration.
|
|
10
|
+
|
|
11
|
+
Every merged finding keeps the union of its citations and the list of roles that
|
|
12
|
+
reported it, so nothing loses its evidence trail.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from netforensicai.agents.base import RoleResult, run_role
|
|
21
|
+
from netforensicai.agents.roles import all_roles
|
|
22
|
+
|
|
23
|
+
_SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1, "info": 0}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sev_rank(severity):
|
|
27
|
+
return _SEVERITY_RANK.get(str(severity).lower(), 0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MergedFinding(BaseModel):
|
|
31
|
+
"""A finding after merging: the strongest statement of it, the union of the
|
|
32
|
+
evidence it rests on, and every role that reported it."""
|
|
33
|
+
|
|
34
|
+
title: str
|
|
35
|
+
severity: str
|
|
36
|
+
confidence: str
|
|
37
|
+
assessment: str
|
|
38
|
+
citations: list # list[Citation], but kept loose to avoid a re-import cycle
|
|
39
|
+
reported_by: List[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TeamResult:
|
|
44
|
+
"""The whole team's output: each role's raw result, and the merged, ranked
|
|
45
|
+
findings across all of them."""
|
|
46
|
+
|
|
47
|
+
role_results: List[RoleResult] = field(default_factory=list)
|
|
48
|
+
findings: List[MergedFinding] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def to_dict(self):
|
|
51
|
+
return {
|
|
52
|
+
"role_results": [r.to_dict() for r in self.role_results],
|
|
53
|
+
"findings": [f.model_dump() for f in self.findings],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cite_keys(finding):
|
|
58
|
+
return {(c.kind, c.evidence_id, str(c.reference)) for c in finding.citations}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _merge_group(members):
|
|
62
|
+
"""One merged finding from a group of (role_slug, finding) that share
|
|
63
|
+
evidence. The lead is the highest-severity member; severity/confidence take
|
|
64
|
+
the strongest across the group; citations are unioned."""
|
|
65
|
+
members = sorted(members, key=lambda m: _sev_rank(m[1].severity), reverse=True)
|
|
66
|
+
_lead_slug, lead = members[0]
|
|
67
|
+
severity = max((m[1].severity for m in members), key=_sev_rank)
|
|
68
|
+
confidence = max((m[1].confidence for m in members), key=lambda c: _SEVERITY_RANK.get(str(c).lower(), 0))
|
|
69
|
+
|
|
70
|
+
reported_by = []
|
|
71
|
+
for slug, _ in members:
|
|
72
|
+
if slug not in reported_by:
|
|
73
|
+
reported_by.append(slug)
|
|
74
|
+
|
|
75
|
+
# Union the citations, de-duplicated on the (kind, evidence_id, reference) key.
|
|
76
|
+
citations, seen = [], set()
|
|
77
|
+
for _, finding in members:
|
|
78
|
+
for c in finding.citations:
|
|
79
|
+
key = (c.kind, c.evidence_id, str(c.reference))
|
|
80
|
+
if key not in seen:
|
|
81
|
+
seen.add(key)
|
|
82
|
+
citations.append(c)
|
|
83
|
+
|
|
84
|
+
if len(members) == 1:
|
|
85
|
+
assessment = lead.assessment
|
|
86
|
+
else:
|
|
87
|
+
# Attribute each distinct assessment to the role(s) that made it.
|
|
88
|
+
parts, seen_text = [], set()
|
|
89
|
+
for slug, finding in members:
|
|
90
|
+
text = finding.assessment.strip()
|
|
91
|
+
if text and text not in seen_text:
|
|
92
|
+
seen_text.add(text)
|
|
93
|
+
parts.append(f"[{slug}] {text}")
|
|
94
|
+
assessment = " ".join(parts)
|
|
95
|
+
|
|
96
|
+
return MergedFinding(
|
|
97
|
+
title=lead.title,
|
|
98
|
+
severity=severity,
|
|
99
|
+
confidence=confidence,
|
|
100
|
+
assessment=assessment,
|
|
101
|
+
citations=citations,
|
|
102
|
+
reported_by=reported_by,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def merge_findings(role_results):
|
|
107
|
+
"""Group findings that share any citation, merge each group, and rank the
|
|
108
|
+
result: most severe first, then most-corroborated (reported by more roles),
|
|
109
|
+
then most evidence."""
|
|
110
|
+
items = [(r.slug, f) for r in role_results for f in r.findings]
|
|
111
|
+
|
|
112
|
+
groups = [] # each: [keys_set, [(slug, finding), ...]]
|
|
113
|
+
for slug, finding in items:
|
|
114
|
+
keys = _cite_keys(finding)
|
|
115
|
+
target = next((g for g in groups if g[0] & keys), None)
|
|
116
|
+
if target is not None:
|
|
117
|
+
target[0] |= keys
|
|
118
|
+
target[1].append((slug, finding))
|
|
119
|
+
else:
|
|
120
|
+
groups.append([set(keys), [(slug, finding)]])
|
|
121
|
+
|
|
122
|
+
merged = [_merge_group(members) for _keys, members in groups]
|
|
123
|
+
merged.sort(key=lambda f: (_sev_rank(f.severity), len(f.reported_by), len(f.citations)), reverse=True)
|
|
124
|
+
return merged
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def investigate(
|
|
128
|
+
case_dir,
|
|
129
|
+
roles=None,
|
|
130
|
+
provider="anthropic",
|
|
131
|
+
api_key=None,
|
|
132
|
+
model=None,
|
|
133
|
+
base_url=None,
|
|
134
|
+
call_for=None,
|
|
135
|
+
):
|
|
136
|
+
"""Run `roles` (default: all registered roles) over `case_dir` and return a
|
|
137
|
+
TeamResult with each role's findings and the merged, ranked set.
|
|
138
|
+
|
|
139
|
+
`call_for(role) -> call` overrides the provider call per role, so the team
|
|
140
|
+
can be tested against scripted model behaviour. In production it is None and
|
|
141
|
+
each role uses the shared provider settings.
|
|
142
|
+
"""
|
|
143
|
+
roles = roles if roles is not None else all_roles()
|
|
144
|
+
role_results = []
|
|
145
|
+
for role in roles:
|
|
146
|
+
call = call_for(role) if call_for is not None else None
|
|
147
|
+
role_results.append(
|
|
148
|
+
run_role(role, case_dir, provider=provider, api_key=api_key, model=model, base_url=base_url, call=call)
|
|
149
|
+
)
|
|
150
|
+
return TeamResult(role_results=role_results, findings=merge_findings(role_results))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The specialist roles of the investigation team.
|
|
2
|
+
|
|
3
|
+
Each role is a `Role` from base.py: a mission that scopes its system prompt and
|
|
4
|
+
the subset of read-only case tools it may call. Missions tell the model WHAT to
|
|
5
|
+
look for in its domain and to prefer the case's own deterministic detections and
|
|
6
|
+
entities over anything it might infer - the grounding contract (cite or be
|
|
7
|
+
dropped) is enforced by the runner, not by the prose here.
|
|
8
|
+
|
|
9
|
+
Phase 2 ships the two roles that map to the two evidence domains the tool
|
|
10
|
+
normalizes richly today: network (pcap / Suricata) and host (EVTX / Sysmon).
|
|
11
|
+
The remaining roles (malware/IOC, threat-intel/correlation, reporter) are added
|
|
12
|
+
in a later phase; new roles just register here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from netforensicai.agents.base import Role
|
|
16
|
+
|
|
17
|
+
NETWORK = Role(
|
|
18
|
+
name="Network Forensics",
|
|
19
|
+
slug="network",
|
|
20
|
+
mission=(
|
|
21
|
+
"Reconstruct what happened on the wire. Concentrate on: connections to "
|
|
22
|
+
"unusual ports or external hosts; DNS lookups (especially to rare or "
|
|
23
|
+
"cheap-TLD domains); TLS SNI and HTTP hostnames/URLs; regular-interval "
|
|
24
|
+
"beaconing that looks like command-and-control; large or one-way "
|
|
25
|
+
"transfers that look like exfiltration; and any credential seen crossing "
|
|
26
|
+
"the network in the clear. Start from the case's own detections and "
|
|
27
|
+
"entities and confirm them against the events, streams and packets; only "
|
|
28
|
+
"then add what they imply. Do not speculate about host activity you "
|
|
29
|
+
"cannot see in the network evidence."
|
|
30
|
+
),
|
|
31
|
+
tools=(
|
|
32
|
+
"list_detections",
|
|
33
|
+
"list_entities",
|
|
34
|
+
"search_events",
|
|
35
|
+
"protocol_summary",
|
|
36
|
+
"list_streams",
|
|
37
|
+
"follow_stream",
|
|
38
|
+
"search_packets",
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
HOST = Role(
|
|
43
|
+
name="Host & Endpoint (DFIR)",
|
|
44
|
+
slug="host",
|
|
45
|
+
mission=(
|
|
46
|
+
"Reconstruct what happened on the endpoint from Windows Event Log / "
|
|
47
|
+
"Sysmon events. Concentrate on: process creation and suspicious "
|
|
48
|
+
"parent-child chains; files written or dropped, especially executables "
|
|
49
|
+
"and script or key material; persistence; account logons and failed-"
|
|
50
|
+
"logon bursts; and signs of credential access or lateral movement. Start "
|
|
51
|
+
"from the case's own detections and host entities (users, hosts, "
|
|
52
|
+
"processes, files) and confirm them against the events; only then add "
|
|
53
|
+
"what they imply. Do not speculate about network activity you cannot see "
|
|
54
|
+
"in the host evidence."
|
|
55
|
+
),
|
|
56
|
+
tools=(
|
|
57
|
+
"list_detections",
|
|
58
|
+
"list_entities",
|
|
59
|
+
"search_events",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Registry, in dispatch order. Later phases append their roles here.
|
|
64
|
+
ROLES = {role.slug: role for role in (NETWORK, HOST)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def all_roles():
|
|
68
|
+
"""Every registered role, in dispatch order."""
|
|
69
|
+
return list(ROLES.values())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_role(slug):
|
|
73
|
+
"""Look up a role by slug, or None if there is no such role."""
|
|
74
|
+
return ROLES.get(slug)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_roles(slugs=None):
|
|
78
|
+
"""The roles named by `slugs` (a list/iterable), or all of them when None.
|
|
79
|
+
|
|
80
|
+
Raises KeyError naming the first unknown slug, so a CLI can report it.
|
|
81
|
+
"""
|
|
82
|
+
if slugs is None:
|
|
83
|
+
return all_roles()
|
|
84
|
+
resolved = []
|
|
85
|
+
for slug in slugs:
|
|
86
|
+
role = ROLES.get(slug)
|
|
87
|
+
if role is None:
|
|
88
|
+
raise KeyError(slug)
|
|
89
|
+
resolved.append(role)
|
|
90
|
+
return resolved
|