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