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.
- breachgraph/__init__.py +10 -0
- breachgraph/ai/__init__.py +322 -0
- breachgraph/ai/context.py +395 -0
- breachgraph/ai/prompts.py +69 -0
- breachgraph/cicd/__init__.py +298 -0
- breachgraph/cicd/workflows.py +188 -0
- breachgraph/cli.py +537 -0
- breachgraph/config.py +234 -0
- breachgraph/enrich/__init__.py +117 -0
- breachgraph/graph/__init__.py +87 -0
- breachgraph/graph/correlate.py +292 -0
- breachgraph/graph/model.py +149 -0
- breachgraph/graph/paths.py +181 -0
- breachgraph/graph/sources/actions.py +286 -0
- breachgraph/graph/sources/azure.py +804 -0
- breachgraph/graph/sources/common.py +40 -0
- breachgraph/graph/sources/gcp.py +718 -0
- breachgraph/graph/sources/hcl.py +122 -0
- breachgraph/graph/sources/images.py +90 -0
- breachgraph/graph/sources/kubernetes.py +537 -0
- breachgraph/graph/sources/routes.py +373 -0
- breachgraph/graph/sources/terraform.py +834 -0
- breachgraph/graph/sources/tfload.py +122 -0
- breachgraph/graph/summary.py +83 -0
- breachgraph/integrations/__init__.py +0 -0
- breachgraph/integrations/github.py +200 -0
- breachgraph/models.py +295 -0
- breachgraph/normalize/__init__.py +291 -0
- breachgraph/normalize/taxonomy.py +117 -0
- breachgraph/paths.py +81 -0
- breachgraph/pipeline.py +198 -0
- breachgraph/policy.py +167 -0
- breachgraph/py.typed +0 -0
- breachgraph/reachability/__init__.py +157 -0
- breachgraph/reachability/imports.py +157 -0
- breachgraph/reporters/__init__.py +38 -0
- breachgraph/reporters/html.py +33 -0
- breachgraph/reporters/junit.py +37 -0
- breachgraph/reporters/markdown.py +239 -0
- breachgraph/reporters/sarif.py +147 -0
- breachgraph/reporters/templates/report.html.j2 +236 -0
- breachgraph/scanners/__init__.py +29 -0
- breachgraph/scanners/_util.py +51 -0
- breachgraph/scanners/base.py +179 -0
- breachgraph/scanners/checkov.py +81 -0
- breachgraph/scanners/cicd.py +46 -0
- breachgraph/scanners/gitleaks.py +112 -0
- breachgraph/scanners/sarif_import.py +99 -0
- breachgraph/scanners/semgrep.py +89 -0
- breachgraph/scanners/trivy.py +242 -0
- breachgraph/scanners/zap.py +94 -0
- breachgraph/scanners/zizmor.py +116 -0
- breachgraph/scoring.py +110 -0
- breachgraph-0.1.0.dist-info/METADATA +290 -0
- breachgraph-0.1.0.dist-info/RECORD +58 -0
- breachgraph-0.1.0.dist-info/WHEEL +4 -0
- breachgraph-0.1.0.dist-info/entry_points.txt +2 -0
- breachgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
breachgraph/__init__.py
ADDED
|
@@ -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
|