breakproof 0.1.1__tar.gz
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-0.1.1/LICENSE +21 -0
- breakproof-0.1.1/PKG-INFO +129 -0
- breakproof-0.1.1/README.md +106 -0
- breakproof-0.1.1/breakproof/__init__.py +3 -0
- breakproof-0.1.1/breakproof/cli.py +167 -0
- breakproof-0.1.1/breakproof/evidence.py +94 -0
- breakproof-0.1.1/breakproof/extract.py +171 -0
- breakproof-0.1.1/breakproof/prgate.py +90 -0
- breakproof-0.1.1/breakproof/radar.py +223 -0
- breakproof-0.1.1/breakproof.egg-info/PKG-INFO +129 -0
- breakproof-0.1.1/breakproof.egg-info/SOURCES.txt +16 -0
- breakproof-0.1.1/breakproof.egg-info/dependency_links.txt +1 -0
- breakproof-0.1.1/breakproof.egg-info/entry_points.txt +2 -0
- breakproof-0.1.1/breakproof.egg-info/requires.txt +3 -0
- breakproof-0.1.1/breakproof.egg-info/top_level.txt +1 -0
- breakproof-0.1.1/pyproject.toml +40 -0
- breakproof-0.1.1/setup.cfg +4 -0
- breakproof-0.1.1/tests/test_gate.py +119 -0
breakproof-0.1.1/LICENSE
ADDED
|
@@ -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,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,106 @@
|
|
|
1
|
+
# Breakproof — merge without fear.
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pipx install breakproof
|
|
7
|
+
breakproof scan ./app --out base.json
|
|
8
|
+
breakproof diff --base base.json --head-dir ./pr-branch
|
|
9
|
+
# verdict=fail, exit 1, and a Markdown receipt of exactly what vanished
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pipx install breakproof # CLI, isolated. The good default.
|
|
16
|
+
pip install breakproof # library + CLI in your venv.
|
|
17
|
+
uvx breakproof --help # no install at all, just vibes.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Python 3.10+. Zero dependencies. No network, no telemetry, no account. It works on a plane.
|
|
21
|
+
|
|
22
|
+
## CLI
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Freeze a directory into a contract
|
|
26
|
+
breakproof scan ./app --out contract.json
|
|
27
|
+
breakproof scan ./app --service payments --out contract.json
|
|
28
|
+
|
|
29
|
+
# Diff two frozen contracts
|
|
30
|
+
breakproof diff --base base.json --head head.json
|
|
31
|
+
|
|
32
|
+
# Diff two directories directly (the 10-second demo)
|
|
33
|
+
breakproof diff --base-dir ./main --head-dir ./pr
|
|
34
|
+
|
|
35
|
+
# Save the receipts instead of printing them
|
|
36
|
+
breakproof diff --base base.json --head head.json \
|
|
37
|
+
--out-gate gate.json --markdown gate.md --radar-markdown radar.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Exit codes: `0` pass, `1` fail (something vanished), `2` you invoked it wrong.
|
|
41
|
+
|
|
42
|
+
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:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"endpoints": [
|
|
47
|
+
{"method": "GET", "path": "/users", "protocol": "http",
|
|
48
|
+
"service": "api", "handler": "", "file": "app.py", "line": 12}
|
|
49
|
+
],
|
|
50
|
+
"edges": []
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Library (yes, it's importable)
|
|
55
|
+
|
|
56
|
+
Same gate your CI runs, inside your own Python:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from breakproof.extract import scan_path
|
|
60
|
+
from breakproof.prgate import build_gate, render_gate_markdown
|
|
61
|
+
|
|
62
|
+
base = scan_path("./main")
|
|
63
|
+
head = scan_path("./pr")
|
|
64
|
+
gate = build_gate(base, head, base_ref="main", head_ref="pr")
|
|
65
|
+
|
|
66
|
+
print(gate["verdict"]) # "pass" | "fail"
|
|
67
|
+
print(render_gate_markdown(gate)) # paste it into a PR comment
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Lower level, if you already have contracts:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from breakproof.radar import build_api_diff
|
|
74
|
+
|
|
75
|
+
diff = build_api_diff(base, head)
|
|
76
|
+
print(diff["summary"]) # {"removed": 1, "added": 0, ..., "breaking": 1}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CI
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
- uses: auditscan-sh/breakproof@v1
|
|
83
|
+
with:
|
|
84
|
+
base-dir: ./base
|
|
85
|
+
head-dir: .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Posts the verdict to the run summary. Set `fail-on-breaking: "false"` for advisory mode (comments, never red).
|
|
89
|
+
|
|
90
|
+
## Why not a spec diff?
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
## Honest limits
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
## Safety
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT. Break things responsibly.
|
|
105
|
+
|
|
106
|
+
Built by [AuditScan](https://auditscan.sh).
|
|
@@ -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())
|
|
@@ -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
|
|
@@ -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": []}
|
|
@@ -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)
|
|
@@ -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,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
breakproof/__init__.py
|
|
5
|
+
breakproof/cli.py
|
|
6
|
+
breakproof/evidence.py
|
|
7
|
+
breakproof/extract.py
|
|
8
|
+
breakproof/prgate.py
|
|
9
|
+
breakproof/radar.py
|
|
10
|
+
breakproof.egg-info/PKG-INFO
|
|
11
|
+
breakproof.egg-info/SOURCES.txt
|
|
12
|
+
breakproof.egg-info/dependency_links.txt
|
|
13
|
+
breakproof.egg-info/entry_points.txt
|
|
14
|
+
breakproof.egg-info/requires.txt
|
|
15
|
+
breakproof.egg-info/top_level.txt
|
|
16
|
+
tests/test_gate.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
breakproof
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "breakproof"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "No-spec API break gate: FAIL only on removed contracts with caller proof"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "AuditScan", email = "team@auditscan.sh"}]
|
|
13
|
+
keywords = ["api", "breaking-changes", "contract-testing", "ci", "merge-gate"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=7.4"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
breakproof = "breakproof.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://auditscan.sh"
|
|
32
|
+
Repository = "https://github.com/auditscan-sh/breakproof"
|
|
33
|
+
Issues = "https://github.com/auditscan-sh/breakproof/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["breakproof*"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
from breakproof.extract import extract_endpoints_from_text, scan_path
|
|
4
|
+
from breakproof.prgate import build_gate
|
|
5
|
+
from breakproof.radar import break_key, build_api_diff, normalize_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _contract(*eps):
|
|
9
|
+
return {"endpoints": [{"method": m, "path": p, "protocol": "http",
|
|
10
|
+
"service": "api", "handler": "", "file": "a.py", "line": 1}
|
|
11
|
+
for m, p in eps], "edges": []}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_break_key_stable():
|
|
15
|
+
assert break_key("get", "/users/") == "API-GET-/users"
|
|
16
|
+
assert break_key("POST", "orders") == "API-POST-/orders"
|
|
17
|
+
assert normalize_path("") == "/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_diff_removed_and_added():
|
|
21
|
+
prev = _contract(("GET", "/users"), ("DELETE", "/gone"))
|
|
22
|
+
curr = _contract(("GET", "/users"), ("GET", "/new"))
|
|
23
|
+
diff = build_api_diff(prev, curr)
|
|
24
|
+
assert diff["summary"]["breaking"] == 1
|
|
25
|
+
assert [r["path"] for r in diff["removed"]] == ["/gone"]
|
|
26
|
+
assert [r["path"] for r in diff["added"]] == ["/new"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_diff_deterministic_and_additions_never_break():
|
|
30
|
+
prev = _contract(("GET", "/users"),)
|
|
31
|
+
curr = _contract(("GET", "/users"), ("POST", "/users"))
|
|
32
|
+
again = build_api_diff(prev, curr)
|
|
33
|
+
assert build_api_diff(prev, curr) == again
|
|
34
|
+
assert again["summary"]["breaking"] == 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_gate_pass_and_fail():
|
|
38
|
+
base = _contract(("GET", "/users"),)
|
|
39
|
+
assert build_gate(base, base)["verdict"] == "pass"
|
|
40
|
+
head = _contract(("GET", "/users"), ("GET", "/new"))
|
|
41
|
+
assert build_gate(base, head)["verdict"] == "pass"
|
|
42
|
+
removed = _contract()
|
|
43
|
+
gate = build_gate(base, removed, base_ref="main", head_ref="pr")
|
|
44
|
+
assert gate["verdict"] == "fail"
|
|
45
|
+
assert gate["summary"]["breaking"] == 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_gate_new_cves_and_secrets_fail():
|
|
49
|
+
base = _contract(("GET", "/users"),)
|
|
50
|
+
gate = build_gate(base, base, head_affected={"CVE-2024-0001"})
|
|
51
|
+
assert gate["verdict"] == "fail"
|
|
52
|
+
gate2 = build_gate(base, base, head_secrets={("a.py", "2")})
|
|
53
|
+
assert gate2["verdict"] == "fail"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_extract_python_and_js():
|
|
57
|
+
py = '@app.get("/users")\ndef f(): pass\n'
|
|
58
|
+
assert ("GET", "/users", 1) in extract_endpoints_from_text(py, "py")
|
|
59
|
+
js = 'app.post("/orders", h)\n'
|
|
60
|
+
assert ("POST", "/orders", 1) in extract_endpoints_from_text(js, "js")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_scan_path_sorts():
|
|
64
|
+
import os
|
|
65
|
+
import shutil
|
|
66
|
+
import tempfile
|
|
67
|
+
tmp = tempfile.mkdtemp(prefix="breakproof-test-")
|
|
68
|
+
try:
|
|
69
|
+
with open(os.path.join(tmp, "b.py"), "w", encoding="utf-8") as fh:
|
|
70
|
+
fh.write('@app.delete("/z")\ndef a(): pass\n')
|
|
71
|
+
with open(os.path.join(tmp, "a.py"), "w", encoding="utf-8") as fh:
|
|
72
|
+
fh.write('@app.get("/a")\ndef b(): pass\n')
|
|
73
|
+
contract = scan_path(tmp)
|
|
74
|
+
assert [(e["method"], e["path"]) for e in contract["endpoints"]] == [
|
|
75
|
+
("DELETE", "/z"), ("GET", "/a")]
|
|
76
|
+
finally:
|
|
77
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
78
|
+
|
|
79
|
+
def test_extract_ignores_garbage():
|
|
80
|
+
assert extract_endpoints_from_text("", "py") == []
|
|
81
|
+
assert extract_endpoints_from_text(None, "py") == []
|
|
82
|
+
assert extract_endpoints_from_text("hello", "cobol") == []
|
|
83
|
+
assert extract_endpoints_from_text("@app.get(123)", "py") == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_scan_rejects_nonsense():
|
|
87
|
+
import pytest
|
|
88
|
+
with pytest.raises(ValueError):
|
|
89
|
+
scan_path("")
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
scan_path("/definitely/not/a/real/dir/breakproof")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_package_is_read_only():
|
|
95
|
+
src = (pathlib.Path(__file__).resolve().parent.parent / "breakproof").glob("*.py")
|
|
96
|
+
banned = ("os.remove", "os.unlink", "os.rmdir", "shutil.rmtree", "os.system",
|
|
97
|
+
"subprocess", "eval(", "exec(")
|
|
98
|
+
for path in src:
|
|
99
|
+
text = path.read_text(encoding="utf-8")
|
|
100
|
+
assert not any(b in text for b in banned), path.name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_cli_roundtrip():
|
|
104
|
+
import json
|
|
105
|
+
import os
|
|
106
|
+
import shutil
|
|
107
|
+
import tempfile
|
|
108
|
+
from breakproof.cli import main
|
|
109
|
+
tmp = tempfile.mkdtemp(prefix="breakproof-cli-")
|
|
110
|
+
try:
|
|
111
|
+
with open(os.path.join(tmp, "a.py"), "w", encoding="utf-8") as fh:
|
|
112
|
+
fh.write('@app.get("/a")\ndef f(): pass\n')
|
|
113
|
+
assert main(["scan", tmp, "--out", os.path.join(tmp, "c.json")]) == 0
|
|
114
|
+
with open(os.path.join(tmp, "c.json"), encoding="utf-8") as fh:
|
|
115
|
+
assert len(json.load(fh)["endpoints"]) == 1
|
|
116
|
+
assert main(["diff", "--base", os.path.join(tmp, "c.json"),
|
|
117
|
+
"--head", os.path.join(tmp, "c.json")]) == 0
|
|
118
|
+
finally:
|
|
119
|
+
shutil.rmtree(tmp, ignore_errors=True)
|