Metadata-Version: 2.4
Name: carabiner-sec
Version: 0.2.0
Summary: Make any repo secure by default, keep it that way, and prove the controls fire.
License-Expression: MIT
Project-URL: Homepage, https://maximo000.github.io/carabiner/
Project-URL: Source, https://github.com/MaXiMo000/carabiner
Project-URL: Issues, https://github.com/MaXiMo000/carabiner/issues
Project-URL: Changelog, https://github.com/MaXiMo000/carabiner/releases
Keywords: security,sast,secrets,sarif,pre-commit,github-actions,gitlab-ci,devsecops,supply-chain
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyYAML>=6
Dynamic: license-file

# carabiner

> A carabiner is the piece of gear that locks the system together and is rated
> to catch a fall. It is also the only piece you check *before* you need it.

Make any repository secure by default in one command, keep it that way, and
prove the protections actually fire.

**→ [maximo000.github.io/carabiner](https://maximo000.github.io/carabiner/)**

```bash
pip install carabiner-sec        # the command it installs is `carabiner`
```

```
$ carabiner scan
  CRITICAL CI001  .github/workflows/pr.yml
           job 'hello' runs on pull_request_target and checks out the PR head
           -- untrusted code runs with your secrets
           fix: use `pull_request`, or split into an untrusted build job and a
                privileged job that never checks out the head

  2 new, 340 accepted (carabiner debt)   0.02s
```

## Why another one

Every scanner already exists and is free — gitleaks, Trivy, Semgrep,
OSV-Scanner. They are excellent and carabiner does not reimplement any of them.
And the median repository runs none of them, for three specific reasons:

1. **Setup is per-tool, per-language, per-CI.** A two-hour job you do once.
2. **The first run returns 400 findings and everyone gives up.** The gate gets
   turned off, and the tool now has *negative* value — it looks like coverage.
3. **A configured control is not a working control.** The hook is in
   `.pre-commit-config.yaml` but nobody ran `pre-commit install`.

## The three things that aren't a wrapper

**The ratchet.** `carabiner lock` accepts every existing finding into a
baseline. From then on CI fails only on what's *new*. You can adopt this in a
ten-year-old repo on a Tuesday afternoon, and security only tightens from
there. Accepted findings stay visible via `carabiner debt` — the debt is
tracked, not deleted — and `--expires 90` puts a deadline on it, because
without one "accepted" quietly means "forever".

Findings are fingerprinted on `(engine, rule, path, normalized snippet)`, never
on line numbers. Adding an import at the top of a file must not resurrect 400
accepted findings; that's why baseline features elsewhere get abandoned.

**The drill.** `carabiner drill` doesn't read configuration — it attacks the
repo. It plants a private key and checks the installed pre-commit hooks actually
stop it; asks GitHub whether push protection is really on; and verifies the
security workflow is a *required* check rather than one that runs, fails, and
merges anyway.

```
$ carabiner drill
  HIGH     DRILL002  pre-commit hooks are configured but NOT installed --
                     the config looks right and nothing runs
  HIGH     DRILL012  the repository default GITHUB_TOKEN is read/WRITE
```

A drill that could not run **never reports as passing** — no token, no network,
no `pre-commit` binary all produce "could NOT be verified", not a green check.
Unverified is not secure. Drills are also never ratcheted: a control that
stopped working is a regression today, not pre-existing debt to accept.

> Most security tools check your configuration. carabiner checks your defenses
> by trying to get past them.

**One normalized model.** Every engine reports into one `Finding`. Deduplicated
across engines, keeping the worse severity — two scanners reporting one CVE is
one finding, and a developer shown the same problem twice trusts the tool less
each time. Emitted as SARIF so findings land in the PR Security tab.

## Adopt it

```bash
carabiner init          # detect, configure, ratchet. Once per repo.
carabiner scan          # what is new. Pre-commit and CI.
carabiner scan --diff   # only what this commit touches. The pre-commit path.
carabiner scan --all    # every engine, whole history. CI cadence.
carabiner drill         # prove the controls fire. After init, and weekly.
carabiner scan --info   # also list informational findings (hidden by default).
carabiner debt          # what you carry, since when, and what is overdue.
carabiner lock --expires 90   # accept it, but only for 90 days.
```

`init` prints every file it will write before writing it, and `--dry-run` writes
nothing. A security tool that silently rewrites your config has no business
asking to be trusted.

## In CI

```yaml
permissions:
  contents: read
  security-events: write

steps:
  - uses: actions/checkout@v4
  - uses: MaXiMo000/carabiner@v0.2.0
  - uses: github/codeql-action/upload-sarif@v3
    with:
      sarif_file: carabiner.sarif
```

Findings land in the PR's Security tab, tracked across commits by the same
stable fingerprint the ratchet uses — so reformatting a file does not report
everything as new.

Add `args: --all --summary carabiner.md` and post that file as a PR comment to
get one short line per PR — `2 new · 1 fixed · 340 accepted` — instead of the
whole backlog restated every time.

## Anywhere else — GitLab CI, Jenkins, CircleCI

```bash
docker run --rm -v "$PWD:/repo:ro" ghcr.io/maximo000/carabiner:0.2.0 scan --all
```

The image bundles gitleaks and osv-scanner, runs as a non-root user, pins its
base by digest, checksum-verifies every binary it downloads, and ships with a
build-provenance attestation.

## As a pre-commit hook

```yaml
repos:
  - repo: https://github.com/MaXiMo000/carabiner
    rev: v0.2.0
    hooks:
      - id: carabiner
```

The fast path measured **0.41s–0.83s** across ten well-known repositories
(requests, flask, fastapi, express, axios, prettier, gin, ripgrep, bat) and
**2.3s** on a 302MB monorepo (next.js). Anything slower gets uninstalled from
pre-commit inside a week, which is why `deps` runs only under `--all`: its OSV
lookups cost 0.7–9.3s on those same repos and are the only thing that ever blew
the budget.

## Engines

| Engine | Checks | Needs |
|---|---|---|
| `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 · CI006 secrets in reach of checked-out contributor code · CI007 self-hosted runners · CI008 `persist-credentials` left on · CI009 `secrets: inherit` across repos · CI010 cache poisoning | nothing |
| `ci` — GitLab CI | GL001 script injection from a merge-request title or branch name · GL002 unpinned remote `include:` · GL003 mutable image and service tags | nothing |
| `repo` | REPO001 `.gitignore` gaps · REPO002 committed key material · REPO003 no disclosure policy · REPO004 credentials in git remotes | nothing |
| `ci` — Jenkins / CircleCI / Azure | JEN001 Groovy interpolation into `sh` · JEN002 literal credential in a pipeline · CIR001 `@volatile` orb · CIR002 pipeline parameter into a run step · AZP001 branch name into a script | nothing |
| `kubernetes` | K8S001 `hostNetwork`/`hostPID`/`hostIPC` · K8S002 privileged container · K8S003 privilege escalation · K8S004 nothing preventing root · K8S005 a literal credential in `env` | nothing |
| `docker` | DOCK001 final stage never drops root · DOCK002 untagged or `:latest` base · DOCK003 credential baked into `ARG`/`ENV` · DOCK004 remote script piped into a shell · DOCK005 TLS verification disabled at build time | nothing |
| `secrets` | working tree every commit; history behind `--all` and one severity higher, because deleting the file is not remediation | `gitleaks` |
| `deps` | lockfile advisories across PyPI, npm, Go, Maven, crates.io and more; ids normalised to CVE so two scanners cannot report one problem twice | `osv-scanner` |

**Severity is calibrated against real repositories.** Across 60 public projects
the default output is a median of **9 findings per repo**; another ~800
informational ones are counted but not listed until you ask with `--info`. A
version tag on an action is informational; a *moving branch* in someone else's
repository is not. A private key under `tests/` is reported lower than one in
`config/`.

A missing scanner degrades to an install hint, never a crash. And a scanner that
*fails* produces a finding saying the check did not happen — a tool that errors
is not a repo that is clean.

## Known limits, stated plainly

- The `ci` engine covers GitHub Actions and GitLab CI. Jenkins, CircleCI and
  Bitbucket get the other engines and nothing from that one.
- The published Docker image is `linux/amd64` only.
- The fast path scans the whole working tree, not just changed files, so a very
  large monorepo can exceed the 2s target.

Tested on Linux and Windows, Python 3.10 and 3.13. `--offline` is enforced by a
test that blocks socket creation and asserts a full scan still completes — the
claim is checked, not documented.

## What it will never do

No SaaS. No dashboard. No account. No telemetry. No AI. No auto-rewriting your
security config. And it never reimplements a scanner that already exists —
the value is the ratchet, the drill, and the normalized model.

Dependencies: PyYAML and the standard library. That is the whole list, on
purpose — every dependency is a package a security auditor now implicitly
vouches for.

## License

MIT. See [SECURITY.md](SECURITY.md) to report a vulnerability.
