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.
Files changed (29) hide show
  1. carabiner_sec-0.1.2/LICENSE +21 -0
  2. carabiner_sec-0.1.2/PKG-INFO +10 -0
  3. carabiner_sec-0.1.2/README.md +101 -0
  4. carabiner_sec-0.1.2/carabiner/__init__.py +1 -0
  5. carabiner_sec-0.1.2/carabiner/baseline.py +83 -0
  6. carabiner_sec-0.1.2/carabiner/cli.py +225 -0
  7. carabiner_sec-0.1.2/carabiner/config.py +92 -0
  8. carabiner_sec-0.1.2/carabiner/drill.py +217 -0
  9. carabiner_sec-0.1.2/carabiner/engines/__init__.py +24 -0
  10. carabiner_sec-0.1.2/carabiner/engines/_github.py +165 -0
  11. carabiner_sec-0.1.2/carabiner/engines/_gitlab.py +152 -0
  12. carabiner_sec-0.1.2/carabiner/engines/_tool.py +62 -0
  13. carabiner_sec-0.1.2/carabiner/engines/ci.py +31 -0
  14. carabiner_sec-0.1.2/carabiner/engines/deps.py +147 -0
  15. carabiner_sec-0.1.2/carabiner/engines/repo.py +91 -0
  16. carabiner_sec-0.1.2/carabiner/engines/secrets.py +120 -0
  17. carabiner_sec-0.1.2/carabiner/finding.py +72 -0
  18. carabiner_sec-0.1.2/carabiner/report/__init__.py +3 -0
  19. carabiner_sec-0.1.2/carabiner/report/human.py +42 -0
  20. carabiner_sec-0.1.2/carabiner/report/sarif.py +67 -0
  21. carabiner_sec-0.1.2/carabiner_sec.egg-info/PKG-INFO +10 -0
  22. carabiner_sec-0.1.2/carabiner_sec.egg-info/SOURCES.txt +27 -0
  23. carabiner_sec-0.1.2/carabiner_sec.egg-info/dependency_links.txt +1 -0
  24. carabiner_sec-0.1.2/carabiner_sec.egg-info/entry_points.txt +3 -0
  25. carabiner_sec-0.1.2/carabiner_sec.egg-info/requires.txt +1 -0
  26. carabiner_sec-0.1.2/carabiner_sec.egg-info/top_level.txt +1 -0
  27. carabiner_sec-0.1.2/pyproject.toml +30 -0
  28. carabiner_sec-0.1.2/setup.cfg +4 -0
  29. 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