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.
@@ -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