breachgraph 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.
Files changed (58) hide show
  1. breachgraph/__init__.py +10 -0
  2. breachgraph/ai/__init__.py +322 -0
  3. breachgraph/ai/context.py +395 -0
  4. breachgraph/ai/prompts.py +69 -0
  5. breachgraph/cicd/__init__.py +298 -0
  6. breachgraph/cicd/workflows.py +188 -0
  7. breachgraph/cli.py +537 -0
  8. breachgraph/config.py +234 -0
  9. breachgraph/enrich/__init__.py +117 -0
  10. breachgraph/graph/__init__.py +87 -0
  11. breachgraph/graph/correlate.py +292 -0
  12. breachgraph/graph/model.py +149 -0
  13. breachgraph/graph/paths.py +181 -0
  14. breachgraph/graph/sources/actions.py +286 -0
  15. breachgraph/graph/sources/azure.py +804 -0
  16. breachgraph/graph/sources/common.py +40 -0
  17. breachgraph/graph/sources/gcp.py +718 -0
  18. breachgraph/graph/sources/hcl.py +122 -0
  19. breachgraph/graph/sources/images.py +90 -0
  20. breachgraph/graph/sources/kubernetes.py +537 -0
  21. breachgraph/graph/sources/routes.py +373 -0
  22. breachgraph/graph/sources/terraform.py +834 -0
  23. breachgraph/graph/sources/tfload.py +122 -0
  24. breachgraph/graph/summary.py +83 -0
  25. breachgraph/integrations/__init__.py +0 -0
  26. breachgraph/integrations/github.py +200 -0
  27. breachgraph/models.py +295 -0
  28. breachgraph/normalize/__init__.py +291 -0
  29. breachgraph/normalize/taxonomy.py +117 -0
  30. breachgraph/paths.py +81 -0
  31. breachgraph/pipeline.py +198 -0
  32. breachgraph/policy.py +167 -0
  33. breachgraph/py.typed +0 -0
  34. breachgraph/reachability/__init__.py +157 -0
  35. breachgraph/reachability/imports.py +157 -0
  36. breachgraph/reporters/__init__.py +38 -0
  37. breachgraph/reporters/html.py +33 -0
  38. breachgraph/reporters/junit.py +37 -0
  39. breachgraph/reporters/markdown.py +239 -0
  40. breachgraph/reporters/sarif.py +147 -0
  41. breachgraph/reporters/templates/report.html.j2 +236 -0
  42. breachgraph/scanners/__init__.py +29 -0
  43. breachgraph/scanners/_util.py +51 -0
  44. breachgraph/scanners/base.py +179 -0
  45. breachgraph/scanners/checkov.py +81 -0
  46. breachgraph/scanners/cicd.py +46 -0
  47. breachgraph/scanners/gitleaks.py +112 -0
  48. breachgraph/scanners/sarif_import.py +99 -0
  49. breachgraph/scanners/semgrep.py +89 -0
  50. breachgraph/scanners/trivy.py +242 -0
  51. breachgraph/scanners/zap.py +94 -0
  52. breachgraph/scanners/zizmor.py +116 -0
  53. breachgraph/scoring.py +110 -0
  54. breachgraph-0.1.0.dist-info/METADATA +290 -0
  55. breachgraph-0.1.0.dist-info/RECORD +58 -0
  56. breachgraph-0.1.0.dist-info/WHEEL +4 -0
  57. breachgraph-0.1.0.dist-info/entry_points.txt +2 -0
  58. breachgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,10 @@
1
+ """breachgraph: one security gate for CI pipelines."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("breachgraph")
7
+ except PackageNotFoundError: # running from a source checkout
8
+ __version__ = "0.0.0+dev"
9
+
10
+ PROJECT_URL = "https://github.com/amoghnellutla/breachgraph"
@@ -0,0 +1,322 @@
1
+ """Optional AI-assisted triage — bring your own Anthropic API key.
2
+
3
+ breachgraph never spends tokens on your behalf. This runs only when you turn it on *and* a key is
4
+ present (``ANTHROPIC_API_KEY``). For the top findings it asks the model for a real / likely-false-
5
+ positive / needs-review verdict with reasoning and a concrete fix, based on the evidence a reviewer
6
+ would open: the enclosing function, the HTTP route that runs it, the attack path it sits on, and
7
+ for dependencies the lines that call the package (see ``context.py``).
8
+
9
+ It is advisory: it never changes the gate, and it is instructed never to produce exploit code.
10
+ Failures (no key, API errors, refusals, budget) become warnings; a scan never fails because of AI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import threading
18
+ from collections import Counter
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from pydantic import ValidationError
25
+
26
+ from breachgraph.ai.context import build_evidence, on_attack_path
27
+ from breachgraph.ai.prompts import TRIAGE_SYSTEM, TriageOutput, build_prompt
28
+ from breachgraph.config import AIConfig
29
+ from breachgraph.models import AIInsight, AIVerdict, Confidence, Finding, ScanResult, Status
30
+
31
+ if TYPE_CHECKING:
32
+ import anthropic
33
+
34
+ log = logging.getLogger("breachgraph")
35
+
36
+ # USD per 1M tokens (input, output), Anthropic first-party list prices.
37
+ _PRICING: dict[str, tuple[float, float]] = {
38
+ "claude-fable-5-1": (10.0, 50.0),
39
+ "claude-fable-5": (10.0, 50.0),
40
+ "claude-opus-5-5": (4.0, 20.0),
41
+ "claude-opus-5": (5.0, 25.0),
42
+ "claude-opus-4-8": (5.0, 25.0),
43
+ "claude-opus-4-7": (5.0, 25.0),
44
+ "claude-opus-4-6": (5.0, 25.0),
45
+ "claude-sonnet-5-5": (2.0, 10.0),
46
+ "claude-sonnet-5": (2.0, 10.0),
47
+ "claude-sonnet-4-6": (3.0, 15.0),
48
+ "claude-haiku-5-5": (0.10, 0.50),
49
+ "claude-haiku-4-5": (1.0, 5.0),
50
+ }
51
+ _CEILING = max(_PRICING.values()) # used for unknown models, so the budget errs on the safe side
52
+ # Models that take server-side refusal fallback: if a safety classifier declines (security code
53
+ # can trip it), the API re-runs the request on a suitable model within the same call.
54
+ _FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
55
+ _FALLBACK_BETA = "server-side-fallback-2026-07-01"
56
+ _NO_EFFORT_MODELS = {"claude-haiku-4-5", "claude-sonnet-4-5"} # reject output_config.effort
57
+ MAX_OUTPUT_TOKENS = 8000 # thinking counts against this; an answer itself is ~300-800 tokens
58
+ _CONCURRENCY = 4
59
+ _CHARS_PER_TOKEN = 3 # deliberately low, so the pre-flight input estimate errs high
60
+
61
+
62
+ def _strip_titles(node: Any) -> Any:
63
+ if isinstance(node, dict):
64
+ return {k: _strip_titles(v) for k, v in node.items() if k != "title"}
65
+ if isinstance(node, list):
66
+ return [_strip_titles(v) for v in node]
67
+ return node
68
+
69
+
70
+ OUTPUT_SCHEMA: dict[str, Any] = {
71
+ **_strip_titles(TriageOutput.model_json_schema()),
72
+ "additionalProperties": False,
73
+ }
74
+
75
+
76
+ class AIUnavailable(Exception):
77
+ """Raised with a friendly, actionable message when AI triage can't run."""
78
+
79
+
80
+ @dataclass
81
+ class AIRunResult:
82
+ model: str
83
+ attempted: int = 0
84
+ triaged: int = 0
85
+ input_tokens: int = 0
86
+ output_tokens: int = 0
87
+ cost_usd: float = 0.0
88
+ cost_is_upper_bound: bool = False # a model without a known price was billed at the ceiling
89
+ warnings: list[str] = field(default_factory=list)
90
+
91
+
92
+ def available() -> tuple[bool, str]:
93
+ """(ready, reason). Checks the SDK and a key without importing anthropic eagerly."""
94
+ try:
95
+ import anthropic # noqa: F401
96
+ except ImportError:
97
+ return False, "the 'anthropic' package is not installed — run: pip install 'breachgraph[ai]'"
98
+ if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")):
99
+ return False, (
100
+ "no Anthropic API key found. Get one at https://console.anthropic.com/ and set it:\n"
101
+ " export ANTHROPIC_API_KEY=sk-ant-... (Windows: setx ANTHROPIC_API_KEY sk-ant-...)"
102
+ )
103
+ return True, ""
104
+
105
+
106
+ def _price(model: str) -> tuple[tuple[float, float], bool]:
107
+ """((input, output) USD per 1M tokens, known). Unknown models get the ceiling price."""
108
+ known = model in _PRICING
109
+ return (_PRICING[model] if known else _CEILING), known
110
+
111
+
112
+ class _Budget:
113
+ """Reserve a request's worst-case cost before sending it; settle with real usage after.
114
+
115
+ Thread-safe, so concurrent requests can never jointly overshoot ``max_usd``."""
116
+
117
+ def __init__(self, max_usd: float) -> None:
118
+ self.max_usd = max_usd
119
+ self.spent = 0.0
120
+ self.reserved = 0.0
121
+ self.input_tokens = 0
122
+ self.output_tokens = 0
123
+ self.upper_bound = False
124
+ self._lock = threading.Lock()
125
+
126
+ def reserve(self, model: str, prompt_chars: int) -> float | None:
127
+ (p_in, p_out), _ = _price(model)
128
+ worst = (prompt_chars / _CHARS_PER_TOKEN * p_in + MAX_OUTPUT_TOKENS * p_out) / 1_000_000
129
+ with self._lock:
130
+ if self.spent + self.reserved + worst > self.max_usd:
131
+ return None
132
+ self.reserved += worst
133
+ return worst
134
+
135
+ def settle(self, reserved: float, charges: list[tuple[str, int, int]]) -> None:
136
+ """Release the reservation and record what was billed: (model, input, output) per attempt."""
137
+ with self._lock:
138
+ self.reserved -= reserved
139
+ for model, input_tokens, output_tokens in charges:
140
+ (p_in, p_out), known = _price(model)
141
+ self.spent += (input_tokens * p_in + output_tokens * p_out) / 1_000_000
142
+ self.input_tokens += input_tokens
143
+ self.output_tokens += output_tokens
144
+ self.upper_bound = self.upper_bound or not known
145
+
146
+
147
+ @dataclass
148
+ class _Answer:
149
+ model: str # the model that answered (differs from the requested one after a fallback)
150
+ # (model, input tokens, output tokens) per attempt. After a fallback the declined attempt is
151
+ # billed separately, at its own model's price, and only appears in usage.iterations.
152
+ charges: list[tuple[str, int, int]]
153
+ output: TriageOutput | None = None
154
+ problem: str | None = None # why there is no output: "refused (cyber)", "truncated", ...
155
+
156
+
157
+ class _Client:
158
+ """Thin wrapper over the Anthropic SDK: one structured-output request per finding."""
159
+
160
+ def __init__(self, cfg: AIConfig, client: anthropic.Anthropic | None = None) -> None:
161
+ import anthropic
162
+
163
+ self.sdk = anthropic
164
+ self.client = client or anthropic.Anthropic(max_retries=3, timeout=300.0)
165
+ self.cfg = cfg
166
+
167
+ def ask(self, prompt: str) -> _Answer:
168
+ sdk = self.sdk
169
+ model = self.cfg.model
170
+ fallback = model in _FALLBACK_MODELS
171
+ output_config: anthropic.types.beta.BetaOutputConfigParam = {
172
+ "format": {"type": "json_schema", "schema": OUTPUT_SCHEMA}
173
+ }
174
+ if model not in _NO_EFFORT_MODELS:
175
+ output_config["effort"] = self.cfg.effort
176
+ resp = self.client.beta.messages.create(
177
+ model=model,
178
+ max_tokens=MAX_OUTPUT_TOKENS,
179
+ system=TRIAGE_SYSTEM,
180
+ messages=[{"role": "user", "content": prompt}],
181
+ output_config=output_config,
182
+ betas=[_FALLBACK_BETA] if fallback else sdk.omit,
183
+ fallbacks="default" if fallback else sdk.omit,
184
+ )
185
+ log.debug("AI triage request %s served by %s", getattr(resp, "_request_id", "?"), resp.model)
186
+ # Every attempt is counted, so a refusal before any output (billed only in some
187
+ # categories) can make the estimate slightly high, never low.
188
+ charges = [
189
+ (getattr(it, "model", None) or model, it.input_tokens, it.output_tokens)
190
+ for it in resp.usage.iterations or []
191
+ if it.type in ("message", "fallback_message")
192
+ ] or [(resp.model, resp.usage.input_tokens, resp.usage.output_tokens)]
193
+ answer = _Answer(resp.model, charges)
194
+ if resp.stop_reason == "refusal":
195
+ category = resp.stop_details.category if resp.stop_details else None
196
+ answer.problem = f"declined by the model's safety filter ({category or 'unspecified'})"
197
+ elif resp.stop_reason == "max_tokens":
198
+ answer.problem = f"answer cut off at {MAX_OUTPUT_TOKENS} output tokens"
199
+ else:
200
+ text = "".join(b.text for b in resp.content if b.type == "text")
201
+ try:
202
+ answer.output = TriageOutput.model_validate_json(text)
203
+ except ValidationError:
204
+ answer.problem = "answer did not match the expected format"
205
+ return answer
206
+
207
+
208
+ def _select(result: ScanResult, cfg: AIConfig) -> list[Finding]:
209
+ """The findings worth the spend: attack-path steps first, then gating, then new, then risk.
210
+
211
+ One finding per location (file and line, or package) comes first: several rules firing on the
212
+ same Terraform block or package share one context, so spreading the budget covers more ground."""
213
+ ag = result.attack_graph
214
+ pool = [f for f in result.findings if f.status is not Status.SUPPRESSED]
215
+ pool.sort(
216
+ key=lambda f: (
217
+ not on_attack_path(f, ag),
218
+ not f.gating,
219
+ f.status is Status.BASELINE,
220
+ -f.risk_score,
221
+ )
222
+ )
223
+ first: list[Finding] = []
224
+ repeats: list[Finding] = []
225
+ seen: set[tuple[str, int | None, str | None]] = set()
226
+ for f in pool:
227
+ key = (f.location.path, f.location.start_line, f.package.name if f.package else None)
228
+ (repeats if key in seen else first).append(f)
229
+ seen.add(key)
230
+ return (first + repeats)[: cfg.max_findings]
231
+
232
+
233
+ def triage(
234
+ result: ScanResult, cfg: AIConfig, target: Path, client: anthropic.Anthropic | None = None
235
+ ) -> AIRunResult:
236
+ """Annotate the top findings with AI verdicts (``finding.ai``), in place."""
237
+ if client is None:
238
+ ready, reason = available()
239
+ if not ready:
240
+ raise AIUnavailable(reason)
241
+ try:
242
+ api = _Client(cfg, client)
243
+ except Exception as exc:
244
+ raise AIUnavailable(f"could not initialize the Anthropic client: {exc}") from exc
245
+
246
+ run = AIRunResult(model=cfg.model)
247
+ findings = _select(result, cfg)
248
+ run.attempted = len(findings)
249
+ if not findings:
250
+ return run
251
+ jobs = [(f, build_prompt(build_evidence(f, target, result.attack_graph))) for f in findings]
252
+
253
+ budget = _Budget(cfg.max_usd)
254
+ stop = threading.Event() # set on errors that would repeat for every request
255
+ fatal: list[str] = []
256
+ sdk = api.sdk
257
+ fatal_errors = (
258
+ sdk.AuthenticationError,
259
+ sdk.PermissionDeniedError,
260
+ sdk.NotFoundError,
261
+ sdk.BadRequestError,
262
+ )
263
+
264
+ def work(f: Finding, prompt: str) -> str:
265
+ if stop.is_set():
266
+ return "skipped"
267
+ reserved = budget.reserve(cfg.model, len(TRIAGE_SYSTEM) + len(prompt))
268
+ if reserved is None:
269
+ return "budget"
270
+ try:
271
+ answer = api.ask(prompt)
272
+ except fatal_errors as exc:
273
+ budget.settle(reserved, [])
274
+ stop.set()
275
+ fatal.append(f"{type(exc).__name__}: {getattr(exc, 'message', exc)}")
276
+ return "skipped"
277
+ except sdk.APIError as exc:
278
+ budget.settle(reserved, [])
279
+ return f"request failed ({type(exc).__name__}: {getattr(exc, 'message', exc)})"
280
+ budget.settle(reserved, answer.charges)
281
+ if answer.output is None:
282
+ return answer.problem or "no answer"
283
+ out = answer.output
284
+ f.ai = AIInsight(
285
+ verdict=AIVerdict(out.verdict),
286
+ confidence=Confidence(out.confidence),
287
+ summary=out.summary.strip(),
288
+ reasoning=out.explanation.strip(),
289
+ remediation=out.remediation.strip(),
290
+ fix_code=out.fix_code.strip("\n"),
291
+ model=answer.model,
292
+ )
293
+ return "ok"
294
+
295
+ with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
296
+ outcomes = list(pool.map(lambda job: work(*job), jobs))
297
+
298
+ run.triaged = outcomes.count("ok")
299
+ run.input_tokens, run.output_tokens = budget.input_tokens, budget.output_tokens
300
+ run.cost_usd, run.cost_is_upper_bound = budget.spent, budget.upper_bound
301
+ run.warnings += _summarize(outcomes, fatal, cfg, budget.upper_bound)
302
+ return run
303
+
304
+
305
+ def _summarize(outcomes: list[str], fatal: list[str], cfg: AIConfig, upper_bound: bool) -> list[str]:
306
+ warnings = []
307
+ if fatal:
308
+ warnings.append(f"AI triage stopped: {fatal[0]}")
309
+ if n := outcomes.count("budget"):
310
+ warnings.append(
311
+ f"AI budget (${cfg.max_usd:.2f}) reached: {n} finding(s) not triaged. "
312
+ "Raise ai.max_usd (--ai-budget) or lower ai.max_findings."
313
+ )
314
+ if upper_bound or cfg.model not in _PRICING:
315
+ warnings.append(
316
+ "no list price known for a model used: the AI cost shown and the budget use the highest "
317
+ "Claude price, so they are upper bounds"
318
+ )
319
+ problems = Counter(o for o in outcomes if o not in ("ok", "budget", "skipped"))
320
+ for problem, n in problems.most_common():
321
+ warnings.append(f"AI triage: {n} finding(s) without a verdict: {problem}")
322
+ return warnings