git-security-tool 0.1.2__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.
Files changed (66) hide show
  1. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/.gitignore +3 -0
  2. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/ARCHITECTURE.md +10 -0
  3. git_security_tool-0.2.0/INSTALL.md +295 -0
  4. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/PKG-INFO +16 -20
  5. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/README.md +15 -19
  6. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/__init__.py +1 -1
  7. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/cli.py +7 -0
  8. git_security_tool-0.2.0/src/git_security/installer/dependencies.py +15 -0
  9. git_security_tool-0.2.0/src/git_security/installer/fetch.py +99 -0
  10. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/installer/git_hook.py +5 -3
  11. git_security_tool-0.2.0/src/git_security/scanners/base.py +67 -0
  12. git_security_tool-0.2.0/tests/test_base.py +61 -0
  13. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_cli.py +5 -0
  14. git_security_tool-0.2.0/tests/test_fetch.py +95 -0
  15. git_security_tool-0.1.2/CLAUDE.md +0 -64
  16. git_security_tool-0.1.2/src/git_security/installer/dependencies.py +0 -14
  17. git_security_tool-0.1.2/src/git_security/scanners/base.py +0 -34
  18. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/.github/workflows/ci.yml +0 -0
  19. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/.github/workflows/release.yml +0 -0
  20. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/.github/workflows/scan.reusable.yml +0 -0
  21. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/LICENSE +0 -0
  22. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/pyproject.toml +0 -0
  23. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/scripts/probe.sh +0 -0
  24. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/__main__.py +0 -0
  25. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/baseline.py +0 -0
  26. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/config/__init__.py +0 -0
  27. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/config/loader.py +0 -0
  28. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/git/__init__.py +0 -0
  29. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/git/diff.py +0 -0
  30. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/git/hooks.py +0 -0
  31. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/git/repository.py +0 -0
  32. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/ignore.py +0 -0
  33. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/installer/__init__.py +0 -0
  34. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/models/__init__.py +0 -0
  35. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/models/finding.py +0 -0
  36. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/policy/__init__.py +0 -0
  37. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/policy/engine.py +0 -0
  38. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/reporter/__init__.py +0 -0
  39. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/reporter/sarif.py +0 -0
  40. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/reporter/terminal.py +0 -0
  41. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/__init__.py +0 -0
  42. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/crypto_tls.yml +0 -0
  43. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/deserialization.yml +0 -0
  44. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/filesystem_net.yml +0 -0
  45. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/injection.yml +0 -0
  46. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/web.yml +0 -0
  47. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/scan.py +0 -0
  48. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/scanners/__init__.py +0 -0
  49. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/scanners/gitleaks.py +0 -0
  50. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/scanners/ruff.py +0 -0
  51. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/scanners/semgrep.py +0 -0
  52. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/suggestions/__init__.py +0 -0
  53. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/suggestions/llm.py +0 -0
  54. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/src/git_security/suggestions/providers.py +0 -0
  55. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_baseline.py +0 -0
  56. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_config.py +0 -0
  57. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_diff.py +0 -0
  58. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_ignore.py +0 -0
  59. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_installer.py +0 -0
  60. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_policy.py +0 -0
  61. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_providers.py +0 -0
  62. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_rules.py +0 -0
  63. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_sarif.py +0 -0
  64. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_scan.py +0 -0
  65. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_scanners.py +0 -0
  66. {git_security_tool-0.1.2 → git_security_tool-0.2.0}/tests/test_suggestions.py +0 -0
@@ -15,3 +15,6 @@ dist/
15
15
  .pytest_cache/
16
16
  .ruff_cache/
17
17
  .git-security-tool.toml
18
+ CLAUDE.md
19
+ LEARNING.md
20
+ CONCEPTS.md
@@ -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.1.2
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
@@ -74,28 +74,24 @@ GIT_SECURITY_NO_BLOCK=1 to override
74
74
 
75
75
  ## Add it to a project
76
76
 
77
- ### 1. Install the tool (once per machine)
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.
78
80
 
79
- The pre-commit hook runs `git-security-tool` from your `PATH`, so install it
80
- somewhere permanent — [pipx](https://pipx.pypa.io/) is cleanest:
81
+ ### 1. Install (once per machine)
81
82
 
82
83
  ```bash
83
- pipx install "git-security-tool[scanners]" # isolated, always on PATH
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"
84
87
  ```
85
88
 
86
- Or into a virtualenv you keep active / on `PATH`:
87
-
88
- ```bash
89
- pip install "git-security-tool[scanners]"
90
- ```
91
-
92
- Then add **Gitleaks** (a Go binary, not on PyPI) for secret detection —
93
- [download a release](https://github.com/gitleaks/gitleaks/releases) and put
94
- it on `PATH`. The scan silently skips any scanner that isn't installed.
95
-
96
- ```bash
97
- git-security-tool check # confirms hook status + which scanners are present
98
- ```
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.
99
95
 
100
96
  ### 2. Turn on the hook (once per repo)
101
97
 
@@ -142,8 +138,8 @@ below: severity threshold, disable a scanner, ignore paths, AI suggestions.
142
138
 
143
139
  ---
144
140
 
145
- **Commands:** `scan [--all] [--format sarif]`, `baseline`, `install [--force]`,
146
- `uninstall`, `check`, `version`.
141
+ **Commands:** `scan [--all] [--format sarif]`, `baseline`, `fetch-gitleaks`,
142
+ `install [--force]`, `uninstall`, `check`, `version`.
147
143
 
148
144
  - `scan` — staged changes (pre-commit)
149
145
  - `scan --all` — every tracked file (CI / audit); `--format sarif` emits SARIF
@@ -45,28 +45,24 @@ GIT_SECURITY_NO_BLOCK=1 to override
45
45
 
46
46
  ## Add it to a project
47
47
 
48
- ### 1. Install the tool (once per machine)
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.
49
51
 
50
- The pre-commit hook runs `git-security-tool` from your `PATH`, so install it
51
- somewhere permanent — [pipx](https://pipx.pypa.io/) is cleanest:
52
+ ### 1. Install (once per machine)
52
53
 
53
54
  ```bash
54
- pipx install "git-security-tool[scanners]" # isolated, always on PATH
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"
55
58
  ```
56
59
 
57
- Or into a virtualenv you keep active / on `PATH`:
58
-
59
- ```bash
60
- pip install "git-security-tool[scanners]"
61
- ```
62
-
63
- Then add **Gitleaks** (a Go binary, not on PyPI) for secret detection —
64
- [download a release](https://github.com/gitleaks/gitleaks/releases) and put
65
- it on `PATH`. The scan silently skips any scanner that isn't installed.
66
-
67
- ```bash
68
- git-security-tool check # confirms hook status + which scanners are present
69
- ```
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.
70
66
 
71
67
  ### 2. Turn on the hook (once per repo)
72
68
 
@@ -113,8 +109,8 @@ below: severity threshold, disable a scanner, ignore paths, AI suggestions.
113
109
 
114
110
  ---
115
111
 
116
- **Commands:** `scan [--all] [--format sarif]`, `baseline`, `install [--force]`,
117
- `uninstall`, `check`, `version`.
112
+ **Commands:** `scan [--all] [--format sarif]`, `baseline`, `fetch-gitleaks`,
113
+ `install [--force]`, `uninstall`, `check`, `version`.
118
114
 
119
115
  - `scan` — staged changes (pre-commit)
120
116
  - `scan --all` — every tracked file (CI / audit); `--format sarif` emits SARIF
@@ -1,3 +1,3 @@
1
1
  """git-security-tool: a local Git security and code-quality gate."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -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 all(deps.values()):
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} missing scanners are skipped at scan time; "
53
- "install them for full coverage"
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