carabiner-sec 0.1.2__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.
- carabiner_sec-0.1.2/LICENSE +21 -0
- carabiner_sec-0.1.2/PKG-INFO +10 -0
- carabiner_sec-0.1.2/README.md +101 -0
- carabiner_sec-0.1.2/carabiner/__init__.py +1 -0
- carabiner_sec-0.1.2/carabiner/baseline.py +83 -0
- carabiner_sec-0.1.2/carabiner/cli.py +225 -0
- carabiner_sec-0.1.2/carabiner/config.py +92 -0
- carabiner_sec-0.1.2/carabiner/drill.py +217 -0
- carabiner_sec-0.1.2/carabiner/engines/__init__.py +24 -0
- carabiner_sec-0.1.2/carabiner/engines/_github.py +165 -0
- carabiner_sec-0.1.2/carabiner/engines/_gitlab.py +152 -0
- carabiner_sec-0.1.2/carabiner/engines/_tool.py +62 -0
- carabiner_sec-0.1.2/carabiner/engines/ci.py +31 -0
- carabiner_sec-0.1.2/carabiner/engines/deps.py +147 -0
- carabiner_sec-0.1.2/carabiner/engines/repo.py +91 -0
- carabiner_sec-0.1.2/carabiner/engines/secrets.py +120 -0
- carabiner_sec-0.1.2/carabiner/finding.py +72 -0
- carabiner_sec-0.1.2/carabiner/report/__init__.py +3 -0
- carabiner_sec-0.1.2/carabiner/report/human.py +42 -0
- carabiner_sec-0.1.2/carabiner/report/sarif.py +67 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/PKG-INFO +10 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/SOURCES.txt +27 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/dependency_links.txt +1 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/entry_points.txt +3 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/requires.txt +1 -0
- carabiner_sec-0.1.2/carabiner_sec.egg-info/top_level.txt +1 -0
- carabiner_sec-0.1.2/pyproject.toml +30 -0
- carabiner_sec-0.1.2/setup.cfg +4 -0
- carabiner_sec-0.1.2/tests/test_carabiner.py +626 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritish Saini
|
|
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,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: carabiner-sec
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Make any repo secure by default, keep it that way, and prove the controls fire.
|
|
5
|
+
Project-URL: Homepage, https://maximo000.github.io/carabiner/
|
|
6
|
+
Project-URL: Source, https://github.com/MaXiMo000/carabiner
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: PyYAML>=6
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# carabiner
|
|
2
|
+
|
|
3
|
+
> A carabiner is the piece of gear that locks the system together and is rated
|
|
4
|
+
> to catch a fall. It is also the only piece you check *before* you need it.
|
|
5
|
+
|
|
6
|
+
Make any repository secure by default in one command, keep it that way, and
|
|
7
|
+
prove the protections actually fire.
|
|
8
|
+
|
|
9
|
+
**→ [maximo000.github.io/carabiner](https://maximo000.github.io/carabiner/)**
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ carabiner scan
|
|
13
|
+
CRITICAL CI001 .github/workflows/pr.yml
|
|
14
|
+
job 'hello' runs on pull_request_target and checks out the PR head
|
|
15
|
+
-- untrusted code runs with your secrets
|
|
16
|
+
fix: use `pull_request`, or split into an untrusted build job and a
|
|
17
|
+
privileged job that never checks out the head
|
|
18
|
+
|
|
19
|
+
2 new, 340 accepted (carabiner debt) 0.02s
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Why another one
|
|
23
|
+
|
|
24
|
+
Every scanner already exists and is free — gitleaks, Trivy, Semgrep,
|
|
25
|
+
OSV-Scanner. They are excellent and carabiner does not reimplement any of them.
|
|
26
|
+
And the median repository runs none of them, for three specific reasons:
|
|
27
|
+
|
|
28
|
+
1. **Setup is per-tool, per-language, per-CI.** A two-hour job you do once.
|
|
29
|
+
2. **The first run returns 400 findings and everyone gives up.** The gate gets
|
|
30
|
+
turned off, and the tool now has *negative* value — it looks like coverage.
|
|
31
|
+
3. **A configured control is not a working control.** The hook is in
|
|
32
|
+
`.pre-commit-config.yaml` but nobody ran `pre-commit install`.
|
|
33
|
+
|
|
34
|
+
## The three things that aren't a wrapper
|
|
35
|
+
|
|
36
|
+
**The ratchet.** `carabiner lock` accepts every existing finding into a
|
|
37
|
+
baseline. From then on CI fails only on what's *new*. You can adopt this in a
|
|
38
|
+
ten-year-old repo on a Tuesday afternoon, and security only tightens from
|
|
39
|
+
there. Accepted findings stay visible via `carabiner debt` — the debt is
|
|
40
|
+
tracked, not deleted — and `--expires 90` puts a deadline on it, because
|
|
41
|
+
without one "accepted" quietly means "forever".
|
|
42
|
+
|
|
43
|
+
Findings are fingerprinted on `(engine, rule, path, normalized snippet)`, never
|
|
44
|
+
on line numbers. Adding an import at the top of a file must not resurrect 400
|
|
45
|
+
accepted findings; that's why baseline features elsewhere get abandoned.
|
|
46
|
+
|
|
47
|
+
**The drill.** `carabiner drill` doesn't read configuration — it attacks the
|
|
48
|
+
repo. It plants a private key and checks the installed pre-commit hooks actually
|
|
49
|
+
stop it; asks GitHub whether push protection is really on; and verifies the
|
|
50
|
+
security workflow is a *required* check rather than one that runs, fails, and
|
|
51
|
+
merges anyway.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ carabiner drill
|
|
55
|
+
HIGH DRILL002 pre-commit hooks are configured but NOT installed --
|
|
56
|
+
the config looks right and nothing runs
|
|
57
|
+
HIGH DRILL012 the repository default GITHUB_TOKEN is read/WRITE
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A drill that could not run **never reports as passing** — no token, no network,
|
|
61
|
+
no `pre-commit` binary all produce "could NOT be verified", not a green check.
|
|
62
|
+
Unverified is not secure. Drills are also never ratcheted: a control that
|
|
63
|
+
stopped working is a regression today, not pre-existing debt to accept.
|
|
64
|
+
|
|
65
|
+
> Most security tools check your configuration. carabiner checks your defenses
|
|
66
|
+
> by trying to get past them.
|
|
67
|
+
|
|
68
|
+
**One normalized model.** Every engine reports into one `Finding`. Deduplicated
|
|
69
|
+
across engines — Trivy and OSV-Scanner both read your lockfile, and a developer
|
|
70
|
+
shown the same CVE twice trusts the tool less each time — and emitted as SARIF
|
|
71
|
+
so findings land in the PR Security tab.
|
|
72
|
+
|
|
73
|
+
## Status
|
|
74
|
+
|
|
75
|
+
Phase 0. Native engines only (`ci`, `repo`) — no external tools required, and
|
|
76
|
+
they still find real problems. Scanner wrappers are Phase 1, the drill is
|
|
77
|
+
Phase 3. See [PLAN.md](PLAN.md).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python3 -m carabiner.cli scan --root /path/to/repo
|
|
81
|
+
python3 -m carabiner.cli lock --root /path/to/repo # ratchet
|
|
82
|
+
python3 -m carabiner.cli debt --root /path/to/repo
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Engines
|
|
86
|
+
|
|
87
|
+
| Engine | Checks | Needs |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `ci` (GitHub Actions) | CI001 `pull_request_target` + PR-head checkout · CI002 script injection from `github.event` into `run:` · CI003 unpinned actions · CI004/5 token blast radius · CI007 self-hosted runners | nothing |
|
|
90
|
+
| `ci` (GitLab CI) | GL001 script injection from merge-request title or branch name · GL002 unpinned remote `include:` · GL003 mutable image/service tags | nothing |
|
|
91
|
+
| `repo` | REPO001 `.gitignore` gaps · REPO002 committed key material · REPO003 no SECURITY.md · REPO004 credentials in git remotes | nothing |
|
|
92
|
+
|
|
93
|
+
## What it will never do
|
|
94
|
+
|
|
95
|
+
No SaaS. No dashboard. No account. No telemetry. No AI. No auto-rewriting your
|
|
96
|
+
security config. And it never reimplements a scanner that already exists —
|
|
97
|
+
the value is the ratchet, the drill, and the normalized model.
|
|
98
|
+
|
|
99
|
+
Dependencies: PyYAML and the standard library. That is the whole list, on
|
|
100
|
+
purpose — every dependency is a package a security auditor now implicitly
|
|
101
|
+
vouches for.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The ratchet.
|
|
2
|
+
|
|
3
|
+
Existing findings are accepted once and recorded. From then on only *new*
|
|
4
|
+
findings fail the build. Security tightens or holds; it does not loosen.
|
|
5
|
+
|
|
6
|
+
This is what makes the tool installable in a ten-year-old repo on a Tuesday
|
|
7
|
+
afternoon instead of requiring a cleanup sprint nobody will schedule.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import pathlib
|
|
14
|
+
from datetime import date, timedelta
|
|
15
|
+
|
|
16
|
+
from .finding import Finding
|
|
17
|
+
|
|
18
|
+
BASELINE_PATH = pathlib.Path(".carabiner/baseline.json")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load(root: pathlib.Path) -> dict[str, dict]:
|
|
22
|
+
path = root / BASELINE_PATH
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return {}
|
|
25
|
+
data = json.loads(path.read_text() or "{}")
|
|
26
|
+
return data.get("accepted", {})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def expired(entry: dict, today: str | None = None) -> bool:
|
|
30
|
+
"""An accepted finding with a date on it stops being accepted when that date
|
|
31
|
+
passes. Without expiry, 'accepted' quietly means 'forever', which is how a
|
|
32
|
+
baseline turns into a place debt goes to be forgotten."""
|
|
33
|
+
stamp = entry.get("expires")
|
|
34
|
+
return bool(stamp) and stamp < (today or date.today().isoformat())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fixed(findings: list[Finding], accepted: dict[str, dict]) -> list[dict]:
|
|
38
|
+
"""Accepted entries whose finding is gone. Worth saying out loud -- a review
|
|
39
|
+
that only ever reports new problems never tells anyone they are winning."""
|
|
40
|
+
live = {f.fingerprint for f in findings}
|
|
41
|
+
return [e for fp, e in accepted.items() if fp not in live]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save(root: pathlib.Path, findings: list[Finding], reason: str = "",
|
|
45
|
+
expires_days: int | None = None) -> int:
|
|
46
|
+
"""Accept everything currently found. Keeps first_seen for entries we
|
|
47
|
+
already knew about, so the age of the debt survives a re-lock."""
|
|
48
|
+
path = root / BASELINE_PATH
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
existing = load(root)
|
|
51
|
+
today = date.today().isoformat()
|
|
52
|
+
horizon = ((date.today() + timedelta(days=expires_days)).isoformat()
|
|
53
|
+
if expires_days else None)
|
|
54
|
+
accepted = {}
|
|
55
|
+
for f in findings:
|
|
56
|
+
prior = existing.get(f.fingerprint, {})
|
|
57
|
+
entry = {
|
|
58
|
+
"rule": f.rule, "path": f.path, "severity": f.severity,
|
|
59
|
+
"message": f.message,
|
|
60
|
+
"first_seen": prior.get("first_seen", today),
|
|
61
|
+
"reason": prior.get("reason") or reason,
|
|
62
|
+
}
|
|
63
|
+
deadline = horizon or prior.get("expires")
|
|
64
|
+
if deadline:
|
|
65
|
+
entry["expires"] = deadline
|
|
66
|
+
accepted[f.fingerprint] = entry
|
|
67
|
+
path.write_text(json.dumps(
|
|
68
|
+
{"version": 1, "accepted": accepted}, indent=2, sort_keys=True) + "\n")
|
|
69
|
+
return len(accepted)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def partition(findings: list[Finding], accepted: dict[str, dict]
|
|
73
|
+
) -> tuple[list[Finding], list[Finding]]:
|
|
74
|
+
"""-> (new, still_accepted). Only the first list should fail a build.
|
|
75
|
+
|
|
76
|
+
An expired acceptance counts as new. That is the whole point of putting a
|
|
77
|
+
date on it: the deadline has to actually arrive.
|
|
78
|
+
"""
|
|
79
|
+
new, old = [], []
|
|
80
|
+
for f in findings:
|
|
81
|
+
entry = accepted.get(f.fingerprint)
|
|
82
|
+
(old if entry and not expired(entry) else new).append(f)
|
|
83
|
+
return new, old
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""carabiner -- make a repo secure by default, keep it that way, prove it fires.
|
|
2
|
+
|
|
3
|
+
carabiner init adopt: detect, configure, ratchet once per repo
|
|
4
|
+
carabiner scan what is new since the baseline pre-commit, CI
|
|
5
|
+
carabiner lock accept what exists today deliberate debt
|
|
6
|
+
carabiner debt what we are carrying sprint planning
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import pathlib
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from . import baseline, config, drill
|
|
18
|
+
from .engines import ALL
|
|
19
|
+
from .engines import missing as engines_missing
|
|
20
|
+
from .finding import rank
|
|
21
|
+
from .report import human, sarif
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _collect(root: pathlib.Path, only: list[str] | None, full: bool = False,
|
|
25
|
+
cfg: config.Config | None = None):
|
|
26
|
+
cfg = cfg or config.Config()
|
|
27
|
+
findings = []
|
|
28
|
+
for name, engine in ALL.items():
|
|
29
|
+
if only and name not in only:
|
|
30
|
+
continue
|
|
31
|
+
if not cfg.enabled(name):
|
|
32
|
+
continue
|
|
33
|
+
if engine.available(root):
|
|
34
|
+
findings.extend(engine.run(root, full))
|
|
35
|
+
return [f for f in dedupe(findings) if not cfg.ignored(f)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dedupe(findings):
|
|
39
|
+
"""Collapse identical findings, keeping the most severe.
|
|
40
|
+
|
|
41
|
+
Severity is load-bearing, not cosmetic. The same secret found in the working
|
|
42
|
+
tree AND in history is one problem, but only the history version carries the
|
|
43
|
+
right remediation -- rotate and purge, not just delete. Keeping whichever
|
|
44
|
+
arrived first would quietly downgrade it. Same rule covers two scanners
|
|
45
|
+
disagreeing about a CVE's severity: believe the worse one.
|
|
46
|
+
"""
|
|
47
|
+
best: dict[str, object] = {}
|
|
48
|
+
for f in findings:
|
|
49
|
+
prior = best.get(f.fingerprint)
|
|
50
|
+
if prior is None or rank(f.severity) > rank(prior.severity):
|
|
51
|
+
best[f.fingerprint] = f
|
|
52
|
+
return list(best.values())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
TEMPLATE = """\
|
|
56
|
+
# carabiner -- https://github.com/MaXiMo000/carabiner
|
|
57
|
+
version: 1
|
|
58
|
+
|
|
59
|
+
engines:
|
|
60
|
+
{engines}
|
|
61
|
+
# Every suppression must carry a written reason. An ignore without one is a
|
|
62
|
+
# config error, not a warning -- an unexplained ignore list is how a scan
|
|
63
|
+
# quietly becomes decorative.
|
|
64
|
+
#
|
|
65
|
+
# ignore:
|
|
66
|
+
# - path: tests/fixtures/**
|
|
67
|
+
# reason: "deliberately vulnerable corpus"
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _init(root: pathlib.Path, dry_run: bool) -> int:
|
|
72
|
+
detected = config.detect(root)
|
|
73
|
+
print("detected:")
|
|
74
|
+
for name, why in detected.items():
|
|
75
|
+
print(f" {name:<9} {why}")
|
|
76
|
+
|
|
77
|
+
skipped = engines_missing(root)
|
|
78
|
+
for name, hint in skipped:
|
|
79
|
+
print(f"\n '{name}' has nothing installed to run -- {hint}")
|
|
80
|
+
|
|
81
|
+
lines = "".join(
|
|
82
|
+
f" {n}: {{enabled: true, fail_on: {'high' if n == 'deps' else 'medium'}}}\n"
|
|
83
|
+
for n in detected)
|
|
84
|
+
body = TEMPLATE.format(engines=lines)
|
|
85
|
+
cfg_path = root / config.CONFIG_NAME
|
|
86
|
+
|
|
87
|
+
print(f"\nwould write {config.CONFIG_NAME}:" if dry_run
|
|
88
|
+
else f"\nwriting {config.CONFIG_NAME}:")
|
|
89
|
+
print("".join(f" | {l}\n" for l in body.splitlines()))
|
|
90
|
+
|
|
91
|
+
findings = _collect(root, None, full=True)
|
|
92
|
+
print(f"first scan: {len(findings)} findings")
|
|
93
|
+
|
|
94
|
+
if dry_run:
|
|
95
|
+
print(f"\n--dry-run: nothing written. Drop the flag to adopt.")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if cfg_path.exists():
|
|
99
|
+
print(f"{config.CONFIG_NAME} already exists -- left alone.")
|
|
100
|
+
else:
|
|
101
|
+
cfg_path.write_text(body)
|
|
102
|
+
n = baseline.save(root, findings, reason="accepted at carabiner init")
|
|
103
|
+
|
|
104
|
+
print(f"ratcheted {n} findings into {baseline.BASELINE_PATH}\n")
|
|
105
|
+
print("CI is green from here, and only NEW findings will fail it.")
|
|
106
|
+
print("Existing debt is recorded, not deleted -- see `carabiner debt`.")
|
|
107
|
+
print("Commit .carabiner.yml and .carabiner/ so the ratchet is shared.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: list[str] | None = None) -> int:
|
|
112
|
+
ap = argparse.ArgumentParser(prog="carabiner")
|
|
113
|
+
ap.add_argument("command", choices=["init", "scan", "drill", "lock", "debt"])
|
|
114
|
+
ap.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."))
|
|
115
|
+
ap.add_argument("--engine", action="append", dest="engines")
|
|
116
|
+
ap.add_argument("--fail-on", default=None,
|
|
117
|
+
help="override the per-engine threshold from .carabiner.yml")
|
|
118
|
+
ap.add_argument("--all", action="store_true", dest="full",
|
|
119
|
+
help="every engine, whole history. CI cadence, not pre-commit.")
|
|
120
|
+
ap.add_argument("--dry-run", action="store_true", help="init: write nothing")
|
|
121
|
+
ap.add_argument("--offline", action="store_true",
|
|
122
|
+
help="make no network calls; API drills report as unverified")
|
|
123
|
+
ap.add_argument("--json", action="store_true")
|
|
124
|
+
ap.add_argument("--sarif", metavar="PATH",
|
|
125
|
+
help="write SARIF 2.1.0 for GitHub code scanning")
|
|
126
|
+
ap.add_argument("--summary", metavar="PATH",
|
|
127
|
+
help="write a short markdown summary for a PR comment")
|
|
128
|
+
ap.add_argument("--expires", type=int, metavar="DAYS",
|
|
129
|
+
help="lock: accept these findings for DAYS, then stop")
|
|
130
|
+
# No --token flag, deliberately: argv is world-readable via /proc and CI
|
|
131
|
+
# logs echo commands. Tokens come from the environment only.
|
|
132
|
+
args = ap.parse_args(argv)
|
|
133
|
+
|
|
134
|
+
root = args.root.resolve()
|
|
135
|
+
try:
|
|
136
|
+
cfg = config.load(root)
|
|
137
|
+
except config.ConfigError as e:
|
|
138
|
+
print(f"error: {e}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
|
|
141
|
+
if args.command == "init":
|
|
142
|
+
return _init(root, args.dry_run)
|
|
143
|
+
|
|
144
|
+
if args.command == "drill":
|
|
145
|
+
started = time.monotonic()
|
|
146
|
+
found = drill.run(root, offline=args.offline)
|
|
147
|
+
print(human.render(found, [], time.monotonic() - started))
|
|
148
|
+
# Drills are not ratcheted. A control that stopped working is not
|
|
149
|
+
# pre-existing debt to accept -- it is a regression, today.
|
|
150
|
+
return cfg.gate(found)
|
|
151
|
+
|
|
152
|
+
started = time.monotonic()
|
|
153
|
+
findings = _collect(root, args.engines, args.full, cfg)
|
|
154
|
+
skipped = engines_missing(root)
|
|
155
|
+
accepted_map = baseline.load(root)
|
|
156
|
+
new, accepted = baseline.partition(findings, accepted_map)
|
|
157
|
+
|
|
158
|
+
if args.command == "lock":
|
|
159
|
+
n = baseline.save(root, findings, expires_days=args.expires)
|
|
160
|
+
print(f"ratcheted: {n} findings accepted -> {baseline.BASELINE_PATH}")
|
|
161
|
+
if args.expires:
|
|
162
|
+
print(f"expires in {args.expires} days, after which they fail again.")
|
|
163
|
+
print("only new findings will fail the build from here.")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
if args.command == "debt":
|
|
167
|
+
gone = baseline.fixed(findings, accepted_map)
|
|
168
|
+
overdue = 0
|
|
169
|
+
for fp, e in sorted(accepted_map.items(),
|
|
170
|
+
key=lambda kv: -rank(kv[1]["severity"])):
|
|
171
|
+
late = baseline.expired(e)
|
|
172
|
+
overdue += late
|
|
173
|
+
tail = f" since {e['first_seen']}"
|
|
174
|
+
if e.get("expires"):
|
|
175
|
+
tail += f" {'OVERDUE since' if late else 'due'} {e['expires']}"
|
|
176
|
+
print(f" {e['severity']:<8} {e['rule']:<8} {e['path']}{tail}")
|
|
177
|
+
if e.get("reason"):
|
|
178
|
+
print(f" {e['reason']}")
|
|
179
|
+
print(f"\n{len(accepted_map)} accepted"
|
|
180
|
+
+ (f", {overdue} overdue" if overdue else "")
|
|
181
|
+
+ (f", {len(gone)} already fixed (run `carabiner lock` to prune)"
|
|
182
|
+
if gone else ""))
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
gone = baseline.fixed(findings, accepted_map)
|
|
186
|
+
if args.summary:
|
|
187
|
+
# Deliberately terse. A bot that restates the entire backlog on every PR
|
|
188
|
+
# gets muted, and then the two lines that mattered are muted with it.
|
|
189
|
+
bits = [f"**{len(new)} new**"]
|
|
190
|
+
if gone:
|
|
191
|
+
bits.append(f"{len(gone)} fixed")
|
|
192
|
+
bits.append(f"{len(accepted)} accepted")
|
|
193
|
+
lines = [f"### carabiner — {' · '.join(bits)}", ""]
|
|
194
|
+
for f in sorted(new, key=lambda x: -rank(x.severity))[:10]:
|
|
195
|
+
loc = f"`{f.path}`" + (f" line {f.line}" if f.line else "")
|
|
196
|
+
lines.append(f"- **{f.severity.upper()}** `{f.rule}` {loc} — {f.message}")
|
|
197
|
+
if len(new) > 10:
|
|
198
|
+
lines.append(f"- …and {len(new)-10} more")
|
|
199
|
+
if not new:
|
|
200
|
+
lines.append("No new findings.")
|
|
201
|
+
pathlib.Path(args.summary).write_text("\n".join(lines) + "\n")
|
|
202
|
+
|
|
203
|
+
if args.sarif:
|
|
204
|
+
# Every finding, not just the new ones: the Security tab is an inventory,
|
|
205
|
+
# not a diff, and GitHub does its own resolved/new tracking from the
|
|
206
|
+
# fingerprints. The build gate below still only considers what is new.
|
|
207
|
+
from . import __version__
|
|
208
|
+
pathlib.Path(args.sarif).write_text(sarif.render(findings, __version__))
|
|
209
|
+
print(f"wrote {len(findings)} findings to {args.sarif}")
|
|
210
|
+
|
|
211
|
+
if args.json:
|
|
212
|
+
print(json.dumps({"new": [f.as_dict() for f in new],
|
|
213
|
+
"accepted": len(accepted)}, indent=2))
|
|
214
|
+
else:
|
|
215
|
+
print(human.render(new, accepted, time.monotonic() - started, skipped,
|
|
216
|
+
len(gone)))
|
|
217
|
+
|
|
218
|
+
if args.fail_on:
|
|
219
|
+
worst = max((rank(f.severity) for f in new), default=-1)
|
|
220
|
+
return 1 if worst >= rank(args.fail_on) else 0
|
|
221
|
+
return cfg.gate(new)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`.carabiner.yml` -- and the one piece of process the tool imposes.
|
|
2
|
+
|
|
3
|
+
Every suppression must carry a written reason. An ignore without one is a config
|
|
4
|
+
error, not a warning. That single rule is the difference between a tool people
|
|
5
|
+
keep and a tool people route around: a silent `ignore:` list grows until the
|
|
6
|
+
scan is decorative, whereas a reason has to be defended in code review.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .finding import Finding, rank
|
|
17
|
+
|
|
18
|
+
CONFIG_NAME = ".carabiner.yml"
|
|
19
|
+
DEFAULT_FAIL_ON = "medium"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""Raised rather than warned. A misread config silently disables checks."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Config:
|
|
27
|
+
def __init__(self, data: dict | None = None):
|
|
28
|
+
data = data or {}
|
|
29
|
+
self.engines: dict = data.get("engines") or {}
|
|
30
|
+
self.ignores: list[dict] = []
|
|
31
|
+
for i, entry in enumerate(data.get("ignore") or []):
|
|
32
|
+
if not isinstance(entry, dict) or not str(entry.get("reason") or "").strip():
|
|
33
|
+
raise ConfigError(
|
|
34
|
+
f"{CONFIG_NAME}: ignore[{i}] has no `reason`. Every "
|
|
35
|
+
"suppression must say why, in writing -- an unexplained "
|
|
36
|
+
"ignore list is how a scan quietly becomes decorative.")
|
|
37
|
+
self.ignores.append(entry)
|
|
38
|
+
|
|
39
|
+
def enabled(self, engine: str) -> bool:
|
|
40
|
+
return (self.engines.get(engine) or {}).get("enabled", True) is not False
|
|
41
|
+
|
|
42
|
+
def fail_on(self, engine: str) -> str:
|
|
43
|
+
return (self.engines.get(engine) or {}).get("fail_on", DEFAULT_FAIL_ON)
|
|
44
|
+
|
|
45
|
+
def ignored(self, f: Finding) -> bool:
|
|
46
|
+
for entry in self.ignores:
|
|
47
|
+
pat, rule = entry.get("path"), entry.get("check")
|
|
48
|
+
if pat and not fnmatch.fnmatch(f.path, pat):
|
|
49
|
+
continue
|
|
50
|
+
if rule and rule != f.rule:
|
|
51
|
+
continue
|
|
52
|
+
if pat or rule:
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def gate(self, findings: list[Finding]) -> int:
|
|
57
|
+
"""Exit code. Per-engine thresholds, because a missing SECURITY.md and a
|
|
58
|
+
leaked key do not deserve the same gate."""
|
|
59
|
+
for f in findings:
|
|
60
|
+
if rank(f.severity) >= rank(self.fail_on(f.engine)):
|
|
61
|
+
return 1
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load(root: pathlib.Path) -> Config:
|
|
66
|
+
path = root / CONFIG_NAME
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return Config()
|
|
69
|
+
try:
|
|
70
|
+
data = yaml.safe_load(path.read_text())
|
|
71
|
+
except yaml.YAMLError as e:
|
|
72
|
+
raise ConfigError(f"{CONFIG_NAME} is not valid YAML: {e}") from e
|
|
73
|
+
if data is not None and not isinstance(data, dict):
|
|
74
|
+
raise ConfigError(f"{CONFIG_NAME} must be a mapping")
|
|
75
|
+
return Config(data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def detect(root: pathlib.Path) -> dict[str, str]:
|
|
79
|
+
"""What this repo actually is -> what to switch on. Reported to the user
|
|
80
|
+
rather than applied silently."""
|
|
81
|
+
from .engines import deps
|
|
82
|
+
|
|
83
|
+
found = {}
|
|
84
|
+
if (root / ".github" / "workflows").is_dir():
|
|
85
|
+
found["ci"] = "GitHub Actions workflows"
|
|
86
|
+
found["repo"] = "repository hygiene (always on)"
|
|
87
|
+
if (root / ".git").is_dir():
|
|
88
|
+
found["secrets"] = "git repository"
|
|
89
|
+
present = [m for m in deps.MANIFESTS if (root / m).exists()]
|
|
90
|
+
if present:
|
|
91
|
+
found["deps"] = ", ".join(present[:3])
|
|
92
|
+
return found
|