git-security-tool 0.1.1__tar.gz → 0.2.0__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.
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.gitignore +3 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/ARCHITECTURE.md +10 -0
- git_security_tool-0.2.0/INSTALL.md +295 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/PKG-INFO +63 -26
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/README.md +62 -25
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/__init__.py +1 -1
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/cli.py +7 -0
- git_security_tool-0.2.0/src/git_security/installer/dependencies.py +15 -0
- git_security_tool-0.2.0/src/git_security/installer/fetch.py +99 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/installer/git_hook.py +5 -3
- git_security_tool-0.2.0/src/git_security/scanners/base.py +67 -0
- git_security_tool-0.2.0/tests/test_base.py +61 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_cli.py +5 -0
- git_security_tool-0.2.0/tests/test_fetch.py +95 -0
- git_security_tool-0.1.1/CLAUDE.md +0 -64
- git_security_tool-0.1.1/src/git_security/installer/dependencies.py +0 -14
- git_security_tool-0.1.1/src/git_security/scanners/base.py +0 -34
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/ci.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/release.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/scan.reusable.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/LICENSE +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/pyproject.toml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/scripts/probe.sh +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/__main__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/baseline.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/config/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/config/loader.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/diff.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/hooks.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/repository.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/ignore.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/installer/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/models/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/models/finding.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/policy/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/policy/engine.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/sarif.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/terminal.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/crypto_tls.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/deserialization.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/filesystem_net.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/injection.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/web.yml +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scan.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/gitleaks.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/ruff.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/semgrep.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/__init__.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/llm.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/providers.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_baseline.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_config.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_diff.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_ignore.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_installer.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_policy.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_providers.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_rules.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_sarif.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_scan.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_scanners.py +0 -0
- {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_suggestions.py +0 -0
|
@@ -195,9 +195,19 @@ forces `0` while still reporting — the dev escape hatch (distinct from
|
|
|
195
195
|
it didn't write unless `--force`, and `uninstall` only removes its own.
|
|
196
196
|
- **`git-security-tool baseline`** — runs a full-repo scan and writes every
|
|
197
197
|
current finding to the baseline file.
|
|
198
|
+
- **`git-security-tool fetch-gitleaks`** — `installer/fetch.py` downloads the
|
|
199
|
+
Gitleaks release for this OS/arch, verifies its SHA-256 against the release
|
|
200
|
+
checksums file, and installs it in `MANAGED_BIN_DIR`
|
|
201
|
+
(`~/.local/share/git-security-tool/bin/`). Gitleaks is a Go binary, so pip
|
|
202
|
+
can't ship it.
|
|
198
203
|
- **`git-security-tool check`** — is the hook installed, which scanners are
|
|
199
204
|
present.
|
|
200
205
|
|
|
206
|
+
**Scanner resolution** (`scanners/base.py::resolve_scanner`) — each wrapper
|
|
207
|
+
calls a tool by bare name; `run_tool` resolves it in order: `MANAGED_BIN_DIR`
|
|
208
|
+
→ next to `sys.executable` (so `ruff`/`semgrep` from the `[scanners]` extra
|
|
209
|
+
are found even inside a pipx-isolated venv that isn't on `PATH`) → `PATH`.
|
|
210
|
+
|
|
201
211
|
---
|
|
202
212
|
|
|
203
213
|
## 5. Optional AI suggestions
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Installing and using git-security-tool
|
|
2
|
+
|
|
3
|
+
A detailed, step-by-step guide. If you just want the short version, see the
|
|
4
|
+
["Add it to a project"](README.md#add-it-to-a-project) section of the README.
|
|
5
|
+
|
|
6
|
+
Two commands to set up your machine, then one command per repository:
|
|
7
|
+
|
|
8
|
+
1. `pipx install "git-security-tool[scanners]"` — the tool + Ruff + Semgrep.
|
|
9
|
+
2. `git-security-tool fetch-gitleaks` — downloads the Gitleaks binary for
|
|
10
|
+
secret detection.
|
|
11
|
+
3. `git-security-tool install` — run once inside each Git repo you want
|
|
12
|
+
protected.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Step 0 — Check the prerequisites
|
|
17
|
+
|
|
18
|
+
You need **Python 3.11 or newer** and **Git**. Check:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 --version # must be 3.11.x or higher
|
|
22
|
+
git --version
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If Python is older than 3.11, install a newer one (`sudo apt install
|
|
26
|
+
python3.12` on Ubuntu, or use [pyenv](https://github.com/pyenv/pyenv)).
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Step 1 — Install the tool
|
|
31
|
+
|
|
32
|
+
The pre-commit hook calls `git-security-tool` by name, so it must be on your
|
|
33
|
+
`PATH` permanently. **pipx** does this cleanly — it installs the tool in its
|
|
34
|
+
own isolated environment and exposes just the command.
|
|
35
|
+
|
|
36
|
+
### Recommended — pipx
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# install pipx itself, once
|
|
40
|
+
python3 -m pip install --user pipx
|
|
41
|
+
python3 -m pipx ensurepath
|
|
42
|
+
# close and reopen your terminal so PATH updates
|
|
43
|
+
|
|
44
|
+
# install the tool - the [scanners] extra also brings Ruff + Semgrep
|
|
45
|
+
pipx install "git-security-tool[scanners]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
You do **not** need to install Ruff or Semgrep separately. They come with the
|
|
49
|
+
`[scanners]` extra, and the tool finds them next to its own interpreter even
|
|
50
|
+
though the pipx environment isn't on your `PATH`.
|
|
51
|
+
|
|
52
|
+
### Alternative — pip into an always-active virtualenv
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 -m venv ~/.venvs/tools && source ~/.venvs/tools/bin/activate
|
|
56
|
+
pip install "git-security-tool[scanners]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Only use this if that venv is active in every shell where you run `git
|
|
60
|
+
commit` (add the `source` line to `~/.bashrc`).
|
|
61
|
+
|
|
62
|
+
### Verify
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git-security-tool version # e.g. git-security-tool 0.2.0
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you get `command not found`, reopen your terminal (pipx just changed
|
|
69
|
+
`PATH`), or run `python3 -m pipx ensurepath` again.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Step 2 — Get Gitleaks (for secret detection)
|
|
74
|
+
|
|
75
|
+
Gitleaks is a Go binary, not a Python package. One command downloads it:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
git-security-tool fetch-gitleaks
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This detects your OS and CPU, downloads the matching release from GitHub,
|
|
82
|
+
**verifies its SHA-256 checksum**, and installs it in
|
|
83
|
+
`~/.local/share/git-security-tool/bin/`. Output:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
[git-security-tool] downloading gitleaks_8.30.1_linux_x64.tar.gz ...
|
|
87
|
+
[git-security-tool] sha-256 verified
|
|
88
|
+
[git-security-tool] installed gitleaks 8.30.1 -> ~/.local/share/git-security-tool/bin/gitleaks
|
|
89
|
+
[git-security-tool] secret scanning is now active
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The tool works without Gitleaks — it just can't catch leaked API keys /
|
|
93
|
+
tokens then.
|
|
94
|
+
|
|
95
|
+
### Verify everything is visible
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git-security-tool check
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
You'll see which scanners are present:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
[git-security-tool] pre-commit hook: not installed
|
|
105
|
+
[git-security-tool] scanners:
|
|
106
|
+
ruff: ok
|
|
107
|
+
gitleaks: ok
|
|
108
|
+
semgrep: ok
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Any `missing` here just means that check is skipped — install it if you want
|
|
112
|
+
that coverage.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Step 3 — Turn on the hook in a repository
|
|
117
|
+
|
|
118
|
+
Do this **once per repo** (the hook lives in `.git/hooks/`, which is not
|
|
119
|
+
committed, so a fresh `git clone` needs this again).
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cd ~/path/to/your-project
|
|
123
|
+
git-security-tool install
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Output:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
[git-security-tool] installed pre-commit hook at .../.git/hooks/pre-commit
|
|
130
|
+
ruff: ok
|
|
131
|
+
gitleaks: ok
|
|
132
|
+
semgrep: ok
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If the repo already has a pre-commit hook that this tool didn't create, it
|
|
136
|
+
will refuse and tell you to re-run with `--force` (which replaces it).
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Step 4 — See it work
|
|
141
|
+
|
|
142
|
+
Now every `git commit` runs the scan first.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# make a file with an obvious problem
|
|
146
|
+
printf 'eval(input())\n' > _probe.py
|
|
147
|
+
git add _probe.py
|
|
148
|
+
git commit -m "test"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Expected — the commit is **blocked**:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
[git-security-tool] pre-commit security scan
|
|
155
|
+
[git-security-tool] 1 file(s) to scan
|
|
156
|
+
[git-security-tool] 1 blocking finding(s):
|
|
157
|
+
[HIGH] semgrep:python-dangerous-eval _probe.py:1 - eval() executes arbitrary
|
|
158
|
+
code from its argument ...
|
|
159
|
+
[git-security-tool] commit blocked - fix the blocking findings above, or set
|
|
160
|
+
GIT_SECURITY_NO_BLOCK=1 to override
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Clean up the probe:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
git restore --staged _probe.py && rm _probe.py
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A normal commit with no problems just passes through with
|
|
170
|
+
`commit allowed`.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Step 5 — Adopting an existing project (optional but recommended)
|
|
175
|
+
|
|
176
|
+
An older codebase probably has findings already. You don't want to be blocked
|
|
177
|
+
on day one for issues you didn't just write.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
cd ~/path/to/your-project
|
|
181
|
+
|
|
182
|
+
# 1. see what's there
|
|
183
|
+
git-security-tool scan --all
|
|
184
|
+
|
|
185
|
+
# 2. if there's a lot, record it as a "baseline" - those get grandfathered
|
|
186
|
+
git-security-tool baseline
|
|
187
|
+
|
|
188
|
+
# 3. commit the baseline file
|
|
189
|
+
git add .git-security-tool-baseline.json
|
|
190
|
+
git commit -m "add git-security-tool baseline"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
From now on, **new** problems block; the recorded ones are ignored. As you
|
|
194
|
+
fix them, regenerate the baseline (`git-security-tool baseline` again) to
|
|
195
|
+
shrink it.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Step 6 — Configuration (optional)
|
|
200
|
+
|
|
201
|
+
Create `.git-security-tool.toml` in the repo root:
|
|
202
|
+
|
|
203
|
+
```toml
|
|
204
|
+
[policy]
|
|
205
|
+
# what severity blocks a commit. HIGH (default) blocks secrets + RCE-class
|
|
206
|
+
# code. Use "CRITICAL" to only block secrets while you get used to it.
|
|
207
|
+
block_threshold = "HIGH"
|
|
208
|
+
|
|
209
|
+
[scanners]
|
|
210
|
+
# turn a scanner off entirely
|
|
211
|
+
# gitleaks = false
|
|
212
|
+
|
|
213
|
+
[ignore]
|
|
214
|
+
# skip generated code, migrations, vendored libraries, fixtures
|
|
215
|
+
paths = ["migrations/", "vendor/", "tests/fixtures/", "*.generated.py"]
|
|
216
|
+
|
|
217
|
+
[ai]
|
|
218
|
+
# optional: ask an LLM to explain and suggest a fix for blocking findings.
|
|
219
|
+
# off by default. also needs an API key in your environment.
|
|
220
|
+
enabled = false
|
|
221
|
+
provider = "gemini" # or "anthropic"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Commit this file — it's meant to be shared across the team.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Step 7 — Add the CI check
|
|
229
|
+
|
|
230
|
+
The local hook can be skipped (`git commit --no-verify`) or simply not
|
|
231
|
+
installed on a teammate's machine. CI is the layer nobody can bypass.
|
|
232
|
+
|
|
233
|
+
Create `.github/workflows/security.yml` in your project:
|
|
234
|
+
|
|
235
|
+
```yaml
|
|
236
|
+
name: security
|
|
237
|
+
on: [push, pull_request]
|
|
238
|
+
|
|
239
|
+
jobs:
|
|
240
|
+
security:
|
|
241
|
+
uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Commit and push it. GitHub will run `git-security-tool scan --all` over the
|
|
245
|
+
whole repo on every push and pull request, and the check fails if there's a
|
|
246
|
+
blocking finding.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Troubleshooting
|
|
251
|
+
|
|
252
|
+
**`git commit` doesn't run any scan / no `[git-security-tool]` output**
|
|
253
|
+
The hook isn't installed, or the tool isn't on `PATH` in that shell. Run
|
|
254
|
+
`git-security-tool check` and `which git-security-tool`. If `check` says
|
|
255
|
+
"not installed", run `git-security-tool install`.
|
|
256
|
+
|
|
257
|
+
**Hook prints "git-security-tool not on PATH - skipping scan"**
|
|
258
|
+
The hook ran but couldn't find the command itself. This happens if you
|
|
259
|
+
installed into a virtualenv that isn't active, or `~/.local/bin` isn't on
|
|
260
|
+
`PATH`. Use pipx (Step 1), or add the install location to `~/.bashrc`.
|
|
261
|
+
|
|
262
|
+
**`check` says `ruff: missing` or `semgrep: missing` after installing**
|
|
263
|
+
You installed without the `[scanners]` extra. Reinstall:
|
|
264
|
+
`pipx install --force "git-security-tool[scanners]"`. You should *not* need
|
|
265
|
+
to install Ruff or Semgrep separately — the tool locates them next to its
|
|
266
|
+
own interpreter.
|
|
267
|
+
|
|
268
|
+
**`check` says `gitleaks: missing`**
|
|
269
|
+
Run `git-security-tool fetch-gitleaks`.
|
|
270
|
+
|
|
271
|
+
**Committing from VS Code / a Git GUI does nothing**
|
|
272
|
+
GUI apps sometimes don't inherit your shell `PATH`. Install with pipx so the
|
|
273
|
+
command lives in `~/.local/bin`, restart the app, and make sure that
|
|
274
|
+
directory is on the system `PATH`.
|
|
275
|
+
|
|
276
|
+
**`command not found: git-security-tool` right after installing**
|
|
277
|
+
Reopen your terminal (pipx updates `PATH` in a way the current shell hasn't
|
|
278
|
+
picked up), or run `python3 -m pipx ensurepath` again.
|
|
279
|
+
|
|
280
|
+
**A commit is blocked and I need to commit anyway (emergency)**
|
|
281
|
+
```bash
|
|
282
|
+
GIT_SECURITY_NO_BLOCK=1 git commit -m "..." # runs the scan, reports, doesn't block
|
|
283
|
+
git commit --no-verify -m "..." # skips the hook entirely
|
|
284
|
+
```
|
|
285
|
+
Use these sparingly — CI will still catch it.
|
|
286
|
+
|
|
287
|
+
**Remove the tool from a repo**
|
|
288
|
+
```bash
|
|
289
|
+
git-security-tool uninstall
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Uninstall completely**
|
|
293
|
+
```bash
|
|
294
|
+
pipx uninstall git-security-tool # or: pip uninstall git-security-tool
|
|
295
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: git-security-tool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local Git security and code-quality gate that runs on pre-commit.
|
|
5
5
|
Project-URL: Homepage, https://github.com/MustafaBasit521/commit-guard
|
|
6
6
|
Project-URL: Issues, https://github.com/MustafaBasit521/commit-guard/issues
|
|
@@ -72,26 +72,78 @@ $ git commit -m "add config loader"
|
|
|
72
72
|
GIT_SECURITY_NO_BLOCK=1 to override
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
##
|
|
75
|
+
## Add it to a project
|
|
76
|
+
|
|
77
|
+
> New to this? [**INSTALL.md**](INSTALL.md) is a slower, detailed walkthrough
|
|
78
|
+
> with expected output and troubleshooting. The steps below are the short
|
|
79
|
+
> version.
|
|
80
|
+
|
|
81
|
+
### 1. Install (once per machine)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pipx install "git-security-tool[scanners]" # the tool + ruff + semgrep, bundled
|
|
85
|
+
git-security-tool fetch-gitleaks # downloads the Gitleaks binary (SHA-256 verified)
|
|
86
|
+
git-security-tool check # confirm all three scanners show "ok"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
[pipx](https://pipx.pypa.io/) keeps the command on your `PATH` in an isolated
|
|
90
|
+
environment. `pip install "git-security-tool[scanners]"` into an
|
|
91
|
+
always-active virtualenv works too. `ruff` and `semgrep` come with the
|
|
92
|
+
`[scanners]` extra and the tool finds them automatically — even inside the
|
|
93
|
+
pipx venv. Gitleaks is a Go binary, so `fetch-gitleaks` pulls it into
|
|
94
|
+
`~/.local/share/git-security-tool/bin/`; the scan skips it if you don't.
|
|
95
|
+
|
|
96
|
+
### 2. Turn on the hook (once per repo)
|
|
76
97
|
|
|
77
98
|
```bash
|
|
78
|
-
pip install "git-security-tool[scanners]" # tool + ruff + semgrep
|
|
79
99
|
cd your-repo
|
|
80
|
-
git-security-tool install
|
|
100
|
+
git-security-tool install # writes .git/hooks/pre-commit
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`.git/hooks/` is not version-controlled, so every clone / machine runs this
|
|
104
|
+
once. `git-security-tool uninstall` removes it; `--force` replaces a hook
|
|
105
|
+
you didn't create.
|
|
106
|
+
|
|
107
|
+
### 3. (existing repo) Baseline what's already there
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
git-security-tool scan --all # see what it finds first
|
|
111
|
+
git-security-tool baseline # grandfather those findings
|
|
112
|
+
git add .git-security-tool-baseline.json && git commit -m "add security baseline"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
New issues still block — only the pre-existing ones are ignored.
|
|
116
|
+
|
|
117
|
+
### 4. Add the CI check
|
|
118
|
+
|
|
119
|
+
Local hooks can be skipped (`--no-verify`) or missing on a teammate's
|
|
120
|
+
machine, so CI is the real enforcement. Create
|
|
121
|
+
`.github/workflows/security.yml`:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
name: security
|
|
125
|
+
on: [push, pull_request]
|
|
126
|
+
jobs:
|
|
127
|
+
security:
|
|
128
|
+
uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
81
129
|
```
|
|
82
130
|
|
|
83
|
-
|
|
84
|
-
|
|
131
|
+
That reusable workflow installs the tool + Gitleaks and runs
|
|
132
|
+
`git-security-tool scan --all` over the whole repo.
|
|
85
133
|
|
|
86
|
-
|
|
87
|
-
|
|
134
|
+
### 5. (optional) Tune it — `.git-security-tool.toml`
|
|
135
|
+
|
|
136
|
+
See [Configuration](#configuration--git-security-tooltoml-optional-repo-root)
|
|
137
|
+
below: severity threshold, disable a scanner, ignore paths, AI suggestions.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
**Commands:** `scan [--all] [--format sarif]`, `baseline`, `fetch-gitleaks`,
|
|
142
|
+
`install [--force]`, `uninstall`, `check`, `version`.
|
|
88
143
|
|
|
89
144
|
- `scan` — staged changes (pre-commit)
|
|
90
145
|
- `scan --all` — every tracked file (CI / audit); `--format sarif` emits SARIF
|
|
91
146
|
on stdout for GitHub code scanning
|
|
92
|
-
- `baseline` — records current findings to `.git-security-tool-baseline.json`
|
|
93
|
-
so an existing repo can adopt the tool without fixing everything first;
|
|
94
|
-
new issues still block
|
|
95
147
|
|
|
96
148
|
Verify the ruleset end to end: `./scripts/probe.sh` (checks all 17 rules +
|
|
97
149
|
secret detection in a throwaway repo).
|
|
@@ -142,21 +194,6 @@ to the API, and secret-bearing files are never sent.
|
|
|
142
194
|
| `gemini` | `GEMINI_API_KEY` | none (stdlib HTTP) |
|
|
143
195
|
| `anthropic` | `ANTHROPIC_API_KEY` | `pip install "git-security-tool[ai]"` |
|
|
144
196
|
|
|
145
|
-
## CI (the second layer)
|
|
146
|
-
|
|
147
|
-
Local hooks can be skipped (`--no-verify`) or simply not installed on a
|
|
148
|
-
teammate's machine, so CI is the enforcement layer. This repo ships a
|
|
149
|
-
reusable workflow:
|
|
150
|
-
|
|
151
|
-
```yaml
|
|
152
|
-
# .github/workflows/security.yml in your project
|
|
153
|
-
name: security
|
|
154
|
-
on: [push, pull_request]
|
|
155
|
-
jobs:
|
|
156
|
-
security:
|
|
157
|
-
uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
158
|
-
```
|
|
159
|
-
|
|
160
197
|
## Overrides
|
|
161
198
|
|
|
162
199
|
- `GIT_SECURITY_NO_BLOCK=1 git commit …` — run the scan, report, never block.
|
|
@@ -43,26 +43,78 @@ $ git commit -m "add config loader"
|
|
|
43
43
|
GIT_SECURITY_NO_BLOCK=1 to override
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
##
|
|
46
|
+
## Add it to a project
|
|
47
|
+
|
|
48
|
+
> New to this? [**INSTALL.md**](INSTALL.md) is a slower, detailed walkthrough
|
|
49
|
+
> with expected output and troubleshooting. The steps below are the short
|
|
50
|
+
> version.
|
|
51
|
+
|
|
52
|
+
### 1. Install (once per machine)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install "git-security-tool[scanners]" # the tool + ruff + semgrep, bundled
|
|
56
|
+
git-security-tool fetch-gitleaks # downloads the Gitleaks binary (SHA-256 verified)
|
|
57
|
+
git-security-tool check # confirm all three scanners show "ok"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
[pipx](https://pipx.pypa.io/) keeps the command on your `PATH` in an isolated
|
|
61
|
+
environment. `pip install "git-security-tool[scanners]"` into an
|
|
62
|
+
always-active virtualenv works too. `ruff` and `semgrep` come with the
|
|
63
|
+
`[scanners]` extra and the tool finds them automatically — even inside the
|
|
64
|
+
pipx venv. Gitleaks is a Go binary, so `fetch-gitleaks` pulls it into
|
|
65
|
+
`~/.local/share/git-security-tool/bin/`; the scan skips it if you don't.
|
|
66
|
+
|
|
67
|
+
### 2. Turn on the hook (once per repo)
|
|
47
68
|
|
|
48
69
|
```bash
|
|
49
|
-
pip install "git-security-tool[scanners]" # tool + ruff + semgrep
|
|
50
70
|
cd your-repo
|
|
51
|
-
git-security-tool install
|
|
71
|
+
git-security-tool install # writes .git/hooks/pre-commit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`.git/hooks/` is not version-controlled, so every clone / machine runs this
|
|
75
|
+
once. `git-security-tool uninstall` removes it; `--force` replaces a hook
|
|
76
|
+
you didn't create.
|
|
77
|
+
|
|
78
|
+
### 3. (existing repo) Baseline what's already there
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git-security-tool scan --all # see what it finds first
|
|
82
|
+
git-security-tool baseline # grandfather those findings
|
|
83
|
+
git add .git-security-tool-baseline.json && git commit -m "add security baseline"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
New issues still block — only the pre-existing ones are ignored.
|
|
87
|
+
|
|
88
|
+
### 4. Add the CI check
|
|
89
|
+
|
|
90
|
+
Local hooks can be skipped (`--no-verify`) or missing on a teammate's
|
|
91
|
+
machine, so CI is the real enforcement. Create
|
|
92
|
+
`.github/workflows/security.yml`:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
name: security
|
|
96
|
+
on: [push, pull_request]
|
|
97
|
+
jobs:
|
|
98
|
+
security:
|
|
99
|
+
uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
52
100
|
```
|
|
53
101
|
|
|
54
|
-
|
|
55
|
-
|
|
102
|
+
That reusable workflow installs the tool + Gitleaks and runs
|
|
103
|
+
`git-security-tool scan --all` over the whole repo.
|
|
56
104
|
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
### 5. (optional) Tune it — `.git-security-tool.toml`
|
|
106
|
+
|
|
107
|
+
See [Configuration](#configuration--git-security-tooltoml-optional-repo-root)
|
|
108
|
+
below: severity threshold, disable a scanner, ignore paths, AI suggestions.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
**Commands:** `scan [--all] [--format sarif]`, `baseline`, `fetch-gitleaks`,
|
|
113
|
+
`install [--force]`, `uninstall`, `check`, `version`.
|
|
59
114
|
|
|
60
115
|
- `scan` — staged changes (pre-commit)
|
|
61
116
|
- `scan --all` — every tracked file (CI / audit); `--format sarif` emits SARIF
|
|
62
117
|
on stdout for GitHub code scanning
|
|
63
|
-
- `baseline` — records current findings to `.git-security-tool-baseline.json`
|
|
64
|
-
so an existing repo can adopt the tool without fixing everything first;
|
|
65
|
-
new issues still block
|
|
66
118
|
|
|
67
119
|
Verify the ruleset end to end: `./scripts/probe.sh` (checks all 17 rules +
|
|
68
120
|
secret detection in a throwaway repo).
|
|
@@ -113,21 +165,6 @@ to the API, and secret-bearing files are never sent.
|
|
|
113
165
|
| `gemini` | `GEMINI_API_KEY` | none (stdlib HTTP) |
|
|
114
166
|
| `anthropic` | `ANTHROPIC_API_KEY` | `pip install "git-security-tool[ai]"` |
|
|
115
167
|
|
|
116
|
-
## CI (the second layer)
|
|
117
|
-
|
|
118
|
-
Local hooks can be skipped (`--no-verify`) or simply not installed on a
|
|
119
|
-
teammate's machine, so CI is the enforcement layer. This repo ships a
|
|
120
|
-
reusable workflow:
|
|
121
|
-
|
|
122
|
-
```yaml
|
|
123
|
-
# .github/workflows/security.yml in your project
|
|
124
|
-
name: security
|
|
125
|
-
on: [push, pull_request]
|
|
126
|
-
jobs:
|
|
127
|
-
security:
|
|
128
|
-
uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
129
|
-
```
|
|
130
|
-
|
|
131
168
|
## Overrides
|
|
132
169
|
|
|
133
170
|
- `GIT_SECURITY_NO_BLOCK=1 git commit …` — run the scan, report, never block.
|
|
@@ -8,6 +8,7 @@ for the handful of subcommands we have - no CLI framework needed yet.
|
|
|
8
8
|
import argparse
|
|
9
9
|
|
|
10
10
|
from git_security import __version__
|
|
11
|
+
from git_security.installer.fetch import fetch_gitleaks
|
|
11
12
|
from git_security.installer.git_hook import install, status, uninstall
|
|
12
13
|
from git_security.scan import run_scan, write_baseline_file
|
|
13
14
|
|
|
@@ -55,6 +56,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
55
56
|
"uninstall", help="remove the pre-commit hook from this repository"
|
|
56
57
|
)
|
|
57
58
|
subparsers.add_parser("check", help="report pre-commit hook and scanner status")
|
|
59
|
+
subparsers.add_parser(
|
|
60
|
+
"fetch-gitleaks",
|
|
61
|
+
help="download the Gitleaks binary (checksum-verified) for secret scanning",
|
|
62
|
+
)
|
|
58
63
|
subparsers.add_parser("version", help="print the version and exit")
|
|
59
64
|
return parser
|
|
60
65
|
|
|
@@ -75,6 +80,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
75
80
|
return uninstall()
|
|
76
81
|
if args.command == "check":
|
|
77
82
|
return status()
|
|
83
|
+
if args.command == "fetch-gitleaks":
|
|
84
|
+
return fetch_gitleaks()
|
|
78
85
|
if args.command == "version":
|
|
79
86
|
print(f"git-security-tool {__version__}")
|
|
80
87
|
return 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Check which external scanners are available.
|
|
2
|
+
|
|
3
|
+
A scanner counts as available if it is in the tool's managed bin directory,
|
|
4
|
+
next to the running interpreter, or on PATH (see ``resolve_scanner``).
|
|
5
|
+
Missing scanners are skipped at scan time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from git_security.scanners.base import resolve_scanner
|
|
9
|
+
|
|
10
|
+
SCANNERS = ("ruff", "gitleaks", "semgrep")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_dependencies() -> dict[str, bool]:
|
|
14
|
+
"""Map each scanner name to whether its executable can be found."""
|
|
15
|
+
return {name: resolve_scanner(name) is not None for name in SCANNERS}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Download the Gitleaks binary into the tool's managed bin directory.
|
|
2
|
+
|
|
3
|
+
Gitleaks is a Go program, not a Python package, so ``pip install`` can't
|
|
4
|
+
pull it. ``git-security-tool fetch-gitleaks`` grabs the matching release
|
|
5
|
+
from GitHub, verifies its SHA-256 against the release checksums file, and
|
|
6
|
+
installs it where the scanner wrappers already look (``MANAGED_BIN_DIR``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import io
|
|
11
|
+
import platform
|
|
12
|
+
import tarfile
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from git_security.scanners.base import MANAGED_BIN_DIR
|
|
17
|
+
|
|
18
|
+
_PREFIX = "[git-security-tool]"
|
|
19
|
+
_VERSION = "8.30.1"
|
|
20
|
+
_BASE = f"https://github.com/gitleaks/gitleaks/releases/download/v{_VERSION}"
|
|
21
|
+
|
|
22
|
+
_ARCH = {
|
|
23
|
+
"x86_64": "x64",
|
|
24
|
+
"amd64": "x64",
|
|
25
|
+
"aarch64": "arm64",
|
|
26
|
+
"arm64": "arm64",
|
|
27
|
+
"armv7l": "armv7",
|
|
28
|
+
}
|
|
29
|
+
_OS = {"Linux": "linux", "Darwin": "darwin"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _asset_name() -> str:
|
|
33
|
+
system = platform.system()
|
|
34
|
+
machine = platform.machine().lower()
|
|
35
|
+
if system not in _OS:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"fetch-gitleaks supports Linux and macOS, not {system!r} - "
|
|
38
|
+
"on Windows use WSL, or install gitleaks manually"
|
|
39
|
+
)
|
|
40
|
+
if machine not in _ARCH:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"no gitleaks build for architecture {machine!r} - install it from "
|
|
43
|
+
"https://github.com/gitleaks/gitleaks/releases"
|
|
44
|
+
)
|
|
45
|
+
return f"gitleaks_{_VERSION}_{_OS[system]}_{_ARCH[machine]}.tar.gz"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _download(url: str) -> bytes:
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 - pinned https
|
|
51
|
+
return resp.read()
|
|
52
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
53
|
+
raise RuntimeError(f"download failed ({url}): {exc}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _expected_sha(checksums: bytes, asset: str) -> str:
|
|
57
|
+
for line in checksums.decode().splitlines():
|
|
58
|
+
parts = line.split()
|
|
59
|
+
if len(parts) == 2 and parts[1] == asset:
|
|
60
|
+
return parts[0]
|
|
61
|
+
raise RuntimeError(f"{asset} is not listed in the release checksums file")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_binary(tarball: bytes) -> bytes:
|
|
65
|
+
with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as archive:
|
|
66
|
+
member = archive.extractfile("gitleaks") # one named file, no extractall
|
|
67
|
+
if member is None:
|
|
68
|
+
raise RuntimeError("archive did not contain a 'gitleaks' file")
|
|
69
|
+
return member.read()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_gitleaks() -> int:
|
|
73
|
+
try:
|
|
74
|
+
asset = _asset_name()
|
|
75
|
+
print(f"{_PREFIX} downloading {asset} ...")
|
|
76
|
+
tarball = _download(f"{_BASE}/{asset}")
|
|
77
|
+
checksums = _download(f"{_BASE}/gitleaks_{_VERSION}_checksums.txt")
|
|
78
|
+
|
|
79
|
+
expected = _expected_sha(checksums, asset)
|
|
80
|
+
actual = hashlib.sha256(tarball).hexdigest()
|
|
81
|
+
if actual != expected:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"checksum mismatch for {asset}\n"
|
|
84
|
+
f" expected {expected}\n got {actual}"
|
|
85
|
+
)
|
|
86
|
+
print(f"{_PREFIX} sha-256 verified")
|
|
87
|
+
|
|
88
|
+
binary = _extract_binary(tarball)
|
|
89
|
+
except RuntimeError as exc:
|
|
90
|
+
print(f"{_PREFIX} fetch-gitleaks failed: {exc}")
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
MANAGED_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
dest = MANAGED_BIN_DIR / "gitleaks"
|
|
95
|
+
dest.write_bytes(binary)
|
|
96
|
+
dest.chmod(0o755)
|
|
97
|
+
print(f"{_PREFIX} installed gitleaks {_VERSION} -> {dest}")
|
|
98
|
+
print(f"{_PREFIX} secret scanning is now active")
|
|
99
|
+
return 0
|
|
@@ -47,10 +47,12 @@ def _print_dependencies() -> None:
|
|
|
47
47
|
deps = check_dependencies()
|
|
48
48
|
for name, present in deps.items():
|
|
49
49
|
print(f" {name}: {'ok' if present else 'missing'}")
|
|
50
|
-
if not
|
|
50
|
+
if not deps.get("gitleaks", True):
|
|
51
|
+
print(f"{_PREFIX} run `git-security-tool fetch-gitleaks` to enable secrets")
|
|
52
|
+
if not deps.get("ruff", True) or not deps.get("semgrep", True):
|
|
51
53
|
print(
|
|
52
|
-
f"{_PREFIX}
|
|
53
|
-
|
|
54
|
+
f"{_PREFIX} for ruff/semgrep, reinstall with: "
|
|
55
|
+
'pipx install "git-security-tool[scanners]"'
|
|
54
56
|
)
|
|
55
57
|
|
|
56
58
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared plumbing for scanner wrappers.
|
|
2
|
+
|
|
3
|
+
Each scanner module owns the tool-specific knowledge: which command to run,
|
|
4
|
+
how to read its exit codes, how to map its output to ``Finding``. What every
|
|
5
|
+
scanner shares - find the executable, run it, cope if it isn't installed, and
|
|
6
|
+
report paths consistently - lives here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Where `git-security-tool fetch-gitleaks` puts the binary it downloads.
|
|
16
|
+
_XDG_DATA = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
17
|
+
MANAGED_BIN_DIR = Path(_XDG_DATA) / "git-security-tool" / "bin"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_scanner(name: str) -> str | None:
|
|
21
|
+
"""Locate a scanner executable, or ``None`` if it isn't anywhere we look.
|
|
22
|
+
|
|
23
|
+
Order:
|
|
24
|
+
1. ``MANAGED_BIN_DIR`` - binaries this tool downloaded itself.
|
|
25
|
+
2. Next to the running interpreter - ``ruff`` / ``semgrep`` installed as
|
|
26
|
+
dependencies land in the same ``bin/`` as our Python, even inside a
|
|
27
|
+
pipx-isolated venv that isn't on the user's PATH.
|
|
28
|
+
3. The user's ``PATH``.
|
|
29
|
+
"""
|
|
30
|
+
exe = MANAGED_BIN_DIR / name
|
|
31
|
+
if exe.is_file() and os.access(exe, os.X_OK):
|
|
32
|
+
return str(exe)
|
|
33
|
+
|
|
34
|
+
sibling = Path(sys.executable).parent / name
|
|
35
|
+
if sibling.is_file() and os.access(sibling, os.X_OK):
|
|
36
|
+
return str(sibling)
|
|
37
|
+
|
|
38
|
+
return shutil.which(name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
42
|
+
"""Run an external tool with stdout/stderr captured.
|
|
43
|
+
|
|
44
|
+
``cmd[0]`` may be a bare name or an absolute path. Returns the
|
|
45
|
+
``CompletedProcess`` for any exit code, or ``None`` if the executable
|
|
46
|
+
cannot be found - so callers skip a missing tool cleanly.
|
|
47
|
+
"""
|
|
48
|
+
resolved = resolve_scanner(cmd[0]) if not Path(cmd[0]).is_absolute() else cmd[0]
|
|
49
|
+
if resolved is None:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
[resolved, *cmd[1:]], capture_output=True, text=True, check=False
|
|
54
|
+
)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_repo_relative(path: str, repo_root: Path) -> str:
|
|
60
|
+
"""Make an absolute path repo-relative; leave anything else untouched."""
|
|
61
|
+
p = Path(path)
|
|
62
|
+
if not p.is_absolute():
|
|
63
|
+
return path
|
|
64
|
+
try:
|
|
65
|
+
return str(p.relative_to(repo_root))
|
|
66
|
+
except ValueError:
|
|
67
|
+
return path
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for scanner resolution (managed dir > next-to-python > PATH)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from git_security.scanners import base
|
|
6
|
+
from git_security.scanners.base import resolve_scanner, run_tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_exe(path):
|
|
10
|
+
path.write_text("#!/bin/sh\necho hi\n")
|
|
11
|
+
path.chmod(0o755)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_prefers_managed_bin_dir(tmp_path, monkeypatch):
|
|
15
|
+
managed = tmp_path / "managed"
|
|
16
|
+
managed.mkdir()
|
|
17
|
+
_make_exe(managed / "ruff")
|
|
18
|
+
monkeypatch.setattr(base, "MANAGED_BIN_DIR", managed)
|
|
19
|
+
|
|
20
|
+
assert resolve_scanner("ruff") == str(managed / "ruff")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_falls_back_to_next_to_interpreter(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.setattr(base, "MANAGED_BIN_DIR", tmp_path / "empty")
|
|
25
|
+
fake_bin = tmp_path / "venv" / "bin"
|
|
26
|
+
fake_bin.mkdir(parents=True)
|
|
27
|
+
_make_exe(fake_bin / "semgrep")
|
|
28
|
+
monkeypatch.setattr(base.sys, "executable", str(fake_bin / "python"))
|
|
29
|
+
|
|
30
|
+
assert resolve_scanner("semgrep") == str(fake_bin / "semgrep")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_returns_none_when_nowhere(tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.setattr(base, "MANAGED_BIN_DIR", tmp_path / "empty")
|
|
35
|
+
monkeypatch.setattr(base.sys, "executable", str(tmp_path / "nope" / "python"))
|
|
36
|
+
monkeypatch.setattr(base.shutil, "which", lambda _name: None)
|
|
37
|
+
|
|
38
|
+
assert resolve_scanner("definitely-not-a-real-tool") is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_run_tool_returns_none_for_missing(monkeypatch):
|
|
42
|
+
monkeypatch.setattr(base, "resolve_scanner", lambda _name: None)
|
|
43
|
+
assert run_tool(["ghost-tool", "--version"]) is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_run_tool_runs_resolved(tmp_path, monkeypatch):
|
|
47
|
+
exe = tmp_path / "echoer"
|
|
48
|
+
exe.write_text("#!/bin/sh\nprintf ok\n")
|
|
49
|
+
exe.chmod(0o755)
|
|
50
|
+
monkeypatch.setattr(base, "resolve_scanner", lambda _name: str(exe))
|
|
51
|
+
|
|
52
|
+
proc = run_tool(["echoer"])
|
|
53
|
+
assert proc is not None
|
|
54
|
+
assert proc.stdout == "ok"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_managed_dir_respects_xdg(monkeypatch):
|
|
58
|
+
# sanity: the module constant is under a data dir, not the repo
|
|
59
|
+
assert base.MANAGED_BIN_DIR.name == "bin"
|
|
60
|
+
assert "git-security-tool" in str(base.MANAGED_BIN_DIR)
|
|
61
|
+
assert os.path.isabs(base.MANAGED_BIN_DIR)
|
|
@@ -43,6 +43,11 @@ def test_baseline_subcommand_parses():
|
|
|
43
43
|
assert args.command == "baseline"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def test_fetch_gitleaks_subcommand_parses():
|
|
47
|
+
args = build_parser().parse_args(["fetch-gitleaks"])
|
|
48
|
+
assert args.command == "fetch-gitleaks"
|
|
49
|
+
|
|
50
|
+
|
|
46
51
|
def test_install_parses_force_flag():
|
|
47
52
|
args = build_parser().parse_args(["install", "--force"])
|
|
48
53
|
assert args.command == "install"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tests for `git-security-tool fetch-gitleaks` (no real network)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import tarfile
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from git_security.installer import fetch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_tarball(binary: bytes = b"#!/bin/sh\necho gitleaks\n") -> bytes:
|
|
13
|
+
buf = io.BytesIO()
|
|
14
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
15
|
+
info = tarfile.TarInfo(name="gitleaks")
|
|
16
|
+
info.size = len(binary)
|
|
17
|
+
tf.addfile(info, io.BytesIO(binary))
|
|
18
|
+
return buf.getvalue()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- helpers ---------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_asset_name_linux_x64(monkeypatch):
|
|
25
|
+
monkeypatch.setattr(fetch.platform, "system", lambda: "Linux")
|
|
26
|
+
monkeypatch.setattr(fetch.platform, "machine", lambda: "x86_64")
|
|
27
|
+
assert fetch._asset_name() == f"gitleaks_{fetch._VERSION}_linux_x64.tar.gz"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_asset_name_mac_arm(monkeypatch):
|
|
31
|
+
monkeypatch.setattr(fetch.platform, "system", lambda: "Darwin")
|
|
32
|
+
monkeypatch.setattr(fetch.platform, "machine", lambda: "arm64")
|
|
33
|
+
assert fetch._asset_name() == f"gitleaks_{fetch._VERSION}_darwin_arm64.tar.gz"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_asset_name_rejects_windows(monkeypatch):
|
|
37
|
+
monkeypatch.setattr(fetch.platform, "system", lambda: "Windows")
|
|
38
|
+
monkeypatch.setattr(fetch.platform, "machine", lambda: "AMD64")
|
|
39
|
+
with pytest.raises(RuntimeError, match="WSL"):
|
|
40
|
+
fetch._asset_name()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_expected_sha_parsing():
|
|
44
|
+
blob = b"abc123 gitleaks_x_linux_x64.tar.gz\ndef456 other.tar.gz\n"
|
|
45
|
+
assert fetch._expected_sha(blob, "gitleaks_x_linux_x64.tar.gz") == "abc123"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_expected_sha_missing_asset():
|
|
49
|
+
with pytest.raises(RuntimeError, match="checksums"):
|
|
50
|
+
fetch._expected_sha(b"abc something-else.tar.gz\n", "wanted.tar.gz")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_extract_binary_roundtrip():
|
|
54
|
+
assert fetch._extract_binary(_make_tarball(b"BODY")) == b"BODY"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- full flow (network mocked) -----------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def offline(monkeypatch, tmp_path):
|
|
62
|
+
monkeypatch.setattr(fetch, "MANAGED_BIN_DIR", tmp_path / "bin")
|
|
63
|
+
monkeypatch.setattr(fetch.platform, "system", lambda: "Linux")
|
|
64
|
+
monkeypatch.setattr(fetch.platform, "machine", lambda: "x86_64")
|
|
65
|
+
return tmp_path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_fetch_installs_verified_binary(offline, monkeypatch, capsys):
|
|
69
|
+
tarball = _make_tarball(b"the-real-binary")
|
|
70
|
+
asset = f"gitleaks_{fetch._VERSION}_linux_x64.tar.gz"
|
|
71
|
+
checksums = f"{hashlib.sha256(tarball).hexdigest()} {asset}\n".encode()
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(
|
|
74
|
+
fetch, "_download", lambda url: checksums if "checksums" in url else tarball
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert fetch.fetch_gitleaks() == 0
|
|
78
|
+
installed = offline / "bin" / "gitleaks"
|
|
79
|
+
assert installed.read_bytes() == b"the-real-binary"
|
|
80
|
+
assert installed.stat().st_mode & 0o111
|
|
81
|
+
assert "sha-256 verified" in capsys.readouterr().out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_fetch_rejects_bad_checksum(offline, monkeypatch, capsys):
|
|
85
|
+
tarball = _make_tarball()
|
|
86
|
+
asset = f"gitleaks_{fetch._VERSION}_linux_x64.tar.gz"
|
|
87
|
+
monkeypatch.setattr(
|
|
88
|
+
fetch,
|
|
89
|
+
"_download",
|
|
90
|
+
lambda url: f"0000 {asset}\n".encode() if "checksums" in url else tarball,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert fetch.fetch_gitleaks() == 1
|
|
94
|
+
assert "checksum mismatch" in capsys.readouterr().out
|
|
95
|
+
assert not (offline / "bin" / "gitleaks").exists()
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# git-security-tool — Claude Code instructions
|
|
2
|
-
|
|
3
|
-
A local Git **pre-commit gate** (Linux) that scans staged changes and blocks
|
|
4
|
-
the commit on serious findings. It is an **orchestration layer** over Ruff,
|
|
5
|
-
Semgrep, and Gitleaks — never reimplement what those tools do. Full design:
|
|
6
|
-
`./ARCHITECTURE.md`. Usage: `./README.md`.
|
|
7
|
-
|
|
8
|
-
## Status
|
|
9
|
-
|
|
10
|
-
Built and working (milestone 15 + extras). Installed and dogfooded on a real
|
|
11
|
-
project. Not yet tagged / published to PyPI.
|
|
12
|
-
|
|
13
|
-
## Dev commands
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
pip install -e ".[scanners,dev]" # tool + ruff + semgrep + pytest
|
|
17
|
-
pytest # ~50s (semgrep subprocess tests)
|
|
18
|
-
ruff check src tests && ruff format --check src tests
|
|
19
|
-
git-security-tool scan --all # run the tool on this repo
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Python 3.11+. Package import name `git_security`; CLI / PyPI name
|
|
23
|
-
`git-security-tool`.
|
|
24
|
-
|
|
25
|
-
## Hard rules (the tool must obey its own advice)
|
|
26
|
-
|
|
27
|
-
- **Scan staged content, not the working tree** — `scan` materializes the
|
|
28
|
-
index into a temp dir. `git diff --cached`, never `git diff`.
|
|
29
|
-
- **subprocess: argument lists only.** Never `shell=True`, never build a
|
|
30
|
-
command from untrusted input. `git/repository.py::run_git` is the only
|
|
31
|
-
place that shells to git.
|
|
32
|
-
- **The exit code is the gate.** `run_scan` returns 0 (allow) or 1 (block);
|
|
33
|
-
nothing else decides.
|
|
34
|
-
- **Policy lives in `policy/engine.py`.** Scanners assign a severity and stop;
|
|
35
|
-
they never decide blocking.
|
|
36
|
-
- **AI is advisory** — off by default, opt-in, never changes the decision,
|
|
37
|
-
never writes files, never sends a Gitleaks-flagged file to the API.
|
|
38
|
-
- **Never print/log secret values** (Gitleaks runs with `--redact`).
|
|
39
|
-
- Keep `ruff check` and `ruff format --check` green on `src/` and `tests/`.
|
|
40
|
-
|
|
41
|
-
## Conventions
|
|
42
|
-
|
|
43
|
-
- One module, one job. New capability = new module + one line in `scan.py`.
|
|
44
|
-
- Each scanner owns its tool's CLI flags, exit-code meaning, and JSON→Finding
|
|
45
|
-
mapping. Shared plumbing (`run_tool`, `to_repo_relative`) is in
|
|
46
|
-
`scanners/base.py`.
|
|
47
|
-
- Config errors raise `ConfigError` → caught in `run_scan` → clean message,
|
|
48
|
-
no traceback.
|
|
49
|
-
- A scanner crash is caught per-scanner (`_safe_scan`) — report it, continue.
|
|
50
|
-
- Tests: pure logic (policy, mappers, config, ignore, baseline, sarif) has no
|
|
51
|
-
I/O; end-to-end tests use a real temp git repo; semgrep/gitleaks tests skip
|
|
52
|
-
if the tool isn't installed.
|
|
53
|
-
|
|
54
|
-
## Out of scope
|
|
55
|
-
|
|
56
|
-
Dependency-CVE scanning, license/SBOM, IaC/container scanning, non-Python
|
|
57
|
-
static analysis, Kubernetes/microservices/databases. The bundled Semgrep set
|
|
58
|
-
is intentionally small.
|
|
59
|
-
|
|
60
|
-
## Working style (learning project)
|
|
61
|
-
|
|
62
|
-
Incremental. For a new concept: explain what/why/how it fits → implement the
|
|
63
|
-
smallest version → explain the key code → test → next. Surface any decision
|
|
64
|
-
that affects architecture before acting. No large unexplained code dumps.
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
"""Check which external scanners are available on PATH.
|
|
2
|
-
|
|
3
|
-
The tool never installs anything itself - it just tells the user what is
|
|
4
|
-
present and what is missing. Missing scanners are skipped at scan time.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import shutil
|
|
8
|
-
|
|
9
|
-
SCANNERS = ("ruff", "gitleaks", "semgrep")
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def check_dependencies() -> dict[str, bool]:
|
|
13
|
-
"""Map each scanner name to whether its executable is on PATH."""
|
|
14
|
-
return {name: shutil.which(name) is not None for name in SCANNERS}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""Shared plumbing for scanner wrappers.
|
|
2
|
-
|
|
3
|
-
Each scanner module owns the tool-specific knowledge: which command to run,
|
|
4
|
-
how to read its exit codes, how to map its output to ``Finding``. What every
|
|
5
|
-
scanner shares - run an external process, cope if it isn't installed, and
|
|
6
|
-
report paths consistently - lives here.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import subprocess
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
14
|
-
"""Run an external tool with stdout/stderr captured.
|
|
15
|
-
|
|
16
|
-
Returns the ``CompletedProcess`` for any exit code, or ``None`` if the
|
|
17
|
-
executable is not on PATH - so callers can skip a missing tool cleanly
|
|
18
|
-
instead of crashing the commit.
|
|
19
|
-
"""
|
|
20
|
-
try:
|
|
21
|
-
return subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
22
|
-
except FileNotFoundError:
|
|
23
|
-
return None
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def to_repo_relative(path: str, repo_root: Path) -> str:
|
|
27
|
-
"""Make an absolute path repo-relative; leave anything else untouched."""
|
|
28
|
-
p = Path(path)
|
|
29
|
-
if not p.is_absolute():
|
|
30
|
-
return path
|
|
31
|
-
try:
|
|
32
|
-
return str(p.relative_to(repo_root))
|
|
33
|
-
except ValueError:
|
|
34
|
-
return path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/crypto_tls.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/injection.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/providers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|