breakproof 0.1.1__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.
- breakproof/__init__.py +3 -0
- breakproof/cli.py +167 -0
- breakproof/evidence.py +94 -0
- breakproof/extract.py +171 -0
- breakproof/prgate.py +90 -0
- breakproof/radar.py +223 -0
- breakproof-0.1.1.dist-info/METADATA +129 -0
- breakproof-0.1.1.dist-info/RECORD +12 -0
- breakproof-0.1.1.dist-info/WHEEL +5 -0
- breakproof-0.1.1.dist-info/entry_points.txt +2 -0
- breakproof-0.1.1.dist-info/licenses/LICENSE +21 -0
- breakproof-0.1.1.dist-info/top_level.txt +1 -0
breakproof/__init__.py
ADDED
breakproof/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""breakproof CLI. Reads a lot, writes only what you point it at.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 pass, 1 fail (something broke), 2 you broke the invocation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from breakproof import __version__
|
|
14
|
+
from breakproof.extract import scan_path
|
|
15
|
+
from breakproof.prgate import build_gate, render_gate_markdown
|
|
16
|
+
from breakproof.radar import build_api_diff, render_radar_markdown
|
|
17
|
+
|
|
18
|
+
MAX_CONTRACT_BYTES = 50_000_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _force_utf8() -> None:
|
|
22
|
+
for stream in (sys.stdout, sys.stderr):
|
|
23
|
+
try:
|
|
24
|
+
if hasattr(stream, "reconfigure"):
|
|
25
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_force_utf8()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_contract(source: str) -> dict:
|
|
34
|
+
if not isinstance(source, str) or not source:
|
|
35
|
+
raise ValueError("empty contract source")
|
|
36
|
+
if os.path.isdir(source):
|
|
37
|
+
return scan_path(source)
|
|
38
|
+
try:
|
|
39
|
+
if os.path.getsize(source) > MAX_CONTRACT_BYTES:
|
|
40
|
+
raise ValueError(f"contract file too large: {source}")
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise ValueError(f"cannot read contract: {exc}") from exc
|
|
43
|
+
try:
|
|
44
|
+
with open(source, "r", encoding="utf-8") as fh:
|
|
45
|
+
data = json.load(fh)
|
|
46
|
+
except (OSError, ValueError) as exc:
|
|
47
|
+
raise ValueError(f"bad contract JSON: {exc}") from exc
|
|
48
|
+
if not isinstance(data, dict) or not isinstance(data.get("endpoints"), list):
|
|
49
|
+
return {"endpoints": [], "edges": []}
|
|
50
|
+
edges = data.get("edges") if isinstance(data.get("edges"), list) else []
|
|
51
|
+
return {"endpoints": data["endpoints"], "edges": edges}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_file(path: str, content: str) -> None:
|
|
55
|
+
"""Write exactly one file the user named. Nothing else. Ever."""
|
|
56
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
57
|
+
os.makedirs(parent, exist_ok=True)
|
|
58
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
59
|
+
fh.write(content)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cmd_scan(args) -> int:
|
|
63
|
+
try:
|
|
64
|
+
contract = scan_path(args.path, service=args.service or "")
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
print(f"breakproof: {exc}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
payload = json.dumps(contract, indent=2, sort_keys=True) + "\n"
|
|
69
|
+
if args.out:
|
|
70
|
+
try:
|
|
71
|
+
_write_file(args.out, payload)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
print(f"breakproof: cannot write {args.out}: {exc}", file=sys.stderr)
|
|
74
|
+
return 2
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
print(payload, end="")
|
|
78
|
+
except BrokenPipeError:
|
|
79
|
+
return 0
|
|
80
|
+
print(f"endpoints={len(contract['endpoints'])}", file=sys.stderr)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_diff(args) -> int:
|
|
85
|
+
base_src = args.base or args.base_dir
|
|
86
|
+
head_src = args.head or args.head_dir
|
|
87
|
+
if not base_src or not head_src:
|
|
88
|
+
print("breakproof diff: need --base/--base-dir and --head/--head-dir",
|
|
89
|
+
file=sys.stderr)
|
|
90
|
+
return 2
|
|
91
|
+
try:
|
|
92
|
+
base = _load_contract(base_src)
|
|
93
|
+
head = _load_contract(head_src)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
print(f"breakproof: {exc}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
gate = build_gate(base, head, base_ref=args.base_ref or "",
|
|
98
|
+
head_ref=args.head_ref or "")
|
|
99
|
+
md = render_gate_markdown(gate)
|
|
100
|
+
try:
|
|
101
|
+
if args.markdown:
|
|
102
|
+
_write_file(args.markdown, md + "\n")
|
|
103
|
+
else:
|
|
104
|
+
print(md)
|
|
105
|
+
except (OSError, BrokenPipeError) as exc:
|
|
106
|
+
if isinstance(exc, BrokenPipeError):
|
|
107
|
+
return 0
|
|
108
|
+
print(f"breakproof: cannot write {args.markdown}: {exc}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
if args.out_gate:
|
|
111
|
+
try:
|
|
112
|
+
_write_file(args.out_gate,
|
|
113
|
+
json.dumps(gate, indent=2, sort_keys=True) + "\n")
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
print(f"breakproof: cannot write {args.out_gate}: {exc}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
if args.radar_markdown:
|
|
118
|
+
try:
|
|
119
|
+
_write_file(args.radar_markdown,
|
|
120
|
+
render_radar_markdown(gate.get("diff", {}),
|
|
121
|
+
args.head_ref or "") + "\n")
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
print(f"breakproof: cannot write {args.radar_markdown}: {exc}",
|
|
124
|
+
file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
verdict = gate.get("verdict", "pass")
|
|
127
|
+
print(f"verdict={verdict}", file=sys.stderr)
|
|
128
|
+
return 1 if verdict == "fail" else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
p = argparse.ArgumentParser(
|
|
133
|
+
prog="breakproof",
|
|
134
|
+
description="Fail the PR only when an API contract vanished. No spec needed.")
|
|
135
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
136
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
137
|
+
|
|
138
|
+
s = sub.add_parser("scan", help="Read a directory, print its contracts.")
|
|
139
|
+
s.add_argument("path", help="Directory to read. Only read, promise.")
|
|
140
|
+
s.add_argument("--out", default="", help="Write contract JSON here, else stdout.")
|
|
141
|
+
s.add_argument("--service", default="", help="Label every endpoint with this service.")
|
|
142
|
+
s.set_defaults(func=_cmd_scan)
|
|
143
|
+
|
|
144
|
+
d = sub.add_parser("diff", help="Base vs head. Removals fail, additions don't.")
|
|
145
|
+
d.add_argument("--base", default="", help="Base contract JSON, or nothing.")
|
|
146
|
+
d.add_argument("--head", default="", help="Head contract JSON, or nothing.")
|
|
147
|
+
d.add_argument("--base-dir", default="", help="Scan this dir as base instead.")
|
|
148
|
+
d.add_argument("--head-dir", default="", help="Scan this dir as head instead.")
|
|
149
|
+
d.add_argument("--base-ref", default="")
|
|
150
|
+
d.add_argument("--head-ref", default="")
|
|
151
|
+
d.add_argument("--out-gate", default="")
|
|
152
|
+
d.add_argument("--markdown", default="")
|
|
153
|
+
d.add_argument("--radar-markdown", default="")
|
|
154
|
+
d.set_defaults(func=_cmd_diff)
|
|
155
|
+
return p
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main(argv=None) -> int:
|
|
159
|
+
try:
|
|
160
|
+
args = build_parser().parse_args(argv)
|
|
161
|
+
return int(args.func(args) or 0)
|
|
162
|
+
except BrokenPipeError:
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
breakproof/evidence.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Finding types. Dumb data, no opinions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Verdict(str, Enum):
|
|
11
|
+
REQUIRED = "REQUIRED"
|
|
12
|
+
NECESSARY = "NECESSARY"
|
|
13
|
+
UNNECESSARY = "UNNECESSARY"
|
|
14
|
+
DEAD = "DEAD"
|
|
15
|
+
UNREACHABLE = "UNREACHABLE"
|
|
16
|
+
MISSING = "MISSING"
|
|
17
|
+
INCORRECT = "INCORRECT"
|
|
18
|
+
REDUNDANT = "REDUNDANT"
|
|
19
|
+
RISKY = "RISKY"
|
|
20
|
+
EXPENSIVE = "EXPENSIVE"
|
|
21
|
+
REGRESSION = "REGRESSION"
|
|
22
|
+
UNPROVEN = "UNPROVEN"
|
|
23
|
+
UNKNOWN = "UNKNOWN"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Evidence:
|
|
28
|
+
kind: str
|
|
29
|
+
summary: str
|
|
30
|
+
detail: Dict = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> Dict:
|
|
33
|
+
return {"kind": self.kind, "summary": self.summary, "detail": self.detail}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Finding:
|
|
38
|
+
id: str
|
|
39
|
+
title: str
|
|
40
|
+
verdict: Verdict
|
|
41
|
+
severity: str
|
|
42
|
+
location: Optional[Dict] = None
|
|
43
|
+
requirement_id: Optional[str] = None
|
|
44
|
+
claim: str = ""
|
|
45
|
+
static_evidence: List[Evidence] = field(default_factory=list)
|
|
46
|
+
runtime_evidence: List[Evidence] = field(default_factory=list)
|
|
47
|
+
counterfactual: Optional[Evidence] = None
|
|
48
|
+
confidence: float = 0.0
|
|
49
|
+
|
|
50
|
+
def add(self, ev: Evidence) -> None:
|
|
51
|
+
if ev.kind == "runtime":
|
|
52
|
+
self.runtime_evidence.append(ev)
|
|
53
|
+
elif ev.kind == "counterfactual":
|
|
54
|
+
self.counterfactual = ev
|
|
55
|
+
else:
|
|
56
|
+
self.static_evidence.append(ev)
|
|
57
|
+
self._recompute_confidence()
|
|
58
|
+
|
|
59
|
+
def _recompute_confidence(self) -> None:
|
|
60
|
+
score = 0.0
|
|
61
|
+
if self.static_evidence:
|
|
62
|
+
score += 0.4
|
|
63
|
+
if self.runtime_evidence:
|
|
64
|
+
score += 0.3
|
|
65
|
+
if self.counterfactual is not None:
|
|
66
|
+
score += 0.3
|
|
67
|
+
self.confidence = min(1.0, score)
|
|
68
|
+
|
|
69
|
+
def as_dict(self) -> Dict:
|
|
70
|
+
return {
|
|
71
|
+
"id": self.id, "title": self.title, "verdict": self.verdict.value,
|
|
72
|
+
"severity": self.severity, "location": self.location,
|
|
73
|
+
"requirement_id": self.requirement_id, "claim": self.claim,
|
|
74
|
+
"static_evidence": [e.as_dict() for e in self.static_evidence],
|
|
75
|
+
"runtime_evidence": [e.as_dict() for e in self.runtime_evidence],
|
|
76
|
+
"counterfactual": self.counterfactual.as_dict() if self.counterfactual else None,
|
|
77
|
+
"confidence": round(self.confidence, 3),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EvidenceEngine:
|
|
82
|
+
"""Mints findings. That's it."""
|
|
83
|
+
|
|
84
|
+
def new(self, *, id: str, title: str, verdict: Verdict, severity: str,
|
|
85
|
+
location: Optional[Dict] = None, requirement_id: Optional[str] = None,
|
|
86
|
+
claim: str = "") -> Finding:
|
|
87
|
+
return Finding(id=id, title=title, verdict=verdict, severity=severity,
|
|
88
|
+
location=location, requirement_id=requirement_id, claim=claim)
|
|
89
|
+
|
|
90
|
+
def finalize(self, f: Finding) -> Finding:
|
|
91
|
+
strong = f.verdict in (Verdict.NECESSARY, Verdict.REGRESSION)
|
|
92
|
+
if strong and f.confidence < 0.6:
|
|
93
|
+
f.verdict = Verdict.UNPROVEN
|
|
94
|
+
return f
|
breakproof/extract.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Find HTTP routes in a directory. Read-only; wouldn't delete your files if you paid it.
|
|
2
|
+
|
|
3
|
+
Covers common Flask/FastAPI/Express/Go patterns. Anything exotic is reported
|
|
4
|
+
as nothing rather than guessed at — a missed route is an honest gap, a fake
|
|
5
|
+
route is a lie.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import bisect
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
MAX_FILE_BYTES = 1_000_000
|
|
15
|
+
MAX_FILES = 50_000
|
|
16
|
+
MAX_ENDPOINTS = 20_000
|
|
17
|
+
MAX_PATH_LEN = 2048
|
|
18
|
+
|
|
19
|
+
SCAN_EXTS = (".py", ".js", ".jsx", ".ts", ".tsx", ".go")
|
|
20
|
+
SKIP_DIRS = frozenset({
|
|
21
|
+
".git", "node_modules", "dist", "build", "__pycache__",
|
|
22
|
+
".venv", "venv", ".tox", "vendor",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")
|
|
26
|
+
|
|
27
|
+
_PY_DECORATOR = re.compile(
|
|
28
|
+
r"""@(?:[\w.]+\.)?(get|post|put|delete|patch|head|options)\s*\(\s*["']([^"']+)["']""",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
_PY_ROUTE = re.compile(
|
|
32
|
+
r"""@(?:[\w.]+\.)?route\s*\(\s*["']([^"']+)["']\s*(?:,\s*methods\s*=\s*\[([^\]]{0,512})\])?""",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
_PY_METHOD = re.compile(r"""["'](\w{3,7})["']""")
|
|
36
|
+
_JS_ROUTE = re.compile(
|
|
37
|
+
r"""(?:app|router|server|api)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*["'`]([^"'`]+)["'`]""",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
_GO_HANDLE = re.compile(r"""HandleFunc\s*\(\s*["']([^"']+)["']""")
|
|
41
|
+
_GO_GIN = re.compile(
|
|
42
|
+
r"""\.\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(\s*["']([^"']+)["']""",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _service_for(relpath: str) -> str:
|
|
47
|
+
head = (relpath or "").replace("\\", "/").split("/", 1)[0]
|
|
48
|
+
return head or "app"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _line_offsets(text: str) -> list:
|
|
52
|
+
offsets, total = [0], 0
|
|
53
|
+
for line in text.splitlines(keepends=True):
|
|
54
|
+
total += len(line)
|
|
55
|
+
offsets.append(total)
|
|
56
|
+
return offsets
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _line_of(offsets: list, pos: int) -> int:
|
|
60
|
+
return bisect.bisect_right(offsets, pos)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _valid_route(path: str) -> bool:
|
|
64
|
+
return bool(path) and path.startswith("/") and len(path) <= MAX_PATH_LEN and " " not in path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_endpoints_from_text(text, lang: str) -> list:
|
|
68
|
+
"""Pull (method, path, line) triples out of one file's text."""
|
|
69
|
+
if not isinstance(text, str) or not text or not isinstance(lang, str):
|
|
70
|
+
return []
|
|
71
|
+
offsets = _line_offsets(text)
|
|
72
|
+
found = []
|
|
73
|
+
|
|
74
|
+
def line_at(pos: int) -> int:
|
|
75
|
+
return _line_of(offsets, pos)
|
|
76
|
+
|
|
77
|
+
if lang == "py":
|
|
78
|
+
for m in _PY_DECORATOR.finditer(text):
|
|
79
|
+
if _valid_route(m.group(2)):
|
|
80
|
+
found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
|
|
81
|
+
for m in _PY_ROUTE.finditer(text):
|
|
82
|
+
if not _valid_route(m.group(1)):
|
|
83
|
+
continue
|
|
84
|
+
methods = _PY_METHOD.findall(m.group(2) or "GET")
|
|
85
|
+
methods = [x.upper() for x in methods if x.upper() in _METHODS] or ["GET"]
|
|
86
|
+
for meth in methods:
|
|
87
|
+
found.append((meth, m.group(1), line_at(m.start())))
|
|
88
|
+
elif lang in ("js", "ts"):
|
|
89
|
+
for m in _JS_ROUTE.finditer(text):
|
|
90
|
+
if _valid_route(m.group(2)):
|
|
91
|
+
found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
|
|
92
|
+
elif lang == "go":
|
|
93
|
+
for m in _GO_HANDLE.finditer(text):
|
|
94
|
+
if _valid_route(m.group(1)):
|
|
95
|
+
found.append(("GET", m.group(1), line_at(m.start())))
|
|
96
|
+
for m in _GO_GIN.finditer(text):
|
|
97
|
+
if _valid_route(m.group(2)):
|
|
98
|
+
found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
|
|
99
|
+
return found
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _lang_of(filename: str) -> str:
|
|
103
|
+
ext = os.path.splitext(filename.lower())[1]
|
|
104
|
+
if ext == ".py":
|
|
105
|
+
return "py"
|
|
106
|
+
if ext == ".go":
|
|
107
|
+
return "go"
|
|
108
|
+
if ext in (".js", ".jsx"):
|
|
109
|
+
return "js"
|
|
110
|
+
return "ts"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _iter_files(root: str) -> list:
|
|
114
|
+
"""Collect candidate files. Skips symlinks — cute trick, not today."""
|
|
115
|
+
out = []
|
|
116
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
117
|
+
dirnames[:] = sorted(
|
|
118
|
+
d for d in dirnames
|
|
119
|
+
if d not in SKIP_DIRS and not d.startswith(".")
|
|
120
|
+
and not os.path.islink(os.path.join(dirpath, d))
|
|
121
|
+
)
|
|
122
|
+
for fn in sorted(filenames):
|
|
123
|
+
if not fn.endswith(SCAN_EXTS):
|
|
124
|
+
continue
|
|
125
|
+
full = os.path.join(dirpath, fn)
|
|
126
|
+
if os.path.islink(full):
|
|
127
|
+
continue
|
|
128
|
+
out.append(full)
|
|
129
|
+
if len(out) >= MAX_FILES:
|
|
130
|
+
return out
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def scan_path(root: str, service: str = "") -> dict:
|
|
135
|
+
"""Scan a directory into a contract. Never writes, never deletes, never executes."""
|
|
136
|
+
if not isinstance(root, str) or not root:
|
|
137
|
+
raise ValueError("scan root must be a non-empty path")
|
|
138
|
+
if not os.path.isdir(root):
|
|
139
|
+
raise ValueError(f"not a directory: {root}")
|
|
140
|
+
endpoints, seen = [], set()
|
|
141
|
+
for path in _iter_files(root):
|
|
142
|
+
try:
|
|
143
|
+
if os.path.getsize(path) > MAX_FILE_BYTES:
|
|
144
|
+
continue
|
|
145
|
+
except OSError:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
149
|
+
text = fh.read()
|
|
150
|
+
except (OSError, ValueError):
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
rel = os.path.relpath(path, root).replace("\\", "/")
|
|
154
|
+
except ValueError:
|
|
155
|
+
continue
|
|
156
|
+
if rel.startswith(".."):
|
|
157
|
+
continue
|
|
158
|
+
svc = service or _service_for(rel)
|
|
159
|
+
for method, route, line in extract_endpoints_from_text(text, _lang_of(path)):
|
|
160
|
+
key = (method, route, rel, line)
|
|
161
|
+
if key in seen:
|
|
162
|
+
continue
|
|
163
|
+
seen.add(key)
|
|
164
|
+
endpoints.append({"method": method, "path": route, "protocol": "http",
|
|
165
|
+
"service": svc, "handler": "", "file": rel, "line": line})
|
|
166
|
+
if len(endpoints) >= MAX_ENDPOINTS:
|
|
167
|
+
break
|
|
168
|
+
if len(endpoints) >= MAX_ENDPOINTS:
|
|
169
|
+
break
|
|
170
|
+
endpoints.sort(key=lambda e: (e["method"], e["path"], e["file"], str(e["line"])))
|
|
171
|
+
return {"endpoints": endpoints, "edges": []}
|
breakproof/prgate.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Gate a PR: fail on removals, new CVEs, new secrets. Everything else passes.
|
|
2
|
+
|
|
3
|
+
Additions never fail. Yes, really.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict
|
|
9
|
+
|
|
10
|
+
from breakproof.radar import build_api_diff
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def affected_cves(statements) -> set:
|
|
14
|
+
return {str(s.get("cve") or "").upper()
|
|
15
|
+
for s in (statements or []) if isinstance(s, dict)
|
|
16
|
+
and (s.get("state") or "") == "affected" and s.get("cve")}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def secret_sites(compliance_controls) -> set:
|
|
20
|
+
out = set()
|
|
21
|
+
for c in compliance_controls or []:
|
|
22
|
+
if not isinstance(c, dict) or c.get("status") != "fail":
|
|
23
|
+
continue
|
|
24
|
+
if c.get("id") not in ("C-SECRETS", "C-CFG-SECRETS"):
|
|
25
|
+
continue
|
|
26
|
+
for e in c.get("evidence") or []:
|
|
27
|
+
if isinstance(e, dict) and e.get("file"):
|
|
28
|
+
out.add((str(e.get("file")), str(e.get("line", ""))))
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_gate(base_contract=None, head_contract=None, base_affected=None,
|
|
33
|
+
head_affected=None, base_secrets=None, head_secrets=None,
|
|
34
|
+
base_ref: str = "", head_ref: str = "") -> Dict:
|
|
35
|
+
"""PR verdict from frozen contracts + advisory/secret sets. Deterministic."""
|
|
36
|
+
diff = build_api_diff(
|
|
37
|
+
base_contract if isinstance(base_contract, dict) else {},
|
|
38
|
+
head_contract if isinstance(head_contract, dict) else {})
|
|
39
|
+
new_cves = sorted(set(head_affected or ()) - set(base_affected or ()))
|
|
40
|
+
new_secrets = sorted(set(head_secrets or ()) - set(base_secrets or ()))
|
|
41
|
+
breaking = (diff.get("summary") or {}).get("breaking", 0)
|
|
42
|
+
failed = bool(breaking or new_cves or new_secrets)
|
|
43
|
+
return {
|
|
44
|
+
"verdict": "fail" if failed else "pass",
|
|
45
|
+
"base_ref": base_ref or "",
|
|
46
|
+
"head_ref": head_ref or "",
|
|
47
|
+
"diff": diff,
|
|
48
|
+
"new_cves": [{"cve": c} for c in new_cves],
|
|
49
|
+
"new_secrets": [{"file": f, "line": ln} for f, ln in new_secrets],
|
|
50
|
+
"summary": {"breaking": breaking,
|
|
51
|
+
"removed": (diff.get("summary") or {}).get("removed", 0),
|
|
52
|
+
"new_cves": len(new_cves),
|
|
53
|
+
"new_secrets": len(new_secrets)},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_gate_markdown(gate: Dict | None) -> str:
|
|
58
|
+
"""CI-readable verdict rendering."""
|
|
59
|
+
g = gate if isinstance(gate, dict) else {}
|
|
60
|
+
s = g.get("summary", {}) or {}
|
|
61
|
+
verdict = str(g.get("verdict", "pass")).upper()
|
|
62
|
+
lines = [
|
|
63
|
+
f"# PR Merge Gate: {verdict}",
|
|
64
|
+
"",
|
|
65
|
+
f"Base: `{g.get('base_ref', '')}` → Head: `{g.get('head_ref', '')}`",
|
|
66
|
+
f"Breaking contracts: {s.get('breaking', 0)} · "
|
|
67
|
+
f"New CVEs: {s.get('new_cves', 0)} · "
|
|
68
|
+
f"New secret sites: {s.get('new_secrets', 0)}",
|
|
69
|
+
"",
|
|
70
|
+
"## Removed contracts (breaking)",
|
|
71
|
+
]
|
|
72
|
+
for rec in (g.get("diff") or {}).get("removed", []) or []:
|
|
73
|
+
callers = ", ".join(c.get("service", "") for c in rec.get("callers", [])[:6])
|
|
74
|
+
lines.append(f"- **{rec.get('method')} {rec.get('path')}** "
|
|
75
|
+
f"(was: {rec.get('service', '?')}; callers: {callers or 'none observed'})")
|
|
76
|
+
if not ((g.get("diff") or {}).get("removed", []) or []):
|
|
77
|
+
lines.append("- None.")
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append("## New reachable CVEs")
|
|
80
|
+
for item in g.get("new_cves", []) or []:
|
|
81
|
+
lines.append(f"- {item.get('cve')}")
|
|
82
|
+
if not (g.get("new_cves", []) or []):
|
|
83
|
+
lines.append("- None.")
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("## New secret sites")
|
|
86
|
+
for item in g.get("new_secrets", []) or []:
|
|
87
|
+
lines.append(f"- {item.get('file')}:{item.get('line')}")
|
|
88
|
+
if not (g.get("new_secrets", []) or []):
|
|
89
|
+
lines.append("- None.")
|
|
90
|
+
return "\n".join(lines)
|
breakproof/radar.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Diff two frozen endpoint contracts. Removed routes break; added ones don't.
|
|
2
|
+
|
|
3
|
+
That's the whole philosophy. Everything else is bookkeeping.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List
|
|
9
|
+
|
|
10
|
+
from breakproof.evidence import Evidence, EvidenceEngine, Finding, Verdict
|
|
11
|
+
|
|
12
|
+
# "X consumes Y's API" edges. Deploy wiring and imports don't count —
|
|
13
|
+
# refactors aren't breakages, no matter how scary the diff looks.
|
|
14
|
+
CALLER_KINDS = ("calls", "publish", "subscribe", "event")
|
|
15
|
+
|
|
16
|
+
MAX_BREAK_FINDINGS = 25
|
|
17
|
+
MAX_WATCH_ROWS = 50
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalize_path(path: str) -> str:
|
|
21
|
+
path = (path or "").strip() or "/"
|
|
22
|
+
if not path.startswith("/"):
|
|
23
|
+
path = "/" + path
|
|
24
|
+
if len(path) > 1:
|
|
25
|
+
path = path.rstrip("/")
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def break_key(method: str, path: str) -> str:
|
|
30
|
+
"""Stable cross-scan identity for one contract (also the finding id)."""
|
|
31
|
+
return f"API-{(method or 'GET').upper()}-{normalize_path(path)}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_endpoints(endpoints) -> Dict[str, Dict]:
|
|
35
|
+
"""Index endpoint records by contract identity (deterministic)."""
|
|
36
|
+
out: Dict[str, Dict] = {}
|
|
37
|
+
for ep in endpoints or []:
|
|
38
|
+
if not isinstance(ep, dict):
|
|
39
|
+
continue
|
|
40
|
+
method = str(ep.get("method", "GET") or "GET").upper()
|
|
41
|
+
proto = str(ep.get("protocol", "http") or "http").lower()
|
|
42
|
+
path = normalize_path(ep.get("path", ""))
|
|
43
|
+
key = break_key(method, path)
|
|
44
|
+
if key not in out:
|
|
45
|
+
out[key] = {"key": key, "protocol": proto, "method": method,
|
|
46
|
+
"path": path,
|
|
47
|
+
"service": str(ep.get("service", "") or ""),
|
|
48
|
+
"handler": str(ep.get("handler", "") or ""),
|
|
49
|
+
"file": str(ep.get("file", "") or ""),
|
|
50
|
+
"line": ep.get("line", "") or ""}
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def api_contract(svc_summary) -> Dict:
|
|
55
|
+
"""Trimmed contract snapshot (endpoints + call edges)."""
|
|
56
|
+
summary = svc_summary if isinstance(svc_summary, dict) else {}
|
|
57
|
+
indexed = normalize_endpoints(summary.get("endpoints"))
|
|
58
|
+
endpoints = [indexed[key] for key in sorted(indexed)]
|
|
59
|
+
edges = []
|
|
60
|
+
for e in summary.get("edges", []) or []:
|
|
61
|
+
if not isinstance(e, dict):
|
|
62
|
+
continue
|
|
63
|
+
edges.append({"src": str(e.get("src") or e.get("from") or ""),
|
|
64
|
+
"dst": str(e.get("dst") or e.get("to") or ""),
|
|
65
|
+
"kind": str(e.get("kind") or e.get("type") or ""),
|
|
66
|
+
"file": str(e.get("file", "") or ""),
|
|
67
|
+
"line": e.get("line", "") or ""})
|
|
68
|
+
return {"endpoints": endpoints, "edges": edges}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _callers(prev_edges, owner: str) -> List[Dict]:
|
|
72
|
+
if not owner:
|
|
73
|
+
return []
|
|
74
|
+
out = []
|
|
75
|
+
for e in prev_edges or []:
|
|
76
|
+
if e.get("kind") not in CALLER_KINDS or e.get("dst") != owner:
|
|
77
|
+
continue
|
|
78
|
+
out.append({"service": e.get("src", ""),
|
|
79
|
+
"file": e.get("file", ""), "line": e.get("line", ""),
|
|
80
|
+
"via": e.get("kind", "calls")})
|
|
81
|
+
out.sort(key=lambda c: (c["service"], str(c["file"]), str(c["line"])))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_api_diff(prev_contract=None, curr_contract=None) -> Dict:
|
|
86
|
+
"""Diff two frozen contracts. Pure, deterministic, sorted."""
|
|
87
|
+
prev = prev_contract if isinstance(prev_contract, dict) else {}
|
|
88
|
+
curr = curr_contract if isinstance(curr_contract, dict) else {}
|
|
89
|
+
prev_eps = normalize_endpoints(prev.get("endpoints"))
|
|
90
|
+
curr_eps = normalize_endpoints(curr.get("endpoints"))
|
|
91
|
+
prev_edges = [e for e in (prev.get("edges") or []) if isinstance(e, dict)]
|
|
92
|
+
curr_triples = {(e.get("src"), e.get("dst"), e.get("kind"))
|
|
93
|
+
for e in (curr.get("edges") or []) if isinstance(e, dict)}
|
|
94
|
+
|
|
95
|
+
removed, added = [], []
|
|
96
|
+
for key in sorted(set(prev_eps) - set(curr_eps)):
|
|
97
|
+
rec = dict(prev_eps[key])
|
|
98
|
+
rec["callers"] = _callers(prev_edges, rec["service"])
|
|
99
|
+
removed.append(rec)
|
|
100
|
+
for key in sorted(set(curr_eps) - set(prev_eps)):
|
|
101
|
+
added.append(dict(curr_eps[key]))
|
|
102
|
+
|
|
103
|
+
changed = []
|
|
104
|
+
for proto, path in sorted({(r["protocol"], r["path"]) for r in
|
|
105
|
+
list(prev_eps.values()) + list(curr_eps.values())}):
|
|
106
|
+
before = sorted(v["method"] for v in prev_eps.values()
|
|
107
|
+
if v["protocol"] == proto and v["path"] == path)
|
|
108
|
+
after = sorted(v["method"] for v in curr_eps.values()
|
|
109
|
+
if v["protocol"] == proto and v["path"] == path)
|
|
110
|
+
dropped = [m for m in before if m not in after]
|
|
111
|
+
gained = [m for m in after if m not in before]
|
|
112
|
+
if dropped or gained:
|
|
113
|
+
still = [m for m in before if m in after]
|
|
114
|
+
if still:
|
|
115
|
+
owner = next((v["service"] for v in curr_eps.values()
|
|
116
|
+
if v["protocol"] == proto and v["path"] == path), "")
|
|
117
|
+
changed.append({"protocol": proto, "path": path,
|
|
118
|
+
"removed_methods": dropped,
|
|
119
|
+
"added_methods": gained,
|
|
120
|
+
"breaking": bool(dropped),
|
|
121
|
+
"service": owner,
|
|
122
|
+
"callers": _callers(prev_edges, owner)})
|
|
123
|
+
|
|
124
|
+
watch = []
|
|
125
|
+
for e in prev_edges:
|
|
126
|
+
if e.get("kind") not in CALLER_KINDS:
|
|
127
|
+
continue
|
|
128
|
+
if (e.get("src"), e.get("dst"), e.get("kind")) not in curr_triples:
|
|
129
|
+
watch.append({"src": e.get("src", ""), "dst": e.get("dst", ""),
|
|
130
|
+
"kind": e.get("kind", ""), "file": e.get("file", ""),
|
|
131
|
+
"line": e.get("line", "")})
|
|
132
|
+
watch.sort(key=lambda w: (w["src"], w["dst"], w["kind"]))
|
|
133
|
+
watch = watch[:MAX_WATCH_ROWS]
|
|
134
|
+
|
|
135
|
+
return {"removed": removed, "added": added, "changed": changed,
|
|
136
|
+
"watch": watch,
|
|
137
|
+
"summary": {"removed": len(removed), "added": len(added),
|
|
138
|
+
"changed": len(changed), "watch": len(watch),
|
|
139
|
+
"breaking": len(removed)}}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def api_break_findings(diff: Dict | None, prev_job_id: str,
|
|
143
|
+
ev: EvidenceEngine) -> List[Finding]:
|
|
144
|
+
"""One RISKY/high finding per removed endpoint (never double-filed)."""
|
|
145
|
+
diff = diff if isinstance(diff, dict) else {}
|
|
146
|
+
prev_job = (prev_job_id or "")[:12]
|
|
147
|
+
targets = []
|
|
148
|
+
for rec in diff.get("removed", []) or []:
|
|
149
|
+
if isinstance(rec, dict):
|
|
150
|
+
targets.append((rec.get("key") or break_key(rec.get("method"), rec.get("path")),
|
|
151
|
+
rec))
|
|
152
|
+
targets.sort(key=lambda t: t[0])
|
|
153
|
+
findings: List[Finding] = []
|
|
154
|
+
for key, rec in targets[:MAX_BREAK_FINDINGS]:
|
|
155
|
+
callers = rec.get("callers") or []
|
|
156
|
+
first = next((c for c in callers if c.get("file")), None)
|
|
157
|
+
location = ({"file": first["file"], "line": first.get("line") or 0,
|
|
158
|
+
"col": 0, "end_line": 0, "end_col": 0}
|
|
159
|
+
if first else {})
|
|
160
|
+
caller_note = (f"Affected callers: "
|
|
161
|
+
+ ", ".join(f"{c['service']} ({c.get('file', '?')}"
|
|
162
|
+
f"{':' + str(c['line']) if c.get('line') else ''})"
|
|
163
|
+
for c in callers[:8]) if callers
|
|
164
|
+
else "No in-repo callers observed — external consumers may still break.")
|
|
165
|
+
f = ev.new(
|
|
166
|
+
id=key,
|
|
167
|
+
title=f"Breaking API change: {rec.get('method')} {rec.get('path')} vanished",
|
|
168
|
+
verdict=Verdict.RISKY, severity="high", location=location,
|
|
169
|
+
claim=(f"Contract {rec.get('method')} {rec.get('path')} served by "
|
|
170
|
+
f"'{rec.get('service') or 'unknown service'}' in scan {prev_job} "
|
|
171
|
+
f"is gone. {caller_note}"))
|
|
172
|
+
f.add(Evidence("static", "Route present in previous frozen scan, absent now.",
|
|
173
|
+
{"contract": key, "previous_job": prev_job_id,
|
|
174
|
+
"owner": rec.get("service", ""),
|
|
175
|
+
"callers": callers[:20]}))
|
|
176
|
+
f.add(Evidence("counterfactual", "Restore the route or migrate every caller, then re-scan.",
|
|
177
|
+
{"fix": f"restore {rec.get('method')} {rec.get('path')}"}))
|
|
178
|
+
findings.append(ev.finalize(f))
|
|
179
|
+
return findings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_radar_markdown(diff: Dict | None, job_id: str = "") -> str:
|
|
183
|
+
"""Shareable contract-diff rendering (findings only for breakage)."""
|
|
184
|
+
d = diff if isinstance(diff, dict) else {}
|
|
185
|
+
s = d.get("summary", {}) or {}
|
|
186
|
+
lines = [
|
|
187
|
+
"# API Change Radar",
|
|
188
|
+
"",
|
|
189
|
+
f"Scan: `{(job_id or '')[:12]}` — breaking changes: {s.get('breaking', 0)} "
|
|
190
|
+
f"(removed {s.get('removed', 0)}, method-breaks "
|
|
191
|
+
f"{sum(1 for c in d.get('changed', []) or [] if c.get('breaking'))}, "
|
|
192
|
+
f"added {s.get('added', 0)})",
|
|
193
|
+
"",
|
|
194
|
+
"## Removed contracts (breaking)",
|
|
195
|
+
]
|
|
196
|
+
for rec in d.get("removed", []) or []:
|
|
197
|
+
callers = ", ".join(c["service"] for c in rec.get("callers", [])[:6]) or "none observed"
|
|
198
|
+
lines.append(f"- **{rec.get('method')} {rec.get('path')}** "
|
|
199
|
+
f"(was: {rec.get('service', '?')}; callers: {callers})")
|
|
200
|
+
if not (d.get("removed", []) or []):
|
|
201
|
+
lines.append("- None.")
|
|
202
|
+
lines.append("")
|
|
203
|
+
lines.append("## Changed method-sets")
|
|
204
|
+
for ch in d.get("changed", []) or []:
|
|
205
|
+
mark = "BREAKING" if ch.get("breaking") else "compatible"
|
|
206
|
+
lines.append(f"- {ch.get('path')} [{mark}]: "
|
|
207
|
+
f"dropped {', '.join(ch.get('removed_methods', [])) or '—'}; "
|
|
208
|
+
f"gained {', '.join(ch.get('added_methods', [])) or '—'}")
|
|
209
|
+
if not (d.get("changed", []) or []):
|
|
210
|
+
lines.append("- None.")
|
|
211
|
+
lines.append("")
|
|
212
|
+
lines.append("## Added contracts (informational)")
|
|
213
|
+
for rec in (d.get("added", []) or [])[:20]:
|
|
214
|
+
lines.append(f"- {rec.get('method')} {rec.get('path')} ({rec.get('service', '?')})")
|
|
215
|
+
if not (d.get("added", []) or []):
|
|
216
|
+
lines.append("- None.")
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append("## Watch: vanished consumption (informational, not flagged)")
|
|
219
|
+
for w in (d.get("watch", []) or [])[:20]:
|
|
220
|
+
lines.append(f"- {w.get('src')} no longer {w.get('kind')} {w.get('dst')}")
|
|
221
|
+
if not (d.get("watch", []) or []):
|
|
222
|
+
lines.append("- None.")
|
|
223
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: breakproof
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: No-spec API break gate: FAIL only on removed contracts with caller proof
|
|
5
|
+
Author-email: AuditScan <team@auditscan.sh>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://auditscan.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/auditscan-sh/breakproof
|
|
9
|
+
Project-URL: Issues, https://github.com/auditscan-sh/breakproof/issues
|
|
10
|
+
Keywords: api,breaking-changes,contract-testing,ci,merge-gate
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7.4; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Breakproof — merge without fear.
|
|
25
|
+
|
|
26
|
+
Your PR deleted an endpoint someone still calls. Breakproof tells you before your users do. No OpenAPI spec, no config novel, no noise. Removed contracts fail. Everything else passes.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install breakproof
|
|
30
|
+
breakproof scan ./app --out base.json
|
|
31
|
+
breakproof diff --base base.json --head-dir ./pr-branch
|
|
32
|
+
# verdict=fail, exit 1, and a Markdown receipt of exactly what vanished
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install breakproof # CLI, isolated. The good default.
|
|
39
|
+
pip install breakproof # library + CLI in your venv.
|
|
40
|
+
uvx breakproof --help # no install at all, just vibes.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.10+. Zero dependencies. No network, no telemetry, no account. It works on a plane.
|
|
44
|
+
|
|
45
|
+
## CLI
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Freeze a directory into a contract
|
|
49
|
+
breakproof scan ./app --out contract.json
|
|
50
|
+
breakproof scan ./app --service payments --out contract.json
|
|
51
|
+
|
|
52
|
+
# Diff two frozen contracts
|
|
53
|
+
breakproof diff --base base.json --head head.json
|
|
54
|
+
|
|
55
|
+
# Diff two directories directly (the 10-second demo)
|
|
56
|
+
breakproof diff --base-dir ./main --head-dir ./pr
|
|
57
|
+
|
|
58
|
+
# Save the receipts instead of printing them
|
|
59
|
+
breakproof diff --base base.json --head head.json \
|
|
60
|
+
--out-gate gate.json --markdown gate.md --radar-markdown radar.md
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Exit codes: `0` pass, `1` fail (something vanished), `2` you invoked it wrong.
|
|
64
|
+
|
|
65
|
+
Contract JSON looks like this. You can hand-write it, generate it, or freeze it in CI — Breakproof doesn't care where it came from:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"endpoints": [
|
|
70
|
+
{"method": "GET", "path": "/users", "protocol": "http",
|
|
71
|
+
"service": "api", "handler": "", "file": "app.py", "line": 12}
|
|
72
|
+
],
|
|
73
|
+
"edges": []
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Library (yes, it's importable)
|
|
78
|
+
|
|
79
|
+
Same gate your CI runs, inside your own Python:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from breakproof.extract import scan_path
|
|
83
|
+
from breakproof.prgate import build_gate, render_gate_markdown
|
|
84
|
+
|
|
85
|
+
base = scan_path("./main")
|
|
86
|
+
head = scan_path("./pr")
|
|
87
|
+
gate = build_gate(base, head, base_ref="main", head_ref="pr")
|
|
88
|
+
|
|
89
|
+
print(gate["verdict"]) # "pass" | "fail"
|
|
90
|
+
print(render_gate_markdown(gate)) # paste it into a PR comment
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Lower level, if you already have contracts:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from breakproof.radar import build_api_diff
|
|
97
|
+
|
|
98
|
+
diff = build_api_diff(base, head)
|
|
99
|
+
print(diff["summary"]) # {"removed": 1, "added": 0, ..., "breaking": 1}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## CI
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
- uses: auditscan-sh/breakproof@v1
|
|
106
|
+
with:
|
|
107
|
+
base-dir: ./base
|
|
108
|
+
head-dir: .
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Posts the verdict to the run summary. Set `fail-on-breaking: "false"` for advisory mode (comments, never red).
|
|
112
|
+
|
|
113
|
+
## Why not a spec diff?
|
|
114
|
+
|
|
115
|
+
Spec tools need an `openapi.yaml` you have to write and keep honest, then flag everything including stuff nobody calls. Breakproof reads your code, freezes what it serves, and fails only on removals — plus new CVEs or secret sites if you hand it those lists as JSON. Nothing to maintain, nothing to triage, nothing to argue about in review.
|
|
116
|
+
|
|
117
|
+
## Honest limits
|
|
118
|
+
|
|
119
|
+
v0.1 recognizes common Flask, FastAPI, Express, Fiber, Go `net/http`, and Gin route patterns. Anything exotic reports zero endpoints instead of inventing some — run `breakproof scan` and eyeball the count before you gate on it. Standalone caller lists read `none observed`; pair it with a scanner that knows your consumers to name names.
|
|
120
|
+
|
|
121
|
+
## Safety
|
|
122
|
+
|
|
123
|
+
Read-only by design. It never deletes, moves, edits, or executes your code — it reads the directories you point it at and writes exactly the files you name with `--out` / `--markdown`. Symlinks skipped, files over 1 MB skipped, scans cap at 50k files / 20k endpoints.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT. Break things responsibly.
|
|
128
|
+
|
|
129
|
+
Built by [AuditScan](https://auditscan.sh).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
breakproof/__init__.py,sha256=spb2RNi-CVmM4d_eFz_zF2AQESjqd83ZhUMORlZ9WJo,93
|
|
2
|
+
breakproof/cli.py,sha256=tpJkrHDSeVFpex-G4nYrPj4VmrN1OMCb6ZT9dlS0s7M,5988
|
|
3
|
+
breakproof/evidence.py,sha256=uFHDi093nfP1t0bwQN9MRChbPedPnVfltFc4zcXsmZQ,2986
|
|
4
|
+
breakproof/extract.py,sha256=ciZLr89ufnwCljHidLOiZCaFPu9SDNxtQTTcQ5XaeeA,5905
|
|
5
|
+
breakproof/prgate.py,sha256=FVKd1jRQyCv11gJfPhYWDuH89ITAzzpk58BeUycE4Kw,3641
|
|
6
|
+
breakproof/radar.py,sha256=v2IBqQwy7PIo_Vp7ZHsFCpYppAxWQfZfTr8E098pzRg,10217
|
|
7
|
+
breakproof-0.1.1.dist-info/licenses/LICENSE,sha256=r0pBrXvK5C-IVj5haUtL2SbIycn4SwPT-1128FOB0cw,1066
|
|
8
|
+
breakproof-0.1.1.dist-info/METADATA,sha256=1uI8b5QzkVpYUhdXBl4EyFrMVz4OQjtQvAa0mav0cQ8,4489
|
|
9
|
+
breakproof-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
breakproof-0.1.1.dist-info/entry_points.txt,sha256=GKA5t3_Vxf-yMX61Q0SVNDnVCmhiXKRiP0cHrd80iXo,51
|
|
11
|
+
breakproof-0.1.1.dist-info/top_level.txt,sha256=ggAr7geCjJNnmc30ixrjFpoVUsHSyLj8mam-Lh6192Q,11
|
|
12
|
+
breakproof-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AuditScan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
breakproof
|