soc-agent-toolkit 0.1.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.
- soc_agent_toolkit/__init__.py +19 -0
- soc_agent_toolkit/agent.py +209 -0
- soc_agent_toolkit/cache.py +98 -0
- soc_agent_toolkit/cli.py +580 -0
- soc_agent_toolkit/config.py +101 -0
- soc_agent_toolkit/enrichment.py +295 -0
- soc_agent_toolkit/enrichment_async.py +168 -0
- soc_agent_toolkit/logging_setup.py +39 -0
- soc_agent_toolkit/mitre.py +208 -0
- soc_agent_toolkit/models.py +110 -0
- soc_agent_toolkit/parser.py +251 -0
- soc_agent_toolkit/schemas.py +145 -0
- soc_agent_toolkit/summarizer.py +114 -0
- soc_agent_toolkit/tests/__init__.py +0 -0
- soc_agent_toolkit/tests/test_models.py +57 -0
- soc_agent_toolkit/tests/test_parser.py +80 -0
- soc_agent_toolkit/tests/test_triage.py +92 -0
- soc_agent_toolkit/triage.py +198 -0
- soc_agent_toolkit/tui.py +253 -0
- soc_agent_toolkit-0.1.0.dist-info/METADATA +462 -0
- soc_agent_toolkit-0.1.0.dist-info/RECORD +25 -0
- soc_agent_toolkit-0.1.0.dist-info/WHEEL +5 -0
- soc_agent_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- soc_agent_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- soc_agent_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
soc_agent_toolkit
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
A modular Python toolkit of callable functions ("tools") for a SOC Analyst
|
|
6
|
+
AI Agent. Each module exposes plain Python functions that can be:
|
|
7
|
+
|
|
8
|
+
1. Called directly from Python / a CLI (see cli.py), or
|
|
9
|
+
2. Exposed as Claude tool-use functions (see schemas.py + agent.py)
|
|
10
|
+
|
|
11
|
+
Pipeline covered (end-to-end):
|
|
12
|
+
raw alerts/logs -> parse -> dedup & prioritize (triage)
|
|
13
|
+
-> enrich IOCs (reputation + MITRE ATT&CK)
|
|
14
|
+
-> AI-generated incident summary
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from . import parser, triage, enrichment, mitre, summarizer, schemas
|
|
18
|
+
|
|
19
|
+
__all__ = ["parser", "triage", "enrichment", "mitre", "summarizer", "schemas"]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent.py — Example SOC Analyst Agent loop.
|
|
3
|
+
|
|
4
|
+
Two ways to use this toolkit:
|
|
5
|
+
|
|
6
|
+
1. DIRECT PIPELINE (no LLM tool-calling, fastest/cheapest — see `run_pipeline`):
|
|
7
|
+
parse -> mitre-tag -> enrich -> triage -> summarize (Claude only used for
|
|
8
|
+
the final natural-language summary).
|
|
9
|
+
|
|
10
|
+
2. AGENTIC LOOP (`run_agent`): hands the raw alert text + a task prompt to
|
|
11
|
+
Claude with the full TOOLS list and lets the model decide which tools to
|
|
12
|
+
call and in what order.
|
|
13
|
+
|
|
14
|
+
Set ANTHROPIC_API_KEY in the environment before using run_agent() or the
|
|
15
|
+
AI-generated summary in run_pipeline().
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import config, enrichment, mitre, parser, summarizer, triage
|
|
25
|
+
from .logging_setup import get_logger
|
|
26
|
+
from .schemas import TOOLS, dispatch_tool_json
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import anthropic
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
anthropic = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_pipeline(
|
|
37
|
+
raw_input: str,
|
|
38
|
+
asset_criticality: dict[str, int] | None = None,
|
|
39
|
+
use_stix_mitre: bool = False,
|
|
40
|
+
progress_callback: Any | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Deterministic end-to-end run:
|
|
43
|
+
parse -> MITRE tag -> enrich -> triage -> summarize.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def report_progress(message: str) -> None:
|
|
47
|
+
if progress_callback is not None:
|
|
48
|
+
progress_callback(message)
|
|
49
|
+
|
|
50
|
+
logger.info("Starting SOC pipeline run")
|
|
51
|
+
|
|
52
|
+
report_progress("Parsing alerts...")
|
|
53
|
+
alerts = parser.parse_alerts(raw_input)
|
|
54
|
+
|
|
55
|
+
report_progress("Mapping MITRE ATT&CK...")
|
|
56
|
+
alerts = [
|
|
57
|
+
mitre.enrich_alert_with_mitre(
|
|
58
|
+
alert,
|
|
59
|
+
use_stix=use_stix_mitre,
|
|
60
|
+
)
|
|
61
|
+
for alert in alerts
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
report_progress("Enriching IOCs...")
|
|
65
|
+
alerts = [
|
|
66
|
+
enrichment.enrich_alert(alert)
|
|
67
|
+
for alert in alerts
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
report_progress("Running triage...")
|
|
71
|
+
triaged = triage.triage_alerts(
|
|
72
|
+
alerts,
|
|
73
|
+
asset_criticality,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
report_progress("Generating incident summary...")
|
|
77
|
+
summary = summarizer.summarize_incident(triaged)
|
|
78
|
+
|
|
79
|
+
report_progress("Analysis complete")
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
"Pipeline run complete: %d alert(s) triaged",
|
|
83
|
+
len(triaged),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"alerts": triaged,
|
|
88
|
+
"summary": summary,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_agent(user_task: str, max_turns: int = 6) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Let Claude drive the tool-calling loop itself.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
"Here are today's alerts: <raw text>. Triage them, enrich any IOCs,
|
|
98
|
+
and give me a summary with next actions."
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
102
|
+
|
|
103
|
+
if not api_key or anthropic is None:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"run_agent() requires ANTHROPIC_API_KEY and the `anthropic` package. "
|
|
106
|
+
"Use run_pipeline() for the offline-friendly, deterministic path instead."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
110
|
+
|
|
111
|
+
messages: list[dict[str, Any]] = [
|
|
112
|
+
{
|
|
113
|
+
"role": "user",
|
|
114
|
+
"content": user_task,
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
for turn in range(max_turns):
|
|
119
|
+
logger.debug(
|
|
120
|
+
"Agent turn %d/%d",
|
|
121
|
+
turn + 1,
|
|
122
|
+
max_turns,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
response = client.messages.create(
|
|
126
|
+
model=config.ANTHROPIC_MODEL,
|
|
127
|
+
max_tokens=2000,
|
|
128
|
+
tools=TOOLS,
|
|
129
|
+
messages=messages,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
messages.append(
|
|
133
|
+
{
|
|
134
|
+
"role": "assistant",
|
|
135
|
+
"content": response.content,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if response.stop_reason != "tool_use":
|
|
140
|
+
return "\n".join(
|
|
141
|
+
block.text
|
|
142
|
+
for block in response.content
|
|
143
|
+
if getattr(block, "type", None) == "text"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
tool_results = []
|
|
147
|
+
|
|
148
|
+
for block in response.content:
|
|
149
|
+
if getattr(block, "type", None) == "tool_use":
|
|
150
|
+
logger.info(
|
|
151
|
+
"Agent calling tool: %s(%s)",
|
|
152
|
+
block.name,
|
|
153
|
+
block.input,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
result_json = dispatch_tool_json(
|
|
158
|
+
block.name,
|
|
159
|
+
block.input,
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc: # noqa: BLE001
|
|
162
|
+
logger.exception(
|
|
163
|
+
"Tool %s failed",
|
|
164
|
+
block.name,
|
|
165
|
+
)
|
|
166
|
+
result_json = json.dumps(
|
|
167
|
+
{
|
|
168
|
+
"error": str(exc),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
tool_results.append(
|
|
173
|
+
{
|
|
174
|
+
"type": "tool_result",
|
|
175
|
+
"tool_use_id": block.id,
|
|
176
|
+
"content": result_json,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
messages.append(
|
|
181
|
+
{
|
|
182
|
+
"role": "user",
|
|
183
|
+
"content": tool_results,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
logger.warning(
|
|
188
|
+
"Agent reached max_turns=%d without a final answer",
|
|
189
|
+
max_turns,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
"Reached max_turns without a final answer — "
|
|
194
|
+
"inspect `messages` for partial progress."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
sample_alerts = """
|
|
200
|
+
CEF:0|PaloAlto|NGFW|10.1|1001|Brute Force Login Attempt|8|src=203.0.113.5 dst=10.0.0.12 duser=admin msg=Multiple failed SSH logins
|
|
201
|
+
CEF:0|PaloAlto|NGFW|10.1|1002|Possible C2 Beacon|7|src=10.0.0.12 dst=198.51.100.9 msg=Periodic beacon to external host
|
|
202
|
+
""".strip()
|
|
203
|
+
|
|
204
|
+
result = run_pipeline(
|
|
205
|
+
sample_alerts,
|
|
206
|
+
asset_criticality={"10.0.0.12": 15},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
print(result["summary"])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cache.py — Simple TTL cache for enrichment lookups.
|
|
3
|
+
|
|
4
|
+
Default backend is a process-local in-memory dict (zero setup, fine for a
|
|
5
|
+
single CLI run or a single agent process). If SOC_REDIS_URL is set, uses
|
|
6
|
+
Redis instead so a cache can be shared across multiple agent workers/processes.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from .cache import get_cache
|
|
10
|
+
cache = get_cache()
|
|
11
|
+
hit = cache.get("ip:1.2.3.4")
|
|
12
|
+
if hit is None:
|
|
13
|
+
hit = do_expensive_lookup()
|
|
14
|
+
cache.set("ip:1.2.3.4", hit, ttl_seconds=3600)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
from .logging_setup import get_logger
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InMemoryTTLCache:
|
|
30
|
+
"""Process-local cache: dict of key -> (expires_at_epoch, value)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._store: dict[str, tuple[float, Any]] = {}
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> Any | None:
|
|
36
|
+
entry = self._store.get(key)
|
|
37
|
+
if entry is None:
|
|
38
|
+
return None
|
|
39
|
+
expires_at, value = entry
|
|
40
|
+
if time.time() >= expires_at:
|
|
41
|
+
del self._store[key]
|
|
42
|
+
return None
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
|
46
|
+
self._store[key] = (time.time() + ttl_seconds, value)
|
|
47
|
+
|
|
48
|
+
def clear(self) -> None:
|
|
49
|
+
self._store.clear()
|
|
50
|
+
|
|
51
|
+
def __len__(self) -> int:
|
|
52
|
+
return len(self._store)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RedisTTLCache:
|
|
56
|
+
"""Thin wrapper around redis-py giving the same get/set interface, JSON-serialized."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, url: str) -> None:
|
|
59
|
+
import redis # imported lazily so redis isn't a hard dependency
|
|
60
|
+
|
|
61
|
+
self._client = redis.Redis.from_url(url)
|
|
62
|
+
|
|
63
|
+
def get(self, key: str) -> Any | None:
|
|
64
|
+
raw = self._client.get(key)
|
|
65
|
+
if raw is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(raw)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
|
73
|
+
self._client.set(key, json.dumps(value, default=str), ex=ttl_seconds)
|
|
74
|
+
|
|
75
|
+
def clear(self) -> None:
|
|
76
|
+
logger.warning("RedisTTLCache.clear() is a no-op by design (would affect shared cache); flush manually if needed.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_CACHE_INSTANCE: InMemoryTTLCache | RedisTTLCache | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_cache() -> InMemoryTTLCache | RedisTTLCache:
|
|
83
|
+
"""Return the process-wide cache instance, choosing backend based on config.REDIS_URL."""
|
|
84
|
+
global _CACHE_INSTANCE
|
|
85
|
+
if _CACHE_INSTANCE is not None:
|
|
86
|
+
return _CACHE_INSTANCE
|
|
87
|
+
|
|
88
|
+
if config.REDIS_URL:
|
|
89
|
+
try:
|
|
90
|
+
_CACHE_INSTANCE = RedisTTLCache(config.REDIS_URL)
|
|
91
|
+
logger.info("Using Redis cache backend at %s", config.REDIS_URL)
|
|
92
|
+
return _CACHE_INSTANCE
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("Failed to connect to Redis; falling back to in-memory cache")
|
|
95
|
+
|
|
96
|
+
_CACHE_INSTANCE = InMemoryTTLCache()
|
|
97
|
+
logger.info("Using in-memory cache backend")
|
|
98
|
+
return _CACHE_INSTANCE
|