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.
Files changed (66) hide show
  1. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.gitignore +3 -0
  2. {git_security_tool-0.1.1 → 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.1 → git_security_tool-0.2.0}/PKG-INFO +63 -26
  5. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/README.md +62 -25
  6. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/__init__.py +1 -1
  7. {git_security_tool-0.1.1 → 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.1 → 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.1 → 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.1/CLAUDE.md +0 -64
  16. git_security_tool-0.1.1/src/git_security/installer/dependencies.py +0 -14
  17. git_security_tool-0.1.1/src/git_security/scanners/base.py +0 -34
  18. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/ci.yml +0 -0
  19. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/release.yml +0 -0
  20. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/.github/workflows/scan.reusable.yml +0 -0
  21. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/LICENSE +0 -0
  22. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/pyproject.toml +0 -0
  23. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/scripts/probe.sh +0 -0
  24. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/__main__.py +0 -0
  25. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/baseline.py +0 -0
  26. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/config/__init__.py +0 -0
  27. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/config/loader.py +0 -0
  28. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/__init__.py +0 -0
  29. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/diff.py +0 -0
  30. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/hooks.py +0 -0
  31. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/git/repository.py +0 -0
  32. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/ignore.py +0 -0
  33. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/installer/__init__.py +0 -0
  34. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/models/__init__.py +0 -0
  35. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/models/finding.py +0 -0
  36. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/policy/__init__.py +0 -0
  37. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/policy/engine.py +0 -0
  38. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/__init__.py +0 -0
  39. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/sarif.py +0 -0
  40. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/reporter/terminal.py +0 -0
  41. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/__init__.py +0 -0
  42. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/crypto_tls.yml +0 -0
  43. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/deserialization.yml +0 -0
  44. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/filesystem_net.yml +0 -0
  45. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/injection.yml +0 -0
  46. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/rules/semgrep/web.yml +0 -0
  47. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scan.py +0 -0
  48. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/__init__.py +0 -0
  49. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/gitleaks.py +0 -0
  50. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/ruff.py +0 -0
  51. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/scanners/semgrep.py +0 -0
  52. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/__init__.py +0 -0
  53. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/llm.py +0 -0
  54. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/src/git_security/suggestions/providers.py +0 -0
  55. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_baseline.py +0 -0
  56. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_config.py +0 -0
  57. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_diff.py +0 -0
  58. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_ignore.py +0 -0
  59. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_installer.py +0 -0
  60. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_policy.py +0 -0
  61. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_providers.py +0 -0
  62. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_rules.py +0 -0
  63. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_sarif.py +0 -0
  64. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_scan.py +0 -0
  65. {git_security_tool-0.1.1 → git_security_tool-0.2.0}/tests/test_scanners.py +0 -0
  66. {git_security_tool-0.1.1 → 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.1
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
- ## Install
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 # writes .git/hooks/pre-commit
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
- Gitleaks is a Go binary [install it](https://github.com/gitleaks/gitleaks#installing)
84
- separately for secret detection (the scan skips any tool that isn't on `PATH`).
131
+ That reusable workflow installs the tool + Gitleaks and runs
132
+ `git-security-tool scan --all` over the whole repo.
85
133
 
86
- Commands: `scan [--all] [--format sarif]`, `baseline`, `install [--force]`,
87
- `uninstall`, `check`, `version`.
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
- ## Install
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 # writes .git/hooks/pre-commit
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
- Gitleaks is a Go binary [install it](https://github.com/gitleaks/gitleaks#installing)
55
- separately for secret detection (the scan skips any tool that isn't on `PATH`).
102
+ That reusable workflow installs the tool + Gitleaks and runs
103
+ `git-security-tool scan --all` over the whole repo.
56
104
 
57
- Commands: `scan [--all] [--format sarif]`, `baseline`, `install [--force]`,
58
- `uninstall`, `check`, `version`.
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.
@@ -1,3 +1,3 @@
1
1
  """git-security-tool: a local Git security and code-quality gate."""
2
2
 
3
- __version__ = "0.1.1"
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