attackmap 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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +278 -0
- attackmap/analyzer_contracts.py +104 -0
- attackmap/analyzers.py +561 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +140 -0
- attackmap/cli.py +185 -0
- attackmap/context_pack.py +82 -0
- attackmap/control_model.py +272 -0
- attackmap/defensive_review.py +597 -0
- attackmap/detection_opportunities.py +343 -0
- attackmap/graph.py +26 -0
- attackmap/insights.py +702 -0
- attackmap/llm_review.py +346 -0
- attackmap/models.py +352 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +121 -0
- attackmap/report.py +74 -0
- attackmap/review_eval.py +281 -0
- attackmap/review_json.py +300 -0
- attackmap/review_prompts.py +230 -0
- attackmap/scanner.py +446 -0
- attackmap/sdk/__init__.py +41 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/models.py +29 -0
- attackmap/security_overlay.py +69 -0
- attackmap/threat_model.py +919 -0
- attackmap-0.1.0.dist-info/METADATA +257 -0
- attackmap-0.1.0.dist-info/RECORD +33 -0
- attackmap-0.1.0.dist-info/WHEEL +5 -0
- attackmap-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap-0.1.0.dist-info/top_level.txt +1 -0
attackmap/llm_review.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Anthropic-powered narrative review generator.
|
|
2
|
+
|
|
3
|
+
Wraps the existing prompt scaffolding (`review_prompts.render_review_prompts`)
|
|
4
|
+
in a real Claude call so users can produce a story-bearing defensive review
|
|
5
|
+
without copy-pasting JSON into a chat client.
|
|
6
|
+
|
|
7
|
+
Auth resolution (in order):
|
|
8
|
+
1. Explicit `client=` (tests / programmatic override)
|
|
9
|
+
2. `ANTHROPIC_API_KEY` env → SDK with api_key (per-token API billing)
|
|
10
|
+
3. `ANTHROPIC_AUTH_TOKEN` env → SDK with auth_token (OAuth bearer)
|
|
11
|
+
4. `claude` CLI on PATH → shell out to `claude -p --output-format=json`
|
|
12
|
+
(uses whatever auth `claude login` configured —
|
|
13
|
+
typically the user's Pro/Max subscription)
|
|
14
|
+
|
|
15
|
+
The user can force a backend via the `backend` argument or the CLI's
|
|
16
|
+
`--llm-backend` flag; "auto" walks the order above and picks the first one
|
|
17
|
+
that resolves.
|
|
18
|
+
|
|
19
|
+
Optional dependency: `pip install attackmap[llm]` installs the anthropic SDK.
|
|
20
|
+
The CLI backend only requires the `claude` binary on PATH (no SDK install).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
from .models import AttackPath, AttackSurface, Finding, ScanResult
|
|
33
|
+
from .review_prompts import render_review_prompts
|
|
34
|
+
|
|
35
|
+
DEFAULT_MODEL = "claude-opus-4-7"
|
|
36
|
+
DEFAULT_EFFORT: Literal["low", "medium", "high", "xhigh", "max"] = "high"
|
|
37
|
+
DEFAULT_MAX_TOKENS = 32000
|
|
38
|
+
CLAUDE_CLI_TIMEOUT_SECONDS = 600
|
|
39
|
+
|
|
40
|
+
LlmBackend = Literal["auto", "api", "cli"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LlmReviewError(RuntimeError):
|
|
44
|
+
"""Raised when the LLM-backed review cannot be produced."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class LlmReviewResult:
|
|
49
|
+
markdown: str
|
|
50
|
+
model: str
|
|
51
|
+
stop_reason: str | None
|
|
52
|
+
usage: dict[str, int]
|
|
53
|
+
backend: Literal["api", "cli"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------- Auth + SDK backend ----------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_sdk_client(api_key: str | None, client: Any | None) -> tuple[Any, str]:
|
|
60
|
+
"""Return (anthropic client, auth_kind). auth_kind ∈ {'api_key', 'auth_token'}."""
|
|
61
|
+
if client is not None:
|
|
62
|
+
return client, "explicit"
|
|
63
|
+
try:
|
|
64
|
+
import anthropic
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise LlmReviewError(
|
|
67
|
+
"The anthropic SDK is not installed. Install with `pip install attackmap[llm]` "
|
|
68
|
+
"to use the API backend, or ensure the `claude` CLI is on PATH for the CLI backend."
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
72
|
+
if resolved_key:
|
|
73
|
+
return anthropic.Anthropic(api_key=resolved_key), "api_key"
|
|
74
|
+
|
|
75
|
+
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
76
|
+
if auth_token:
|
|
77
|
+
return anthropic.Anthropic(auth_token=auth_token), "auth_token"
|
|
78
|
+
|
|
79
|
+
raise LlmReviewError(
|
|
80
|
+
"No API credentials found. Set ANTHROPIC_API_KEY (per-token API billing) or "
|
|
81
|
+
"ANTHROPIC_AUTH_TOKEN (OAuth bearer), or install the `claude` CLI and run "
|
|
82
|
+
"`claude login` to use your Claude subscription."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_text(blocks: list[Any]) -> str:
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
for block in blocks:
|
|
89
|
+
if getattr(block, "type", None) == "text":
|
|
90
|
+
text = getattr(block, "text", "")
|
|
91
|
+
if text:
|
|
92
|
+
parts.append(text)
|
|
93
|
+
return "\n".join(parts).strip()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _usage_dict(usage: Any) -> dict[str, int]:
|
|
97
|
+
out: dict[str, int] = {}
|
|
98
|
+
for field in (
|
|
99
|
+
"input_tokens",
|
|
100
|
+
"output_tokens",
|
|
101
|
+
"cache_creation_input_tokens",
|
|
102
|
+
"cache_read_input_tokens",
|
|
103
|
+
):
|
|
104
|
+
value = getattr(usage, field, None)
|
|
105
|
+
if isinstance(value, int):
|
|
106
|
+
out[field] = value
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_via_sdk(
|
|
111
|
+
rendered_system: str,
|
|
112
|
+
rendered_user: str,
|
|
113
|
+
*,
|
|
114
|
+
model: str,
|
|
115
|
+
effort: str,
|
|
116
|
+
max_tokens: int,
|
|
117
|
+
api_key: str | None,
|
|
118
|
+
client: Any | None,
|
|
119
|
+
) -> LlmReviewResult:
|
|
120
|
+
sdk_client, _ = _resolve_sdk_client(api_key, client)
|
|
121
|
+
|
|
122
|
+
request_kwargs: dict[str, Any] = {
|
|
123
|
+
"model": model,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"system": [
|
|
126
|
+
{"type": "text", "text": rendered_system, "cache_control": {"type": "ephemeral"}}
|
|
127
|
+
],
|
|
128
|
+
"messages": [{"role": "user", "content": rendered_user}],
|
|
129
|
+
"thinking": {"type": "adaptive"},
|
|
130
|
+
"output_config": {"effort": effort},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with sdk_client.messages.stream(**request_kwargs) as stream:
|
|
135
|
+
final_message = stream.get_final_message()
|
|
136
|
+
except LlmReviewError:
|
|
137
|
+
raise
|
|
138
|
+
except Exception as exc: # pragma: no cover - surface SDK errors verbatim
|
|
139
|
+
raise LlmReviewError(f"Claude API call failed: {exc}") from exc
|
|
140
|
+
|
|
141
|
+
markdown = _extract_text(getattr(final_message, "content", []) or [])
|
|
142
|
+
if not markdown:
|
|
143
|
+
raise LlmReviewError("Claude returned no text content for the review.")
|
|
144
|
+
|
|
145
|
+
stop_reason = getattr(final_message, "stop_reason", None)
|
|
146
|
+
return LlmReviewResult(
|
|
147
|
+
markdown=markdown,
|
|
148
|
+
model=model,
|
|
149
|
+
stop_reason=stop_reason if isinstance(stop_reason, str) else None,
|
|
150
|
+
usage=_usage_dict(getattr(final_message, "usage", None)),
|
|
151
|
+
backend="api",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------- Claude CLI backend ----------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _claude_cli_available() -> bool:
|
|
159
|
+
return shutil.which("claude") is not None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_via_claude_cli(
|
|
163
|
+
rendered_system: str,
|
|
164
|
+
rendered_user: str,
|
|
165
|
+
*,
|
|
166
|
+
model: str,
|
|
167
|
+
runner: Any | None = None,
|
|
168
|
+
) -> LlmReviewResult:
|
|
169
|
+
"""Invoke `claude -p --output-format=json` and return the result.
|
|
170
|
+
|
|
171
|
+
The `runner` argument is only used by tests to inject a fake subprocess
|
|
172
|
+
runner; in production we go straight to subprocess.run.
|
|
173
|
+
"""
|
|
174
|
+
if runner is None and not _claude_cli_available():
|
|
175
|
+
raise LlmReviewError(
|
|
176
|
+
"`claude` CLI was not found on PATH. Install Claude Code (https://claude.com/claude-code) "
|
|
177
|
+
"and run `claude login`, or set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN to use the SDK backend."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
cmd = [
|
|
181
|
+
"claude",
|
|
182
|
+
"-p",
|
|
183
|
+
"--output-format=json",
|
|
184
|
+
"--model",
|
|
185
|
+
model,
|
|
186
|
+
"--system-prompt",
|
|
187
|
+
rendered_system,
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
if runner is None:
|
|
192
|
+
completed = subprocess.run(
|
|
193
|
+
cmd,
|
|
194
|
+
input=rendered_user,
|
|
195
|
+
text=True,
|
|
196
|
+
capture_output=True,
|
|
197
|
+
check=False,
|
|
198
|
+
timeout=CLAUDE_CLI_TIMEOUT_SECONDS,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
completed = runner(cmd, rendered_user)
|
|
202
|
+
except FileNotFoundError as exc:
|
|
203
|
+
raise LlmReviewError("`claude` CLI was not found on PATH.") from exc
|
|
204
|
+
except subprocess.TimeoutExpired as exc:
|
|
205
|
+
raise LlmReviewError(
|
|
206
|
+
f"`claude` CLI did not respond within {CLAUDE_CLI_TIMEOUT_SECONDS}s."
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
stdout = (completed.stdout or "").strip()
|
|
210
|
+
stderr = (completed.stderr or "").strip()
|
|
211
|
+
if completed.returncode != 0 and not stdout:
|
|
212
|
+
raise LlmReviewError(
|
|
213
|
+
f"`claude` CLI exited with status {completed.returncode}: {stderr or '<no stderr>'}"
|
|
214
|
+
)
|
|
215
|
+
if not stdout:
|
|
216
|
+
raise LlmReviewError("`claude` CLI produced no output.")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
payload = json.loads(stdout)
|
|
220
|
+
except json.JSONDecodeError as exc:
|
|
221
|
+
raise LlmReviewError(
|
|
222
|
+
f"`claude` CLI returned non-JSON output: {stdout[:200]}..."
|
|
223
|
+
) from exc
|
|
224
|
+
|
|
225
|
+
if payload.get("is_error"):
|
|
226
|
+
errors = payload.get("errors") or [payload.get("subtype") or "unknown error"]
|
|
227
|
+
raise LlmReviewError(f"`claude` CLI reported an error: {'; '.join(str(e) for e in errors)}")
|
|
228
|
+
|
|
229
|
+
markdown = payload.get("result")
|
|
230
|
+
if not isinstance(markdown, str) or not markdown.strip():
|
|
231
|
+
raise LlmReviewError("`claude` CLI returned no `result` text.")
|
|
232
|
+
|
|
233
|
+
usage_payload = payload.get("usage") or {}
|
|
234
|
+
usage: dict[str, int] = {}
|
|
235
|
+
for field in (
|
|
236
|
+
"input_tokens",
|
|
237
|
+
"output_tokens",
|
|
238
|
+
"cache_creation_input_tokens",
|
|
239
|
+
"cache_read_input_tokens",
|
|
240
|
+
):
|
|
241
|
+
value = usage_payload.get(field)
|
|
242
|
+
if isinstance(value, int):
|
|
243
|
+
usage[field] = value
|
|
244
|
+
|
|
245
|
+
cli_model = model
|
|
246
|
+
model_usage = payload.get("modelUsage")
|
|
247
|
+
if isinstance(model_usage, dict) and model_usage:
|
|
248
|
+
cli_model = next(iter(model_usage.keys()), model)
|
|
249
|
+
|
|
250
|
+
stop_reason = payload.get("stop_reason")
|
|
251
|
+
return LlmReviewResult(
|
|
252
|
+
markdown=markdown.strip(),
|
|
253
|
+
model=cli_model,
|
|
254
|
+
stop_reason=stop_reason if isinstance(stop_reason, str) else None,
|
|
255
|
+
usage=usage,
|
|
256
|
+
backend="cli",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------- Public entry point ----------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolve_backend(
|
|
264
|
+
backend: LlmBackend,
|
|
265
|
+
*,
|
|
266
|
+
api_key: str | None,
|
|
267
|
+
client: Any | None,
|
|
268
|
+
) -> Literal["api", "cli"]:
|
|
269
|
+
if backend == "api":
|
|
270
|
+
return "api"
|
|
271
|
+
if backend == "cli":
|
|
272
|
+
if not _claude_cli_available():
|
|
273
|
+
raise LlmReviewError(
|
|
274
|
+
"`--llm-backend cli` was requested but the `claude` CLI is not on PATH."
|
|
275
|
+
)
|
|
276
|
+
return "cli"
|
|
277
|
+
# auto
|
|
278
|
+
if client is not None or api_key:
|
|
279
|
+
return "api"
|
|
280
|
+
if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
|
281
|
+
return "api"
|
|
282
|
+
if _claude_cli_available():
|
|
283
|
+
return "cli"
|
|
284
|
+
raise LlmReviewError(
|
|
285
|
+
"No LLM backend available. Set ANTHROPIC_API_KEY, set ANTHROPIC_AUTH_TOKEN, "
|
|
286
|
+
"or install the `claude` CLI and run `claude login`."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def generate_llm_review(
|
|
291
|
+
scan: ScanResult,
|
|
292
|
+
attack_surfaces: list[AttackSurface],
|
|
293
|
+
findings: list[Finding],
|
|
294
|
+
attack_paths: list[AttackPath],
|
|
295
|
+
*,
|
|
296
|
+
model: str | None = None,
|
|
297
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None,
|
|
298
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
299
|
+
api_key: str | None = None,
|
|
300
|
+
client: Any | None = None,
|
|
301
|
+
backend: LlmBackend = "auto",
|
|
302
|
+
cli_runner: Any | None = None,
|
|
303
|
+
) -> LlmReviewResult:
|
|
304
|
+
"""Produce a narrative defensive review by calling Claude.
|
|
305
|
+
|
|
306
|
+
Resolves which backend to use (API SDK or `claude` CLI) based on available
|
|
307
|
+
auth, then runs the existing prompt pack through it. Streams the SDK path
|
|
308
|
+
so we never hit HTTP timeouts on long reviews. The CLI path runs synchronously
|
|
309
|
+
via `claude -p --output-format=json`.
|
|
310
|
+
"""
|
|
311
|
+
resolved_model = model or os.environ.get("ATTACKMAP_LLM_MODEL") or DEFAULT_MODEL
|
|
312
|
+
resolved_effort = effort or DEFAULT_EFFORT
|
|
313
|
+
|
|
314
|
+
rendered = render_review_prompts(scan, attack_surfaces, findings, attack_paths)
|
|
315
|
+
if backend == "cli" and cli_runner is not None:
|
|
316
|
+
chosen_backend: Literal["api", "cli"] = "cli"
|
|
317
|
+
else:
|
|
318
|
+
chosen_backend = _resolve_backend(backend, api_key=api_key, client=client)
|
|
319
|
+
|
|
320
|
+
if chosen_backend == "api":
|
|
321
|
+
return _run_via_sdk(
|
|
322
|
+
rendered.system,
|
|
323
|
+
rendered.user,
|
|
324
|
+
model=resolved_model,
|
|
325
|
+
effort=resolved_effort,
|
|
326
|
+
max_tokens=max_tokens,
|
|
327
|
+
api_key=api_key,
|
|
328
|
+
client=client,
|
|
329
|
+
)
|
|
330
|
+
return _run_via_claude_cli(
|
|
331
|
+
rendered.system,
|
|
332
|
+
rendered.user,
|
|
333
|
+
model=resolved_model,
|
|
334
|
+
runner=cli_runner,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = [
|
|
339
|
+
"generate_llm_review",
|
|
340
|
+
"LlmReviewError",
|
|
341
|
+
"LlmReviewResult",
|
|
342
|
+
"LlmBackend",
|
|
343
|
+
"DEFAULT_MODEL",
|
|
344
|
+
"DEFAULT_EFFORT",
|
|
345
|
+
"DEFAULT_MAX_TOKENS",
|
|
346
|
+
]
|
attackmap/models.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Route(BaseModel):
|
|
10
|
+
path: str
|
|
11
|
+
method: str = "ANY"
|
|
12
|
+
file: str
|
|
13
|
+
line: int | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExternalCall(BaseModel):
|
|
17
|
+
target: str
|
|
18
|
+
file: str
|
|
19
|
+
line: int | None = None
|
|
20
|
+
evidence_text: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DatabaseHint(BaseModel):
|
|
24
|
+
kind: str
|
|
25
|
+
file: str
|
|
26
|
+
line: int | None = None
|
|
27
|
+
evidence_text: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthHint(BaseModel):
|
|
31
|
+
hint: str
|
|
32
|
+
file: str
|
|
33
|
+
line: int | None = None
|
|
34
|
+
evidence_text: str | None = None
|
|
35
|
+
confidence: float = 0.7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServiceHint(BaseModel):
|
|
39
|
+
hint: str
|
|
40
|
+
file: str
|
|
41
|
+
line: int | None = None
|
|
42
|
+
evidence_text: str | None = None
|
|
43
|
+
confidence: float = 0.7
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EdgeHint(BaseModel):
|
|
47
|
+
hint: str
|
|
48
|
+
file: str
|
|
49
|
+
line: int | None = None
|
|
50
|
+
evidence_text: str | None = None
|
|
51
|
+
confidence: float = 0.7
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EntrypointHint(BaseModel):
|
|
55
|
+
hint: str
|
|
56
|
+
file: str
|
|
57
|
+
line: int | None = None
|
|
58
|
+
evidence_text: str | None = None
|
|
59
|
+
confidence: float = 0.7
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProtocolHint(BaseModel):
|
|
63
|
+
hint: str
|
|
64
|
+
file: str
|
|
65
|
+
line: int | None = None
|
|
66
|
+
evidence_text: str | None = None
|
|
67
|
+
confidence: float = 0.7
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FrameworkHint(BaseModel):
|
|
71
|
+
hint: str
|
|
72
|
+
file: str
|
|
73
|
+
line: int | None = None
|
|
74
|
+
evidence_text: str | None = None
|
|
75
|
+
confidence: float = 0.7
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SecretHint(BaseModel):
|
|
79
|
+
name: str
|
|
80
|
+
file: str
|
|
81
|
+
line: int | None = None
|
|
82
|
+
evidence_text: str | None = None
|
|
83
|
+
confidence: float = 0.85
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SignalKind = Literal[
|
|
87
|
+
"route",
|
|
88
|
+
"external_call",
|
|
89
|
+
"database",
|
|
90
|
+
"auth",
|
|
91
|
+
"service",
|
|
92
|
+
"edge",
|
|
93
|
+
"entrypoint",
|
|
94
|
+
"protocol",
|
|
95
|
+
"framework",
|
|
96
|
+
"secret",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Signal(BaseModel):
|
|
101
|
+
"""Unified view of a single static-analysis signal.
|
|
102
|
+
|
|
103
|
+
Synthesized from the typed hint lists on `ScanResult` via `all_signals()`.
|
|
104
|
+
Existing analyzer plugins keep populating the typed hint lists; downstream
|
|
105
|
+
consumers (insights, controls, asset detection, prompts) can iterate over
|
|
106
|
+
the unified Signal stream to reason uniformly about location, confidence,
|
|
107
|
+
and evidence.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
kind: SignalKind
|
|
111
|
+
label: str
|
|
112
|
+
file: str
|
|
113
|
+
line: int | None = None
|
|
114
|
+
confidence: float = 0.7
|
|
115
|
+
evidence_text: str | None = None
|
|
116
|
+
properties: dict[str, str] = Field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
def location(self) -> str:
|
|
119
|
+
"""Stable `file:line` reference if line known, else just file."""
|
|
120
|
+
return f"{self.file}:{self.line}" if self.line is not None else self.file
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AttackSurface(BaseModel):
|
|
124
|
+
route: str
|
|
125
|
+
method: str
|
|
126
|
+
file: str
|
|
127
|
+
category: Literal["webhook", "admin", "auth", "upload", "internal", "health", "public_api"]
|
|
128
|
+
exposure: Literal["public", "internal", "unknown"] = "public"
|
|
129
|
+
risk: Literal["low", "medium", "high"]
|
|
130
|
+
auth_signals: list[str] = Field(default_factory=list)
|
|
131
|
+
data_store_interaction: bool = False
|
|
132
|
+
outbound_integration: bool = False
|
|
133
|
+
rationale: list[str] = Field(default_factory=list)
|
|
134
|
+
line: int | None = None
|
|
135
|
+
|
|
136
|
+
def location(self) -> str:
|
|
137
|
+
return f"{self.file}:{self.line}" if self.line is not None else self.file
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AttackTechnique(BaseModel):
|
|
141
|
+
"""Reference to a MITRE ATT&CK technique (Enterprise matrix)."""
|
|
142
|
+
|
|
143
|
+
technique_id: str # e.g., "T1190", "T1078.004"
|
|
144
|
+
name: str # e.g., "Exploit Public-Facing Application"
|
|
145
|
+
tactic: str # e.g., "Initial Access"
|
|
146
|
+
url: str | None = None # https://attack.mitre.org/techniques/T1190/
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Finding(BaseModel):
|
|
150
|
+
title: str
|
|
151
|
+
severity: Literal["low", "medium", "high"]
|
|
152
|
+
evidence: list[str] = Field(default_factory=list)
|
|
153
|
+
mitigation: str
|
|
154
|
+
confidence: Literal["low", "medium", "high"] = "medium"
|
|
155
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AttackPath(BaseModel):
|
|
159
|
+
name: str
|
|
160
|
+
steps: list[str]
|
|
161
|
+
impact: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
AssetKind = Literal[
|
|
165
|
+
"credentials",
|
|
166
|
+
"session",
|
|
167
|
+
"user_pii",
|
|
168
|
+
"payment",
|
|
169
|
+
"internal_secret",
|
|
170
|
+
"audit_log",
|
|
171
|
+
"business_data",
|
|
172
|
+
"configuration",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Asset(BaseModel):
|
|
177
|
+
id: str
|
|
178
|
+
kind: AssetKind
|
|
179
|
+
name: str
|
|
180
|
+
criticality: Literal["critical", "high", "medium", "low"]
|
|
181
|
+
locations: list[str] = Field(default_factory=list)
|
|
182
|
+
evidence: list[str] = Field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
ControlKind = Literal[
|
|
186
|
+
"authentication",
|
|
187
|
+
"authorization",
|
|
188
|
+
"input_validation",
|
|
189
|
+
"output_encoding",
|
|
190
|
+
"rate_limiting",
|
|
191
|
+
"csrf_protection",
|
|
192
|
+
"encryption_at_rest",
|
|
193
|
+
"encryption_in_transit",
|
|
194
|
+
"audit_logging",
|
|
195
|
+
"rbac",
|
|
196
|
+
"mfa",
|
|
197
|
+
"secret_management",
|
|
198
|
+
"security_headers",
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
ControlStrength = Literal["strong", "moderate", "weak", "absent"]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Control(BaseModel):
|
|
206
|
+
id: str
|
|
207
|
+
kind: ControlKind
|
|
208
|
+
name: str
|
|
209
|
+
strength: ControlStrength
|
|
210
|
+
scope: Literal["global", "module", "route", "service", "asset"]
|
|
211
|
+
placements: list[str] = Field(default_factory=list)
|
|
212
|
+
evidence: list[str] = Field(default_factory=list)
|
|
213
|
+
notes: str | None = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
InsightKind = Literal[
|
|
217
|
+
"shared_secret_blast_radius",
|
|
218
|
+
"sensitive_asset_reachability",
|
|
219
|
+
"control_bypass",
|
|
220
|
+
"defense_gap_in_chain",
|
|
221
|
+
"asymmetric_protection",
|
|
222
|
+
"trust_boundary_violation",
|
|
223
|
+
"audit_gap",
|
|
224
|
+
"control_strength_mismatch",
|
|
225
|
+
"single_point_of_failure",
|
|
226
|
+
"stale_or_contradictory_signal",
|
|
227
|
+
"admin_action_without_auth",
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Insight(BaseModel):
|
|
232
|
+
id: str
|
|
233
|
+
kind: InsightKind
|
|
234
|
+
title: str
|
|
235
|
+
narrative: str
|
|
236
|
+
severity: Literal["critical", "high", "medium", "low", "informational"]
|
|
237
|
+
confidence: Literal["high", "medium", "low"]
|
|
238
|
+
evidence: list[str] = Field(default_factory=list)
|
|
239
|
+
related_assets: list[str] = Field(default_factory=list)
|
|
240
|
+
related_controls: list[str] = Field(default_factory=list)
|
|
241
|
+
related_routes: list[str] = Field(default_factory=list)
|
|
242
|
+
suggested_action: str | None = None
|
|
243
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class DetectionOpportunity(BaseModel):
|
|
247
|
+
"""A defender-facing detection-engineering hint produced from an insight or finding.
|
|
248
|
+
|
|
249
|
+
Designed to answer: "given this static-analysis finding, what runtime
|
|
250
|
+
detection signal would catch the same condition in production?"
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
id: str
|
|
254
|
+
title: str
|
|
255
|
+
rationale: str
|
|
256
|
+
signal_kind: Literal["log", "metric", "trace", "network", "config_audit"]
|
|
257
|
+
suggested_rule: str # human-readable rule sketch (Sigma/KQL/Splunk-style)
|
|
258
|
+
related_insight_ids: list[str] = Field(default_factory=list)
|
|
259
|
+
related_finding_titles: list[str] = Field(default_factory=list)
|
|
260
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ScanResult(BaseModel):
|
|
264
|
+
root: str
|
|
265
|
+
languages: list[str] = Field(default_factory=list)
|
|
266
|
+
routes: list[Route] = Field(default_factory=list)
|
|
267
|
+
external_calls: list[ExternalCall] = Field(default_factory=list)
|
|
268
|
+
databases: list[DatabaseHint] = Field(default_factory=list)
|
|
269
|
+
auth_hints: list[AuthHint] = Field(default_factory=list)
|
|
270
|
+
service_hints: list[ServiceHint] = Field(default_factory=list)
|
|
271
|
+
edge_hints: list[EdgeHint] = Field(default_factory=list)
|
|
272
|
+
entrypoint_hints: list[EntrypointHint] = Field(default_factory=list)
|
|
273
|
+
protocol_hints: list[ProtocolHint] = Field(default_factory=list)
|
|
274
|
+
framework_hints: list[FrameworkHint] = Field(default_factory=list)
|
|
275
|
+
secret_hints: list[SecretHint] = Field(default_factory=list)
|
|
276
|
+
files_scanned: int = 0
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def root_path(self) -> Path:
|
|
280
|
+
return Path(self.root)
|
|
281
|
+
|
|
282
|
+
def all_signals(self) -> list[Signal]:
|
|
283
|
+
"""Synthesize the unified Signal stream from typed hint lists.
|
|
284
|
+
|
|
285
|
+
Cheap O(n) view — does not mutate `self`. Order: routes, external calls,
|
|
286
|
+
databases, then the hint families (auth, service, edge, entrypoint,
|
|
287
|
+
protocol, framework, secret) in that order.
|
|
288
|
+
"""
|
|
289
|
+
signals: list[Signal] = []
|
|
290
|
+
for r in self.routes:
|
|
291
|
+
signals.append(
|
|
292
|
+
Signal(
|
|
293
|
+
kind="route",
|
|
294
|
+
label=f"{r.method} {r.path}",
|
|
295
|
+
file=r.file,
|
|
296
|
+
line=r.line,
|
|
297
|
+
properties={"method": r.method, "path": r.path},
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
for ext in self.external_calls:
|
|
301
|
+
signals.append(
|
|
302
|
+
Signal(
|
|
303
|
+
kind="external_call",
|
|
304
|
+
label=ext.target,
|
|
305
|
+
file=ext.file,
|
|
306
|
+
line=ext.line,
|
|
307
|
+
evidence_text=ext.evidence_text,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
for db in self.databases:
|
|
311
|
+
signals.append(
|
|
312
|
+
Signal(
|
|
313
|
+
kind="database",
|
|
314
|
+
label=db.kind,
|
|
315
|
+
file=db.file,
|
|
316
|
+
line=db.line,
|
|
317
|
+
evidence_text=db.evidence_text,
|
|
318
|
+
properties={"kind": db.kind},
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
_hint_kind_pairs: tuple[tuple[SignalKind, list], ...] = (
|
|
322
|
+
("auth", self.auth_hints),
|
|
323
|
+
("service", self.service_hints),
|
|
324
|
+
("edge", self.edge_hints),
|
|
325
|
+
("entrypoint", self.entrypoint_hints),
|
|
326
|
+
("protocol", self.protocol_hints),
|
|
327
|
+
("framework", self.framework_hints),
|
|
328
|
+
)
|
|
329
|
+
for kind, hints in _hint_kind_pairs:
|
|
330
|
+
for h in hints:
|
|
331
|
+
signals.append(
|
|
332
|
+
Signal(
|
|
333
|
+
kind=kind,
|
|
334
|
+
label=h.hint,
|
|
335
|
+
file=h.file,
|
|
336
|
+
line=getattr(h, "line", None),
|
|
337
|
+
evidence_text=getattr(h, "evidence_text", None),
|
|
338
|
+
confidence=getattr(h, "confidence", 0.7),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
for s in self.secret_hints:
|
|
342
|
+
signals.append(
|
|
343
|
+
Signal(
|
|
344
|
+
kind="secret",
|
|
345
|
+
label=s.name,
|
|
346
|
+
file=s.file,
|
|
347
|
+
line=s.line,
|
|
348
|
+
evidence_text=s.evidence_text,
|
|
349
|
+
confidence=getattr(s, "confidence", 0.85),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
return signals
|