linebreak-gate 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linebreak_gate/__init__.py +22 -0
- linebreak_gate/cli.py +443 -0
- linebreak_gate/code_scan.py +360 -0
- linebreak_gate/entitlements/__init__.py +112 -0
- linebreak_gate/entitlements/base.py +148 -0
- linebreak_gate/entitlements/open_entitlements.py +34 -0
- linebreak_gate/gate_config.py +108 -0
- linebreak_gate/llm.py +61 -0
- linebreak_gate/security_artifact.py +264 -0
- linebreak_gate/security_scan.py +532 -0
- linebreak_gate/verdict.py +108 -0
- linebreak_gate-1.0.0.dist-info/METADATA +166 -0
- linebreak_gate-1.0.0.dist-info/RECORD +16 -0
- linebreak_gate-1.0.0.dist-info/WHEEL +4 -0
- linebreak_gate-1.0.0.dist-info/entry_points.txt +2 -0
- linebreak_gate-1.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""linebreak-gate — LineBreak's security gate, shared by the desktop app and CI.
|
|
2
|
+
|
|
3
|
+
The canonical home of the scanner core that previously lived inside the desktop
|
|
4
|
+
backend (``apps/desktop/backend/app``):
|
|
5
|
+
|
|
6
|
+
* :mod:`linebreak_gate.security_scan` — dependency CVE scanning (osv-scanner
|
|
7
|
+
preferred, npm-audit fallback), pure parsing, fail-closed semantics.
|
|
8
|
+
* :mod:`linebreak_gate.code_scan` — AI SAST over first-party source with
|
|
9
|
+
adversarial verification (injectable ``discover``/``verify``).
|
|
10
|
+
* :mod:`linebreak_gate.security_artifact` — the versioned, git-native security
|
|
11
|
+
artifact (findings + approval trail) both surfaces read and write.
|
|
12
|
+
* :mod:`linebreak_gate.entitlements` — the provider-agnostic entitlements
|
|
13
|
+
contract (Decision/Verdict/protocol) and the permissive ``open`` provider.
|
|
14
|
+
|
|
15
|
+
The desktop backend imports these via thin shims (``app/security_scan.py`` et
|
|
16
|
+
al. replace themselves with these modules), so there is exactly ONE
|
|
17
|
+
implementation — no fork of scanner logic between the in-app gate and the CI
|
|
18
|
+
gate. The CI-facing pieces (``gate_config``, ``verdict``, ``cli``) live only
|
|
19
|
+
here and are consumed via the ``linebreak-gate`` console script.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
linebreak_gate/cli.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""``linebreak-gate`` — the security gate at the git/CI boundary.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
|
|
5
|
+
* ``scan`` — dependency CVE scan (osv-scanner / npm audit) + AI SAST over
|
|
6
|
+
the working tree; writes git-native audit artifacts under
|
|
7
|
+
``.linebreak/audit/``. Exit 0 = pass, 1 = blocking findings, 2 = tool/config
|
|
8
|
+
error (fail closed — a scanner crash is never a clean pass).
|
|
9
|
+
* ``report`` — human-readable summary of the recorded scan (counts by
|
|
10
|
+
severity; each finding with CVE id, CVSS, advisory link); ``--format json``
|
|
11
|
+
for the machine-readable form.
|
|
12
|
+
* ``override`` — record a human-approved acknowledgment of ONE exact finding
|
|
13
|
+
(package+version+CVE tuple). Requires ``--reason`` and ``--approver``; the
|
|
14
|
+
record lands in the artifact's approval trail. The gate never auto-clears on
|
|
15
|
+
an agent's say-so.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import getpass
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import uuid
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import code_scan, entitlements, llm, security_scan
|
|
30
|
+
from . import security_artifact as sa
|
|
31
|
+
from .gate_config import FAIL_ON_LEVELS, GateConfig, GateConfigError, resolve_config
|
|
32
|
+
from .security_scan import _norm_severity
|
|
33
|
+
from .verdict import evaluate, finding_id, finding_rank
|
|
34
|
+
|
|
35
|
+
# CI audit records live with the gate config, committed to the repo. Same
|
|
36
|
+
# document format as the desktop's _bmad-output/security artifacts.
|
|
37
|
+
AUDIT_DIR = str(Path(".linebreak") / "audit")
|
|
38
|
+
|
|
39
|
+
_STATUS_LABELS = {
|
|
40
|
+
"blocking": "BLOCKING",
|
|
41
|
+
"acknowledged": "ACKNOWLEDGED (override on record)",
|
|
42
|
+
"below_floor": "below floor",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _err(message: str) -> None:
|
|
47
|
+
print(f"linebreak-gate: {message}", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _actor() -> str:
|
|
51
|
+
for env in ("GITHUB_ACTOR", "GITLAB_USER_LOGIN", "CI_COMMIT_AUTHOR", "USER", "USERNAME"):
|
|
52
|
+
if os.environ.get(env):
|
|
53
|
+
return os.environ[env]
|
|
54
|
+
try:
|
|
55
|
+
return getpass.getuser()
|
|
56
|
+
except OSError:
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
|
61
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0, "total": 0}
|
|
62
|
+
for f in findings:
|
|
63
|
+
counts[_norm_severity(f.get("severity"))] += 1
|
|
64
|
+
counts["total"] += 1
|
|
65
|
+
return counts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _summarize(findings: list[dict[str, Any]], scanner: str | None) -> str:
|
|
69
|
+
if not findings:
|
|
70
|
+
return f"Scan clean — no known vulnerabilities ({scanner})."
|
|
71
|
+
c = _counts(findings)
|
|
72
|
+
parts = ", ".join(
|
|
73
|
+
f"{c[s]} {s}" for s in ("critical", "high", "medium", "low", "unknown") if c[s]
|
|
74
|
+
)
|
|
75
|
+
return f"{c['total']} finding(s) ({parts}) via {scanner}."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _override_ids(doc: dict[str, Any]) -> set[str]:
|
|
79
|
+
ids: set[str] = set()
|
|
80
|
+
for entry in doc.get("approvals") or []:
|
|
81
|
+
if not isinstance(entry, dict) or entry.get("decision") != "override":
|
|
82
|
+
continue
|
|
83
|
+
finding = entry.get("finding")
|
|
84
|
+
if isinstance(finding, dict) and finding.get("id"):
|
|
85
|
+
ids.add(str(finding["id"]))
|
|
86
|
+
return ids
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_scan_artifact(
|
|
90
|
+
root: Path, name: str, kind: str, result: dict[str, Any], actor: str
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Write the scan artifact, carrying the existing approval trail forward so
|
|
93
|
+
recorded overrides survive rescans (they live in the committed artifact)."""
|
|
94
|
+
prior = sa.read_artifact(root, name, base_dir=AUDIT_DIR)
|
|
95
|
+
findings = result.get("findings") or []
|
|
96
|
+
doc = sa.new_artifact(
|
|
97
|
+
kind,
|
|
98
|
+
id="security",
|
|
99
|
+
findings=findings,
|
|
100
|
+
risk_score=result.get("risk_score"),
|
|
101
|
+
summary=_summarize(findings, result.get("scanner")),
|
|
102
|
+
scanner=result.get("scanner"),
|
|
103
|
+
)
|
|
104
|
+
doc["approvals"] = prior.get("approvals") or []
|
|
105
|
+
doc["actor"] = actor
|
|
106
|
+
return sa.write_artifact(root, name, doc, base_dir=AUDIT_DIR)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _detector_payload(
|
|
110
|
+
doc: dict[str, Any] | None, detector: str, cfg: GateConfig, override_ids: set[str]
|
|
111
|
+
) -> dict[str, Any] | None:
|
|
112
|
+
if doc is None or doc.get("kind") is None:
|
|
113
|
+
return None
|
|
114
|
+
findings = doc.get("findings") or []
|
|
115
|
+
verdict = evaluate(findings, fail_on=cfg.fail_on, override_ids=override_ids, detector=detector)
|
|
116
|
+
return {
|
|
117
|
+
"scanner": doc.get("scanner"),
|
|
118
|
+
"generated_at": doc.get("generated_at"),
|
|
119
|
+
"counts": _counts(findings),
|
|
120
|
+
"findings": verdict["findings"],
|
|
121
|
+
"blocking": verdict["blocking"],
|
|
122
|
+
"acknowledged": verdict["acknowledged"],
|
|
123
|
+
"passes": verdict["passes"],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _evaluate_all(
|
|
128
|
+
cfg: GateConfig,
|
|
129
|
+
sec_doc: dict[str, Any] | None,
|
|
130
|
+
code_doc: dict[str, Any] | None,
|
|
131
|
+
*,
|
|
132
|
+
code_skipped: str | None = None,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
override_ids: set[str] = set()
|
|
135
|
+
for doc in (sec_doc, code_doc):
|
|
136
|
+
if doc:
|
|
137
|
+
override_ids |= _override_ids(doc)
|
|
138
|
+
dependencies = _detector_payload(sec_doc, "dep", cfg, override_ids)
|
|
139
|
+
code = _detector_payload(code_doc, "code", cfg, override_ids)
|
|
140
|
+
passes = all(p["passes"] for p in (dependencies, code) if p is not None)
|
|
141
|
+
return {
|
|
142
|
+
"passes": passes,
|
|
143
|
+
"fail_on": cfg.fail_on,
|
|
144
|
+
"fail_on_source": cfg.fail_on_source,
|
|
145
|
+
"dependencies": dependencies,
|
|
146
|
+
"code": code,
|
|
147
|
+
"code_skipped": code_skipped,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _print_findings(payload: dict[str, Any], detector: str) -> None:
|
|
152
|
+
# Worst first, ranked by the SAME rule that decides blocking (severity
|
|
153
|
+
# string with CVSS fallback) so the printed order can't contradict the
|
|
154
|
+
# verdict.
|
|
155
|
+
ordered = sorted(payload["findings"], key=finding_rank, reverse=True)
|
|
156
|
+
for f in ordered:
|
|
157
|
+
status = _STATUS_LABELS.get(f.get("status"), "below floor")
|
|
158
|
+
label = f.get("cve_id") or f.get("title") or "(unidentified)"
|
|
159
|
+
if detector == "dep":
|
|
160
|
+
subject = f"{f.get('package')}@{f.get('installed_version')}"
|
|
161
|
+
fix = f" fix: {f['fixed_version']}" if f.get("fixed_version") else ""
|
|
162
|
+
else:
|
|
163
|
+
subject = f"{f.get('file')}:{f.get('line')}"
|
|
164
|
+
fix = ""
|
|
165
|
+
cvss = f" cvss {f['cvss']}" if f.get("cvss") is not None else ""
|
|
166
|
+
print(f" [{status}] {label} {f.get('severity')}{cvss} {subject}{fix}")
|
|
167
|
+
if f.get("advisory_url"):
|
|
168
|
+
print(f" {f['advisory_url']}")
|
|
169
|
+
print(f" id: {f['id']}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _emit(payload: dict[str, Any], fmt: str) -> None:
|
|
173
|
+
if fmt == "json":
|
|
174
|
+
print(json.dumps(payload, indent=2))
|
|
175
|
+
return
|
|
176
|
+
print(f"LineBreak security gate — fail on: {payload['fail_on']} ({payload['fail_on_source']})")
|
|
177
|
+
for label, key, detector in (
|
|
178
|
+
("Dependencies", "dependencies", "dep"),
|
|
179
|
+
("Code scan", "code", "code"),
|
|
180
|
+
):
|
|
181
|
+
part = payload[key]
|
|
182
|
+
if part is None:
|
|
183
|
+
if key == "code" and payload.get("code_skipped"):
|
|
184
|
+
print(f"Code scan: skipped — {payload['code_skipped']}")
|
|
185
|
+
continue
|
|
186
|
+
c = part["counts"]
|
|
187
|
+
print(
|
|
188
|
+
f"{label} ({part['scanner']}): {c['total']} finding(s) — "
|
|
189
|
+
f"{c['critical']} critical, {c['high']} high, {c['medium']} medium, "
|
|
190
|
+
f"{c['low']} low, {c['unknown']} unknown"
|
|
191
|
+
)
|
|
192
|
+
_print_findings(part, detector)
|
|
193
|
+
if key == "code" and payload.get("code_skipped"):
|
|
194
|
+
print(f"Code scan note: {payload['code_skipped']}")
|
|
195
|
+
deps, code = payload["dependencies"], payload["code"]
|
|
196
|
+
blocking_total = sum(len(p["blocking"]) for p in (deps, code) if p is not None)
|
|
197
|
+
if payload["passes"]:
|
|
198
|
+
print("VERDICT: PASS — no blocking findings.")
|
|
199
|
+
else:
|
|
200
|
+
print(
|
|
201
|
+
f"VERDICT: BLOCKED — {blocking_total} blocking finding(s) at/above "
|
|
202
|
+
f"'{payload['fail_on']}'. Fix them or record a human-approved override "
|
|
203
|
+
"(linebreak-gate override --finding <id> --reason ... --approver ...)."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_entitlement() -> bool:
|
|
208
|
+
decision, notice = entitlements.gate_decision(os.environ.get("LINEBREAK_LICENSE_KEY"))
|
|
209
|
+
if notice:
|
|
210
|
+
print(notice, file=sys.stderr)
|
|
211
|
+
if not decision.allowed:
|
|
212
|
+
upgrade = f" ({decision.upgrade_url})" if decision.upgrade_url else ""
|
|
213
|
+
_err(f"entitlement check failed: {decision.reason}{upgrade}")
|
|
214
|
+
return False
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------- commands
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
222
|
+
root = Path(args.path).resolve()
|
|
223
|
+
cfg = resolve_config(root, cli_fail_on=args.fail_on)
|
|
224
|
+
if not _check_entitlement():
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
dep_result = security_scan.scan_project(root, exclude_paths=list(cfg.exclude_paths))
|
|
228
|
+
if dep_result.get("error"):
|
|
229
|
+
# Fail closed: no artifact is written and the check fails — a scan that
|
|
230
|
+
# could not run must never be mistaken for a clean pass.
|
|
231
|
+
_err(f"dependency scan failed (gate stays closed): {dep_result['error']}")
|
|
232
|
+
return 2
|
|
233
|
+
|
|
234
|
+
code_result: dict[str, Any] | None = None
|
|
235
|
+
code_skipped: str | None = None
|
|
236
|
+
if cfg.code_scan == "off":
|
|
237
|
+
code_skipped = "code_scan is 'off' in .linebreak/gate.yml"
|
|
238
|
+
else:
|
|
239
|
+
ask = llm.build_ask()
|
|
240
|
+
if ask is None:
|
|
241
|
+
if cfg.code_scan == "on":
|
|
242
|
+
_err(
|
|
243
|
+
"code_scan is 'on' but no model credentials are available — "
|
|
244
|
+
"set ANTHROPIC_API_KEY (gate stays closed)"
|
|
245
|
+
)
|
|
246
|
+
return 2
|
|
247
|
+
code_skipped = "no ANTHROPIC_API_KEY (dependency scan only)"
|
|
248
|
+
_err(
|
|
249
|
+
"code scan skipped: no ANTHROPIC_API_KEY. Set the key to enable the "
|
|
250
|
+
"AI SAST pass, or set `code_scan: off` in .linebreak/gate.yml to silence this."
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
excludes = list(cfg.exclude_paths)
|
|
254
|
+
code_result = code_scan.scan_code(
|
|
255
|
+
str(root),
|
|
256
|
+
discover=lambda r: code_scan.llm_discover(r, ask=ask, exclude_paths=excludes),
|
|
257
|
+
verify=lambda f: code_scan.llm_verify(f, ask=ask),
|
|
258
|
+
)
|
|
259
|
+
if code_result.get("error"):
|
|
260
|
+
_err(f"code scan failed (gate stays closed): {code_result['error']}")
|
|
261
|
+
return 2
|
|
262
|
+
|
|
263
|
+
actor = _actor()
|
|
264
|
+
sec_doc = _write_scan_artifact(root, "security", "cve_scan", dep_result, actor)
|
|
265
|
+
code_doc: dict[str, Any] | None = None
|
|
266
|
+
if code_result is not None:
|
|
267
|
+
code_doc = _write_scan_artifact(root, "code", "code_scan", code_result, actor)
|
|
268
|
+
elif cfg.code_scan == "auto":
|
|
269
|
+
# The detector was skipped for lack of credentials, but a committed
|
|
270
|
+
# code.json is evidence on the record — it still gates (fail closed)
|
|
271
|
+
# and keeps `scan` and `report` telling the same story. `code_scan:
|
|
272
|
+
# off` is the explicit opt-out that ignores it on both commands.
|
|
273
|
+
existing = sa.read_artifact(root, "code", base_dir=AUDIT_DIR)
|
|
274
|
+
if existing.get("kind") == "code_scan":
|
|
275
|
+
code_doc = existing
|
|
276
|
+
code_skipped = (
|
|
277
|
+
f"{code_skipped}; the committed code.json record (from an earlier scan) still gates"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
payload = _evaluate_all(cfg, sec_doc, code_doc, code_skipped=code_skipped)
|
|
281
|
+
_emit(payload, args.format)
|
|
282
|
+
return 0 if payload["passes"] else 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
286
|
+
root = Path(args.path).resolve()
|
|
287
|
+
cfg = resolve_config(root, cli_fail_on=args.fail_on)
|
|
288
|
+
sec_doc = sa.read_artifact(root, "security", base_dir=AUDIT_DIR)
|
|
289
|
+
# Mirror the scan's rule so report and scan can never disagree about the
|
|
290
|
+
# code detector: `code_scan: off` ignores a committed code.json.
|
|
291
|
+
code_doc = (
|
|
292
|
+
None if cfg.code_scan == "off" else sa.read_artifact(root, "code", base_dir=AUDIT_DIR)
|
|
293
|
+
)
|
|
294
|
+
if sec_doc.get("kind") is None and (code_doc is None or code_doc.get("kind") is None):
|
|
295
|
+
if args.format == "json":
|
|
296
|
+
print(json.dumps({"passes": None, "error": "no scan recorded"}, indent=2))
|
|
297
|
+
else:
|
|
298
|
+
print(
|
|
299
|
+
"No scan recorded under .linebreak/audit/ — run `linebreak-gate scan` "
|
|
300
|
+
"first (a missing scan keeps the gate closed)."
|
|
301
|
+
)
|
|
302
|
+
return 0
|
|
303
|
+
payload = _evaluate_all(cfg, sec_doc, code_doc)
|
|
304
|
+
_emit(payload, args.format)
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _cmd_override(args: argparse.Namespace) -> int:
|
|
309
|
+
root = Path(args.path).resolve()
|
|
310
|
+
reason = (args.reason or "").strip()
|
|
311
|
+
approver = (args.approver or "").strip()
|
|
312
|
+
if not reason:
|
|
313
|
+
_err("a non-empty --reason is required to record an override")
|
|
314
|
+
return 2
|
|
315
|
+
if not approver:
|
|
316
|
+
_err("a non-empty --approver (name/email) is required to record an override")
|
|
317
|
+
return 2
|
|
318
|
+
|
|
319
|
+
target: tuple[str, str, dict[str, Any]] | None = None
|
|
320
|
+
# The id prefix names the artifact the finding lives in.
|
|
321
|
+
detectors = (("code", "code"),) if args.finding.startswith("code:") else (("security", "dep"),)
|
|
322
|
+
for name, detector in detectors:
|
|
323
|
+
doc = sa.read_artifact(root, name, base_dir=AUDIT_DIR)
|
|
324
|
+
if doc.get("kind") is None:
|
|
325
|
+
continue
|
|
326
|
+
for f in doc.get("findings") or []:
|
|
327
|
+
if finding_id(f, detector=detector) == args.finding:
|
|
328
|
+
target = (name, detector, f)
|
|
329
|
+
break
|
|
330
|
+
if target:
|
|
331
|
+
break
|
|
332
|
+
if target is None:
|
|
333
|
+
_err(
|
|
334
|
+
f"finding {args.finding!r} is not in the recorded scan — run "
|
|
335
|
+
"`linebreak-gate scan` and copy the finding id from its output"
|
|
336
|
+
)
|
|
337
|
+
return 2
|
|
338
|
+
|
|
339
|
+
name, detector, f = target
|
|
340
|
+
if detector == "dep":
|
|
341
|
+
record = {
|
|
342
|
+
"id": args.finding,
|
|
343
|
+
"detector": "dependencies",
|
|
344
|
+
"package": f.get("package"),
|
|
345
|
+
"installed_version": f.get("installed_version"),
|
|
346
|
+
"cve_id": f.get("cve_id"),
|
|
347
|
+
"advisory_url": f.get("advisory_url"),
|
|
348
|
+
"severity": f.get("severity"),
|
|
349
|
+
"title": f.get("title"),
|
|
350
|
+
}
|
|
351
|
+
else:
|
|
352
|
+
record = {
|
|
353
|
+
"id": args.finding,
|
|
354
|
+
"detector": "code",
|
|
355
|
+
"file": f.get("file"),
|
|
356
|
+
"line": f.get("line"),
|
|
357
|
+
"title": f.get("title"),
|
|
358
|
+
"category": f.get("category"),
|
|
359
|
+
"severity": f.get("severity"),
|
|
360
|
+
}
|
|
361
|
+
sa.append_approval(
|
|
362
|
+
root,
|
|
363
|
+
name,
|
|
364
|
+
approval_id=uuid.uuid4().hex,
|
|
365
|
+
role="approver",
|
|
366
|
+
decision="override",
|
|
367
|
+
user_email=approver,
|
|
368
|
+
notes=reason,
|
|
369
|
+
finding=record,
|
|
370
|
+
base_dir=AUDIT_DIR,
|
|
371
|
+
)
|
|
372
|
+
rel = Path(AUDIT_DIR) / f"{name}.json"
|
|
373
|
+
print(
|
|
374
|
+
f"Override recorded for {args.finding} by {approver} in {rel} — commit this "
|
|
375
|
+
"file so the acknowledgment applies in CI. It covers this exact finding "
|
|
376
|
+
"only; a different CVE or version still blocks."
|
|
377
|
+
)
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------- entrypoint
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
385
|
+
p.add_argument("--path", default=".", help="project root to scan (default: .)")
|
|
386
|
+
p.add_argument(
|
|
387
|
+
"--fail-on",
|
|
388
|
+
choices=FAIL_ON_LEVELS,
|
|
389
|
+
default=None,
|
|
390
|
+
help="blocking severity floor; overrides .linebreak/gate.yml (default: critical)",
|
|
391
|
+
)
|
|
392
|
+
p.add_argument(
|
|
393
|
+
"--format",
|
|
394
|
+
choices=("summary", "json"),
|
|
395
|
+
default="summary",
|
|
396
|
+
help="output format (default: summary)",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
prog="linebreak-gate",
|
|
403
|
+
description="LineBreak security gate at the git/CI boundary",
|
|
404
|
+
)
|
|
405
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
406
|
+
|
|
407
|
+
scan = sub.add_parser("scan", help="run the dependency + code scan and gate on the result")
|
|
408
|
+
_add_common(scan)
|
|
409
|
+
|
|
410
|
+
report = sub.add_parser("report", help="human-readable summary of the recorded scan")
|
|
411
|
+
_add_common(report)
|
|
412
|
+
|
|
413
|
+
override = sub.add_parser(
|
|
414
|
+
"override", help="record a human-approved override for one exact finding"
|
|
415
|
+
)
|
|
416
|
+
override.add_argument("--path", default=".", help="project root (default: .)")
|
|
417
|
+
override.add_argument(
|
|
418
|
+
"--finding", required=True, help="finding id from `linebreak-gate scan` output"
|
|
419
|
+
)
|
|
420
|
+
override.add_argument(
|
|
421
|
+
"--reason", required=True, help="why shipping with this finding is acceptable"
|
|
422
|
+
)
|
|
423
|
+
override.add_argument(
|
|
424
|
+
"--approver", required=True, help="name/email of the human approving the override"
|
|
425
|
+
)
|
|
426
|
+
return parser
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def main(argv: list[str] | None = None) -> int:
|
|
430
|
+
args = build_parser().parse_args(argv)
|
|
431
|
+
try:
|
|
432
|
+
if args.command == "scan":
|
|
433
|
+
return _cmd_scan(args)
|
|
434
|
+
if args.command == "report":
|
|
435
|
+
return _cmd_report(args)
|
|
436
|
+
return _cmd_override(args)
|
|
437
|
+
except GateConfigError as e:
|
|
438
|
+
_err(f"config error: {e}")
|
|
439
|
+
return 2
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == "__main__": # pragma: no cover
|
|
443
|
+
sys.exit(main())
|