git-security-tool 0.1.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.0/.github/workflows/ci.yml +50 -0
- git_security_tool-0.1.0/.github/workflows/release.yml +41 -0
- git_security_tool-0.1.0/.github/workflows/scan.reusable.yml +46 -0
- git_security_tool-0.1.0/.gitignore +17 -0
- git_security_tool-0.1.0/ARCHITECTURE.md +279 -0
- git_security_tool-0.1.0/CLAUDE.md +64 -0
- git_security_tool-0.1.0/LICENSE +21 -0
- git_security_tool-0.1.0/PKG-INFO +149 -0
- git_security_tool-0.1.0/README.md +120 -0
- git_security_tool-0.1.0/pyproject.toml +62 -0
- git_security_tool-0.1.0/src/git_security/__init__.py +3 -0
- git_security_tool-0.1.0/src/git_security/__main__.py +5 -0
- git_security_tool-0.1.0/src/git_security/baseline.py +67 -0
- git_security_tool-0.1.0/src/git_security/cli.py +87 -0
- git_security_tool-0.1.0/src/git_security/config/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/config/loader.py +127 -0
- git_security_tool-0.1.0/src/git_security/git/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/git/diff.py +50 -0
- git_security_tool-0.1.0/src/git_security/git/hooks.py +25 -0
- git_security_tool-0.1.0/src/git_security/git/repository.py +38 -0
- git_security_tool-0.1.0/src/git_security/ignore.py +27 -0
- git_security_tool-0.1.0/src/git_security/installer/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/installer/dependencies.py +14 -0
- git_security_tool-0.1.0/src/git_security/installer/git_hook.py +122 -0
- git_security_tool-0.1.0/src/git_security/models/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/models/finding.py +33 -0
- git_security_tool-0.1.0/src/git_security/policy/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/policy/engine.py +38 -0
- git_security_tool-0.1.0/src/git_security/reporter/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/reporter/sarif.py +72 -0
- git_security_tool-0.1.0/src/git_security/reporter/terminal.py +46 -0
- git_security_tool-0.1.0/src/git_security/rules/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/rules/semgrep/crypto_tls.yml +37 -0
- git_security_tool-0.1.0/src/git_security/rules/semgrep/deserialization.yml +38 -0
- git_security_tool-0.1.0/src/git_security/rules/semgrep/filesystem_net.yml +38 -0
- git_security_tool-0.1.0/src/git_security/rules/semgrep/injection.yml +43 -0
- git_security_tool-0.1.0/src/git_security/rules/semgrep/web.yml +32 -0
- git_security_tool-0.1.0/src/git_security/scan.py +245 -0
- git_security_tool-0.1.0/src/git_security/scanners/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/scanners/base.py +34 -0
- git_security_tool-0.1.0/src/git_security/scanners/gitleaks.py +59 -0
- git_security_tool-0.1.0/src/git_security/scanners/ruff.py +103 -0
- git_security_tool-0.1.0/src/git_security/scanners/semgrep.py +81 -0
- git_security_tool-0.1.0/src/git_security/suggestions/__init__.py +0 -0
- git_security_tool-0.1.0/src/git_security/suggestions/llm.py +67 -0
- git_security_tool-0.1.0/src/git_security/suggestions/providers.py +110 -0
- git_security_tool-0.1.0/tests/test_baseline.py +59 -0
- git_security_tool-0.1.0/tests/test_cli.py +49 -0
- git_security_tool-0.1.0/tests/test_config.py +104 -0
- git_security_tool-0.1.0/tests/test_diff.py +59 -0
- git_security_tool-0.1.0/tests/test_ignore.py +33 -0
- git_security_tool-0.1.0/tests/test_installer.py +85 -0
- git_security_tool-0.1.0/tests/test_policy.py +64 -0
- git_security_tool-0.1.0/tests/test_providers.py +76 -0
- git_security_tool-0.1.0/tests/test_rules.py +45 -0
- git_security_tool-0.1.0/tests/test_sarif.py +52 -0
- git_security_tool-0.1.0/tests/test_scan.py +184 -0
- git_security_tool-0.1.0/tests/test_scanners.py +117 -0
- git_security_tool-0.1.0/tests/test_suggestions.py +48 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
security-events: write # for the SARIF upload
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
|
|
21
|
+
- name: Install
|
|
22
|
+
run: pip install -e ".[scanners,dev]"
|
|
23
|
+
|
|
24
|
+
- name: Install gitleaks
|
|
25
|
+
run: |
|
|
26
|
+
url=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest \
|
|
27
|
+
| grep -o 'https://[^"]*linux_x64\.tar\.gz' | head -1)
|
|
28
|
+
curl -sL "$url" | tar xz -C /usr/local/bin gitleaks
|
|
29
|
+
gitleaks version
|
|
30
|
+
|
|
31
|
+
- name: Lint
|
|
32
|
+
run: |
|
|
33
|
+
ruff check src tests
|
|
34
|
+
ruff format --check src tests
|
|
35
|
+
|
|
36
|
+
- name: Test
|
|
37
|
+
run: pytest -q
|
|
38
|
+
|
|
39
|
+
- name: Self-scan (full repo)
|
|
40
|
+
run: git-security-tool scan --all
|
|
41
|
+
|
|
42
|
+
- name: SARIF report
|
|
43
|
+
if: always()
|
|
44
|
+
run: git-security-tool scan --all --format sarif > results.sarif || true
|
|
45
|
+
|
|
46
|
+
- name: Upload SARIF to code scanning
|
|
47
|
+
if: always()
|
|
48
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
49
|
+
with:
|
|
50
|
+
sarif_file: results.sarif
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI on a version tag (v*), using PyPI Trusted Publishing
|
|
4
|
+
# (OIDC) - no API token stored as a secret.
|
|
5
|
+
#
|
|
6
|
+
# One-time setup on pypi.org:
|
|
7
|
+
# Project "git-security-tool" -> Publishing -> add a trusted publisher:
|
|
8
|
+
# owner: MustafaBasit521 · repo: commit-guard
|
|
9
|
+
# workflow: release.yml · environment: pypi
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
tags: ["v*"]
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- run: pip install build
|
|
24
|
+
- run: python -m build
|
|
25
|
+
- uses: actions/upload-artifact@v4
|
|
26
|
+
with:
|
|
27
|
+
name: dist
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment: pypi
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write # for trusted publishing
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/download-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: dist
|
|
40
|
+
path: dist/
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: git-security-tool scan
|
|
2
|
+
|
|
3
|
+
# Reusable workflow. Other repos call it with:
|
|
4
|
+
#
|
|
5
|
+
# jobs:
|
|
6
|
+
# security:
|
|
7
|
+
# uses: MustafaBasit521/commit-guard/.github/workflows/scan.reusable.yml@main
|
|
8
|
+
#
|
|
9
|
+
# Pin @v0.1.0 (or a commit SHA) instead of @main once the tool is tagged, so a
|
|
10
|
+
# change to the tool can't unexpectedly break a downstream project's CI.
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_call:
|
|
14
|
+
inputs:
|
|
15
|
+
ref:
|
|
16
|
+
description: "git-security-tool version, branch, or SHA to install"
|
|
17
|
+
type: string
|
|
18
|
+
default: main
|
|
19
|
+
python-version:
|
|
20
|
+
type: string
|
|
21
|
+
default: "3.12"
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
scan:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ inputs.python-version }}
|
|
32
|
+
|
|
33
|
+
- name: Install git-security-tool
|
|
34
|
+
run: >
|
|
35
|
+
pip install "git-security-tool[scanners] @
|
|
36
|
+
git+https://github.com/MustafaBasit521/commit-guard.git@${{ inputs.ref }}"
|
|
37
|
+
|
|
38
|
+
- name: Install gitleaks
|
|
39
|
+
run: |
|
|
40
|
+
url=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest \
|
|
41
|
+
| grep -o 'https://[^"]*linux_x64\.tar\.gz' | head -1)
|
|
42
|
+
curl -sL "$url" | tar xz -C /usr/local/bin gitleaks
|
|
43
|
+
gitleaks version
|
|
44
|
+
|
|
45
|
+
- name: Scan
|
|
46
|
+
run: git-security-tool scan --all
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# git-security-tool — Architecture
|
|
2
|
+
|
|
3
|
+
This is the as-built reference. It also doubles as a design retrospective:
|
|
4
|
+
each section explains **what** a piece does and **why** it ended up that way.
|
|
5
|
+
For usage, see `README.md`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. What it is
|
|
10
|
+
|
|
11
|
+
A local Git **pre-commit gate** for Linux. On `git commit` it scans the
|
|
12
|
+
*staged* changes; if policy says a finding is serious enough, the commit is
|
|
13
|
+
aborted (`exit 1`). The same engine runs in CI over the whole repo.
|
|
14
|
+
|
|
15
|
+
It is an **orchestration layer**, not a scanner. Detection is delegated to
|
|
16
|
+
three mature tools; this project decides what to scan, runs them, normalizes
|
|
17
|
+
their output, applies policy, and reports.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
git commit
|
|
21
|
+
└─► .git/hooks/pre-commit (3-line shell shim)
|
|
22
|
+
└─► git-security-tool scan
|
|
23
|
+
├─ ruff check → lint (LOW → warn)
|
|
24
|
+
├─ ruff format --check → formatting (LOW → warn)
|
|
25
|
+
├─ semgrep + 17 rules → insecure code (HIGH/MEDIUM)
|
|
26
|
+
└─ gitleaks → secrets (CRITICAL → block)
|
|
27
|
+
└─► policy.evaluate() → Decision
|
|
28
|
+
└─► exit 0 (allow) | exit 1 (block)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Core design rule, held throughout: **the exit code is the entire control
|
|
32
|
+
mechanism.** Everything else is logic for choosing between `0` and `1`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Package layout
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
src/git_security/
|
|
40
|
+
├── __main__.py python -m git_security → cli.main()
|
|
41
|
+
├── cli.py argparse; dispatch to an action; return an exit code
|
|
42
|
+
├── scan.py the pipeline: gather → scan → policy → report → exit
|
|
43
|
+
│
|
|
44
|
+
├── git/
|
|
45
|
+
│ ├── repository.py run_git() — the ONLY place that shells to git
|
|
46
|
+
│ ├── diff.py staged files, staged diff, materialize_staged, ls-files
|
|
47
|
+
│ └── hooks.py resolve this repo's hooks dir / pre-commit path
|
|
48
|
+
│
|
|
49
|
+
├── scanners/
|
|
50
|
+
│ ├── base.py run_tool() (missing-tool tolerant), to_repo_relative()
|
|
51
|
+
│ ├── ruff.py run_ruff (lint) + run_ruff_format → list[Finding]
|
|
52
|
+
│ ├── gitleaks.py run_gitleaks(staged=…) → list[Finding]
|
|
53
|
+
│ └── semgrep.py run_semgrep(files, root, rules_dir) → list[Finding]
|
|
54
|
+
│
|
|
55
|
+
├── models/finding.py Severity (IntEnum) + Finding (frozen dataclass)
|
|
56
|
+
├── policy/engine.py PolicyConfig, Decision, evaluate()
|
|
57
|
+
├── config/loader.py .git-security-tool.toml → Config (+ ConfigError)
|
|
58
|
+
├── ignore.py glob/prefix path-ignore matching
|
|
59
|
+
├── baseline.py suppress findings already recorded in a baseline file
|
|
60
|
+
│
|
|
61
|
+
├── reporter/
|
|
62
|
+
│ ├── terminal.py Decision → human text
|
|
63
|
+
│ └── sarif.py findings → SARIF 2.1.0 JSON (for CI code scanning)
|
|
64
|
+
│
|
|
65
|
+
├── installer/
|
|
66
|
+
│ ├── git_hook.py install / uninstall / status; the managed hook text
|
|
67
|
+
│ └── dependencies.py which scanners are on PATH
|
|
68
|
+
│
|
|
69
|
+
├── suggestions/
|
|
70
|
+
│ ├── llm.py optional AI explanation of blocking findings
|
|
71
|
+
│ └── providers.py AnthropicProvider, GeminiProvider (same 4 methods)
|
|
72
|
+
│
|
|
73
|
+
└── rules/semgrep/*.yml 17 bundled rules, shipped as package data
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Why a package and not one file:** every module has one job and one reason
|
|
77
|
+
to change. `cli.py` reads like a table of contents; `scan.py` is pure
|
|
78
|
+
orchestration; each scanner owns exactly one tool's quirks. Adding a
|
|
79
|
+
capability is usually one new module + one line in `scan.py`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. The data flow
|
|
84
|
+
|
|
85
|
+
### 3.1 Get the files
|
|
86
|
+
|
|
87
|
+
`git/diff.py` provides three sources:
|
|
88
|
+
|
|
89
|
+
| function | used by | returns |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `get_staged_files()` | pre-commit (`scan`) | staged paths, `--diff-filter=ACM` (no deletions) |
|
|
92
|
+
| `get_tracked_files()` | CI (`scan --all`) | `git ls-files` |
|
|
93
|
+
| `materialize_staged(files, dest)` | pre-commit only | writes the **index** blob content into a temp dir via `git checkout-index` |
|
|
94
|
+
|
|
95
|
+
**Why `materialize_staged`:** a commit is built from the *index*, not the
|
|
96
|
+
working tree. Stage `x.py`, edit it again, and a working-tree scan would
|
|
97
|
+
flag code that isn't being committed *and* miss code that is — the dangerous
|
|
98
|
+
direction for a security gate. So `scan` (staged mode) copies the exact
|
|
99
|
+
staged bytes to a throwaway dir and scans that. `scan --all` has no "staged"
|
|
100
|
+
concept, so it scans the working tree in place. Gitleaks is Git-aware and
|
|
101
|
+
scans the index itself (`gitleaks git --staged`), so it never needs the
|
|
102
|
+
temp dir.
|
|
103
|
+
|
|
104
|
+
`_copy_project_config()` also drops the repo's `pyproject.toml` / `ruff.toml`
|
|
105
|
+
into the temp dir — scanners resolve config by walking up from each file, and
|
|
106
|
+
a bare temp dir would make Ruff silently use built-in defaults.
|
|
107
|
+
|
|
108
|
+
### 3.2 Run the scanners
|
|
109
|
+
|
|
110
|
+
`scan._collect_findings(scope)` runs each enabled scanner through
|
|
111
|
+
`_safe_scan()` — a wrapper that turns a scanner crash (`RuntimeError`) into
|
|
112
|
+
a printed message + empty list, so one broken tool doesn't take down the
|
|
113
|
+
commit with a traceback. A scanner that isn't installed is skipped silently
|
|
114
|
+
by `base.run_tool()` returning `None`.
|
|
115
|
+
|
|
116
|
+
### 3.3 Normalize — the `Finding` model
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Finding:
|
|
121
|
+
tool: str; rule: str; severity: Severity
|
|
122
|
+
file: str; line: int; message: str
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Each scanner's `_to_finding()` converts its tool's native JSON into this.
|
|
126
|
+
The three tools emit wildly different shapes:
|
|
127
|
+
|
|
128
|
+
| | Ruff | Gitleaks | Semgrep |
|
|
129
|
+
|---|---|---|---|
|
|
130
|
+
| path key | `filename` (absolute) | `File` (relative) | `path` |
|
|
131
|
+
| line | `location.row` | `StartLine` | `start.line` |
|
|
132
|
+
| rule | `code` (can be null) | `RuleID` | `check_id` (path-namespaced) |
|
|
133
|
+
| severity | *(none — assigned)* | *(none — CRITICAL)* | `extra.severity` (ERROR/WARNING/INFO) |
|
|
134
|
+
|
|
135
|
+
Absorbing that asymmetry in one place per scanner is the whole point — from
|
|
136
|
+
here on, policy and reporting see only `Finding`.
|
|
137
|
+
|
|
138
|
+
`Severity` is an `IntEnum` so policy can write `f.severity >= threshold`
|
|
139
|
+
directly (it's `4 >= 3` underneath) while still being a typo-proof named
|
|
140
|
+
constant.
|
|
141
|
+
|
|
142
|
+
### 3.4 Baseline + ignores
|
|
143
|
+
|
|
144
|
+
Before policy:
|
|
145
|
+
|
|
146
|
+
- `ignore.filter_ignored()` drops files matching `[ignore] paths` patterns
|
|
147
|
+
(`tests/fixtures/`, `*.generated.py`, …). Gitleaks findings are filtered
|
|
148
|
+
after the fact since it scans everything.
|
|
149
|
+
- `baseline.apply_baseline()` removes findings whose
|
|
150
|
+
`(tool, rule, file, message)` fingerprint is already in
|
|
151
|
+
`.git-security-tool-baseline.json`. This lets an established repo adopt the
|
|
152
|
+
tool without a big-bang cleanup: existing issues are grandfathered, new
|
|
153
|
+
ones still block. The fingerprint deliberately excludes line number so
|
|
154
|
+
unrelated edits don't resurface a baselined finding.
|
|
155
|
+
|
|
156
|
+
### 3.5 Policy
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
def evaluate(findings, config) -> Decision:
|
|
160
|
+
blocking = [f for f in findings if f.severity >= config.block_threshold]
|
|
161
|
+
warnings = [f for f in findings if f.severity < config.block_threshold]
|
|
162
|
+
return Decision(blocked=bool(blocking), blocking=..., warnings=...)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
One pure function. No printing, no `sys.exit`, no subprocess — which is why
|
|
166
|
+
it's trivially unit-tested with fake `Finding`s. `block_threshold` defaults
|
|
167
|
+
to `HIGH` (secrets + RCE-class patterns block; style warns) and is
|
|
168
|
+
overridable per repo.
|
|
169
|
+
|
|
170
|
+
**Why a whole module for ten lines:** it is the single place blocking
|
|
171
|
+
behaviour is defined. Per-directory thresholds, per-rule allowlists,
|
|
172
|
+
"never block on Fridays" — all of that would land here, and scanners /
|
|
173
|
+
`scan.py` wouldn't change.
|
|
174
|
+
|
|
175
|
+
### 3.6 Report + exit
|
|
176
|
+
|
|
177
|
+
- text mode → `reporter/terminal.py` groups warnings then blocking findings,
|
|
178
|
+
sorted by `(file, line, tool)`, long messages collapsed to one line.
|
|
179
|
+
- sarif mode (`--format sarif`) → `reporter/sarif.py` prints SARIF 2.1.0 to
|
|
180
|
+
**stdout** (progress goes to stderr so the JSON is clean); CI uploads it
|
|
181
|
+
to GitHub code scanning.
|
|
182
|
+
|
|
183
|
+
Then `run_scan` returns `0` or `1`. In staged mode, `GIT_SECURITY_NO_BLOCK=1`
|
|
184
|
+
forces `0` while still reporting — the dev escape hatch (distinct from
|
|
185
|
+
`git commit --no-verify`, which skips the hook entirely).
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 4. The other entry points
|
|
190
|
+
|
|
191
|
+
- **`git-security-tool install`** — `installer/git_hook.py` writes a 3-line
|
|
192
|
+
shell shim to this repo's hooks dir (resolved via
|
|
193
|
+
`git rev-parse --git-path hooks`, so `core.hooksPath` / worktrees work).
|
|
194
|
+
The shim carries a marker comment; `install` refuses to overwrite a hook
|
|
195
|
+
it didn't write unless `--force`, and `uninstall` only removes its own.
|
|
196
|
+
- **`git-security-tool baseline`** — runs a full-repo scan and writes every
|
|
197
|
+
current finding to the baseline file.
|
|
198
|
+
- **`git-security-tool check`** — is the hook installed, which scanners are
|
|
199
|
+
present.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 5. Optional AI suggestions
|
|
204
|
+
|
|
205
|
+
Off by default. Enabled only when `[ai] enabled = true` **and** the provider's
|
|
206
|
+
API key is set. `providers.py` has `AnthropicProvider` and `GeminiProvider`
|
|
207
|
+
behind one 4-method shape (`name`, `default_model`, `available()`,
|
|
208
|
+
`complete()`); `[ai] provider` picks one. Gemini uses stdlib `urllib` (no
|
|
209
|
+
dependency); Anthropic needs `pip install ".[ai]"`.
|
|
210
|
+
|
|
211
|
+
Guarantees: it never changes the block decision, never writes files,
|
|
212
|
+
announces before sending code to the API, and **never sends a file Gitleaks
|
|
213
|
+
flagged** (a secret could be in the surrounding snippet).
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 6. Two defense layers
|
|
218
|
+
|
|
219
|
+
| | local | CI |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| trigger | `git commit` | `git push` / PR |
|
|
222
|
+
| command | `git-security-tool scan` (staged) | `git-security-tool scan --all` |
|
|
223
|
+
| bypass | `--no-verify`, or not installed | none |
|
|
224
|
+
| purpose | fast feedback, before it's in history | enforcement nobody can skip |
|
|
225
|
+
|
|
226
|
+
`.github/workflows/scan.reusable.yml` is a `workflow_call` reusable workflow
|
|
227
|
+
so downstream repos reference it in three lines instead of copy-pasting.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 7. Build history — milestone by milestone
|
|
232
|
+
|
|
233
|
+
Each milestone was: explain the concept → smallest implementation → explain
|
|
234
|
+
the code → test → next. The lesson each one carried:
|
|
235
|
+
|
|
236
|
+
| # | Milestone | What it established |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| 1 | Hook runs a Python program | Git finds an executable `pre-commit`; exit 0/non-zero is the whole gate; hooks aren't version-controlled |
|
|
239
|
+
| 2 | `subprocess` captures staged changes | argument lists (never `shell=True`); `git diff --cached` ≠ `git diff`; `CompletedProcess` |
|
|
240
|
+
| 3 | Ruff wrapper | linter exit codes (1 = "found issues" is normal, not failure); JSON output over text scraping |
|
|
241
|
+
| 4 | Gitleaks wrapper | a second tool with a different invocation model and a different JSON shape → motivates normalization |
|
|
242
|
+
| 5 | `Finding` model + `scanners/base.py` | two concrete tool outputs = the right time to design the common one; extract the shared helper, keep the per-tool quirks |
|
|
243
|
+
| 6 | Semgrep | source → AST → pattern match → structured finding; first tool with real severities; offline (`--metrics=off`) |
|
|
244
|
+
| 7 | Policy engine | `Severity` becomes an ordered `IntEnum`; `evaluate()` is pure; **the exit code finally means something** |
|
|
245
|
+
| 8 | Reporter module + pytest | pull presentation out of `main`; the pure policy/mapper code begs for tests |
|
|
246
|
+
| 9 | Packaging | `pyproject.toml` (hatchling), `[project.scripts]` creates the command, rules ship as package data, `-e` editable install |
|
|
247
|
+
| 10 | `install` / `uninstall` / `check` | the hook must be written per-repo; only ever touch a hook we created (marker + `--force`) |
|
|
248
|
+
| 11 | Config file | `tomllib` (stdlib, 3.11+); reuse `PolicyConfig`; malformed config = clean message, not traceback |
|
|
249
|
+
| 12 | Scan the staged blob | the accuracy bug: index vs working tree; `git checkout-index` to a temp dir; copy project config so scanners keep their settings |
|
|
250
|
+
| 13 | Path ignores | `fnmatch` glob + directory prefix; filter Gitleaks findings after the fact |
|
|
251
|
+
| 14 | `ruff format --check` | linting ≠ formatting; the tool must pass its own checks (reformatted the codebase) |
|
|
252
|
+
| 15 | Optional AI | advisory only; opt-in; announce before sending; never send secret-bearing files |
|
|
253
|
+
| — | Gemini provider | provider-pluggable behind one shape; stdlib `urllib` keeps core zero-dependency |
|
|
254
|
+
| — | `scan --all` + CI | CI has no "staged" — scan tracked files in place; the second, unbypassable layer |
|
|
255
|
+
| — | 17 Semgrep rules | injection / deserialization / crypto-TLS / web / filesystem-net; validated in CI |
|
|
256
|
+
| — | Baseline + SARIF | adopt-on-a-dirty-repo; GitHub code-scanning integration |
|
|
257
|
+
| — | Hardening | `ConfigError` handling, per-scanner isolation (`_safe_scan`), AI secret-leak guard, fixed Gitleaks 8.30 (`protect`/`detect` removed → `git --staged` / `dir`) |
|
|
258
|
+
|
|
259
|
+
### Mistakes worth remembering
|
|
260
|
+
|
|
261
|
+
- **`git reset --hard` on a commit that held real work** wiped milestone 7;
|
|
262
|
+
recovered from reflog. `--hard` discards commits *and* uncommitted changes.
|
|
263
|
+
- **Staging a scratch file alongside real work** made the "undo the probe"
|
|
264
|
+
step also undo the milestone. Test with the probe unstaged, or `git add`
|
|
265
|
+
only the real files.
|
|
266
|
+
- **`--amend` after pushing** diverged local/remote; fixed with
|
|
267
|
+
`push --force-with-lease` (safe here — solo repo).
|
|
268
|
+
- **Ruff's shifting defaults** kept flagging our own code (`PLW1510`,
|
|
269
|
+
`TRY004`); fixed by pinning `[tool.ruff.lint] select`.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 8. Scope boundaries
|
|
274
|
+
|
|
275
|
+
Not in scope: dependency-CVE scanning, license checks, SBOM, IaC/container
|
|
276
|
+
scanning, non-Python static analysis. The bundled Semgrep set is curated and
|
|
277
|
+
small — not a replacement for the Semgrep registry or a full SAST platform.
|
|
278
|
+
Kubernetes / microservices / a database were explicitly ruled out from day
|
|
279
|
+
one and never needed.
|
|
@@ -0,0 +1,64 @@
|
|
|
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.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Mustafa Basit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: git-security-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local Git security and code-quality gate that runs on pre-commit.
|
|
5
|
+
Project-URL: Homepage, https://github.com/MustafaBasit521/commit-guard
|
|
6
|
+
Project-URL: Issues, https://github.com/MustafaBasit521/commit-guard/issues
|
|
7
|
+
Author: Muhammad Mustafa Basit
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: git,gitleaks,pre-commit,sast,secrets,security,semgrep
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: ai
|
|
21
|
+
Requires-Dist: anthropic; extra == 'ai'
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Provides-Extra: scanners
|
|
26
|
+
Requires-Dist: ruff; extra == 'scanners'
|
|
27
|
+
Requires-Dist: semgrep; extra == 'scanners'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# git-security-tool
|
|
31
|
+
|
|
32
|
+
[](https://github.com/MustafaBasit521/commit-guard/actions/workflows/ci.yml)
|
|
33
|
+
|
|
34
|
+
A local Git **pre-commit gate** for Linux. It scans your *staged* changes and
|
|
35
|
+
blocks the commit when it finds something serious — secrets, dangerous code
|
|
36
|
+
patterns — while surfacing quality and formatting issues as warnings.
|
|
37
|
+
|
|
38
|
+
It is an **orchestration layer**, not a new scanner: it runs
|
|
39
|
+
[Gitleaks](https://github.com/gitleaks/gitleaks),
|
|
40
|
+
[Semgrep](https://semgrep.dev/), and [Ruff](https://docs.astral.sh/ruff/),
|
|
41
|
+
normalizes their output, applies your policy, and decides pass/block.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
git commit
|
|
45
|
+
└─► .git/hooks/pre-commit
|
|
46
|
+
└─► git-security-tool scan
|
|
47
|
+
├─ gitleaks → secrets (CRITICAL → blocks)
|
|
48
|
+
├─ semgrep → security patterns (HIGH → blocks)
|
|
49
|
+
├─ ruff check → lint (LOW → warns)
|
|
50
|
+
└─ ruff format --check → format (LOW → warns)
|
|
51
|
+
└─► PASS (exit 0) / BLOCK (exit 1)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install "git-security-tool[scanners]" # tool + ruff + semgrep
|
|
58
|
+
cd your-repo
|
|
59
|
+
git-security-tool install # writes .git/hooks/pre-commit
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Latest unreleased version, straight from the repo:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install "git-security-tool[scanners] @ git+https://github.com/MustafaBasit521/commit-guard.git"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Gitleaks is a Go binary — install it separately if you want secret detection
|
|
69
|
+
(the scan skips any tool that isn't on `PATH`).
|
|
70
|
+
|
|
71
|
+
Commands: `scan [--all] [--format sarif]`, `baseline`, `install [--force]`,
|
|
72
|
+
`uninstall`, `check`, `version`.
|
|
73
|
+
|
|
74
|
+
- `scan` — staged changes (pre-commit)
|
|
75
|
+
- `scan --all` — every tracked file (CI / audit); `--format sarif` emits SARIF
|
|
76
|
+
on stdout for GitHub code scanning
|
|
77
|
+
- `baseline` — records current findings to `.git-security-tool-baseline.json`
|
|
78
|
+
so a repo can adopt the tool without fixing everything first; new issues
|
|
79
|
+
still block
|
|
80
|
+
|
|
81
|
+
## What it checks
|
|
82
|
+
|
|
83
|
+
| category | tool | severity | blocks by default |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| Secrets / credentials | Gitleaks (staged diff) | CRITICAL | yes |
|
|
86
|
+
| Insecure code patterns (17 rules) | Semgrep + bundled rules | HIGH / MEDIUM | HIGH yes |
|
|
87
|
+
| Lint (unused imports, undefined names, …) | `ruff check` | LOW | no |
|
|
88
|
+
| Formatting | `ruff format --check` | LOW | no |
|
|
89
|
+
|
|
90
|
+
The bundled Semgrep rules (`src/git_security/rules/semgrep/`) cover code/command
|
|
91
|
+
injection (`eval`, `exec`, `os.system`, `shell=True`), unsafe deserialization
|
|
92
|
+
(`pickle`, `yaml.load`, insecure XML), weak crypto & disabled TLS verification,
|
|
93
|
+
web footguns (Flask `debug=True`, Jinja autoescape off, `mark_safe`), and
|
|
94
|
+
filesystem/network hygiene (`extractall`, `mktemp`, `requests` without timeout).
|
|
95
|
+
|
|
96
|
+
Semgrep/Ruff analysis is **Python only**; Gitleaks is language-agnostic.
|
|
97
|
+
Scanners see the exact **staged** content, not your working tree.
|
|
98
|
+
|
|
99
|
+
## Configuration — `.git-security-tool.toml` (optional, repo root)
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
[policy]
|
|
103
|
+
block_threshold = "HIGH" # INFO | LOW | MEDIUM | HIGH | CRITICAL
|
|
104
|
+
|
|
105
|
+
[scanners]
|
|
106
|
+
gitleaks = false # disable a scanner
|
|
107
|
+
|
|
108
|
+
[ignore]
|
|
109
|
+
paths = ["tests/fixtures/", "*.generated.py"]
|
|
110
|
+
|
|
111
|
+
[ai]
|
|
112
|
+
enabled = false # optional LLM remediation suggestions
|
|
113
|
+
provider = "gemini" # "anthropic" | "gemini"
|
|
114
|
+
model = "" # blank = provider default
|
|
115
|
+
max_findings = 3
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**AI suggestions** are off by default and advisory only — they never affect
|
|
119
|
+
the pass/block decision or modify files, they announce before sending code
|
|
120
|
+
to the API, and secret-bearing files are never sent.
|
|
121
|
+
|
|
122
|
+
| provider | key env var | extra install |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `gemini` | `GEMINI_API_KEY` | none (stdlib HTTP) |
|
|
125
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `pip install ".[ai]"` |
|
|
126
|
+
|
|
127
|
+
## Overrides
|
|
128
|
+
|
|
129
|
+
- `GIT_SECURITY_NO_BLOCK=1 git commit …` — run the scan, report, never block.
|
|
130
|
+
- `git commit --no-verify` — skip the hook entirely (Git built-in).
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install -e ".[scanners,dev]"
|
|
136
|
+
pytest
|
|
137
|
+
ruff check src tests && ruff format --check src tests
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Scope / non-goals
|
|
141
|
+
|
|
142
|
+
No dependency-CVE scanning, no license checks, no SBOM, no IaC/container
|
|
143
|
+
scanning, no non-Python static analysis. The bundled Semgrep ruleset is
|
|
144
|
+
curated and intentionally small — not a replacement for a full SAST platform
|
|
145
|
+
or the Semgrep registry.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT — see [LICENSE](LICENSE).
|