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.
Files changed (58) hide show
  1. netforensicai/__init__.py +1 -0
  2. netforensicai/agents/__init__.py +27 -0
  3. netforensicai/agents/base.py +226 -0
  4. netforensicai/agents/coordinator.py +150 -0
  5. netforensicai/agents/roles.py +90 -0
  6. netforensicai/cli.py +2421 -0
  7. netforensicai/core/__init__.py +0 -0
  8. netforensicai/core/ai_assistant.py +534 -0
  9. netforensicai/core/attack.py +93 -0
  10. netforensicai/core/audit.py +157 -0
  11. netforensicai/core/capture.py +705 -0
  12. netforensicai/core/case.py +256 -0
  13. netforensicai/core/chat.py +560 -0
  14. netforensicai/core/config.py +179 -0
  15. netforensicai/core/correlation.py +275 -0
  16. netforensicai/core/ctf.py +398 -0
  17. netforensicai/core/detections.py +807 -0
  18. netforensicai/core/diagnostics.py +133 -0
  19. netforensicai/core/entities.py +122 -0
  20. netforensicai/core/event.py +152 -0
  21. netforensicai/core/evidence.py +220 -0
  22. netforensicai/core/export.py +167 -0
  23. netforensicai/core/finding.py +216 -0
  24. netforensicai/core/investigate.py +114 -0
  25. netforensicai/core/ioc.py +730 -0
  26. netforensicai/core/narrative.py +360 -0
  27. netforensicai/core/pipeline.py +115 -0
  28. netforensicai/core/report.py +653 -0
  29. netforensicai/core/search.py +350 -0
  30. netforensicai/core/store.py +901 -0
  31. netforensicai/core/streams.py +304 -0
  32. netforensicai/core/threat_intel.py +100 -0
  33. netforensicai/core/timeline.py +162 -0
  34. netforensicai/dashboard.py +41 -0
  35. netforensicai/integrations/__init__.py +9 -0
  36. netforensicai/integrations/wireshark.py +576 -0
  37. netforensicai/intel/__init__.py +0 -0
  38. netforensicai/intel/virustotal.py +101 -0
  39. netforensicai/parsers/__init__.py +38 -0
  40. netforensicai/parsers/base.py +70 -0
  41. netforensicai/parsers/credentials.py +254 -0
  42. netforensicai/parsers/evtx.py +243 -0
  43. netforensicai/parsers/generic.py +189 -0
  44. netforensicai/parsers/pcap.py +866 -0
  45. netforensicai/parsers/pcap_engine.py +204 -0
  46. netforensicai/parsers/pcap_tshark.py +804 -0
  47. netforensicai/parsers/suricata.py +232 -0
  48. netforensicai/web/__init__.py +0 -0
  49. netforensicai/web/app.py +1220 -0
  50. netforensicai/web/static/app.js +3627 -0
  51. netforensicai/web/static/index.html +63 -0
  52. netforensicai/web/static/style.css +751 -0
  53. netforensicai-0.3.0.dist-info/METADATA +418 -0
  54. netforensicai-0.3.0.dist-info/RECORD +58 -0
  55. netforensicai-0.3.0.dist-info/WHEEL +5 -0
  56. netforensicai-0.3.0.dist-info/entry_points.txt +2 -0
  57. netforensicai-0.3.0.dist-info/licenses/LICENSE +21 -0
  58. 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