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.
@@ -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())