git-security-tool 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- git_security/__main__.py +5 -0
- git_security/baseline.py +67 -0
- git_security/cli.py +87 -0
- git_security/config/__init__.py +0 -0
- git_security/config/loader.py +127 -0
- git_security/git/__init__.py +0 -0
- git_security/git/diff.py +50 -0
- git_security/git/hooks.py +25 -0
- git_security/git/repository.py +38 -0
- git_security/ignore.py +27 -0
- git_security/installer/__init__.py +0 -0
- git_security/installer/dependencies.py +14 -0
- git_security/installer/git_hook.py +122 -0
- git_security/models/__init__.py +0 -0
- git_security/models/finding.py +33 -0
- git_security/policy/__init__.py +0 -0
- git_security/policy/engine.py +38 -0
- git_security/reporter/__init__.py +0 -0
- git_security/reporter/sarif.py +72 -0
- git_security/reporter/terminal.py +46 -0
- git_security/rules/__init__.py +0 -0
- git_security/rules/semgrep/crypto_tls.yml +37 -0
- git_security/rules/semgrep/deserialization.yml +38 -0
- git_security/rules/semgrep/filesystem_net.yml +38 -0
- git_security/rules/semgrep/injection.yml +43 -0
- git_security/rules/semgrep/web.yml +32 -0
- git_security/scan.py +245 -0
- git_security/scanners/__init__.py +0 -0
- git_security/scanners/base.py +34 -0
- git_security/scanners/gitleaks.py +59 -0
- git_security/scanners/ruff.py +103 -0
- git_security/scanners/semgrep.py +81 -0
- git_security/suggestions/__init__.py +0 -0
- git_security/suggestions/llm.py +67 -0
- git_security/suggestions/providers.py +110 -0
- git_security_tool-0.1.0.dist-info/METADATA +149 -0
- git_security_tool-0.1.0.dist-info/RECORD +41 -0
- git_security_tool-0.1.0.dist-info/WHEEL +4 -0
- git_security_tool-0.1.0.dist-info/entry_points.txt +2 -0
- git_security_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Ruff wrapper: staged Python files -> list[Finding].
|
|
2
|
+
|
|
3
|
+
Knows how to talk to Ruff and how to map Ruff's JSON to the normalized
|
|
4
|
+
``Finding`` model. Knows nothing about Git internals, policy, or reporting.
|
|
5
|
+
|
|
6
|
+
Approach A (milestone 3): scans the files as they exist in the working tree,
|
|
7
|
+
not the exact staged blobs. Editing a file after ``git add`` means Ruff sees
|
|
8
|
+
the newer version. Precise staged-content scanning is a later milestone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from git_security.models.finding import Finding, Severity
|
|
15
|
+
from git_security.scanners.base import run_tool, to_repo_relative
|
|
16
|
+
|
|
17
|
+
_NOT_FOUND_MSG = (
|
|
18
|
+
"[git-security-tool] ruff not found on PATH - skipping "
|
|
19
|
+
"(activate your venv / pip install ruff)"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_ruff(files: list[str], repo_root: Path) -> list[Finding]:
|
|
24
|
+
"""Lint the Python files in *files* and return normalized findings."""
|
|
25
|
+
python_files = [f for f in files if f.endswith(".py")]
|
|
26
|
+
if not python_files:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
proc = run_tool(["ruff", "check", "--output-format=json", "--", *python_files])
|
|
30
|
+
if proc is None:
|
|
31
|
+
print(_NOT_FOUND_MSG)
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
# Ruff exit codes: 0 = clean, 1 = issues found, 2 = execution error.
|
|
35
|
+
if proc.returncode == 2:
|
|
36
|
+
raise RuntimeError(f"ruff failed: {proc.stderr.strip()}")
|
|
37
|
+
|
|
38
|
+
if not proc.stdout.strip():
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
return [_to_finding(item, repo_root) for item in json.loads(proc.stdout)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_finding(item: dict, repo_root: Path) -> Finding:
|
|
45
|
+
location = item.get("location") or {}
|
|
46
|
+
return Finding(
|
|
47
|
+
tool="ruff",
|
|
48
|
+
rule=item.get("code") or "",
|
|
49
|
+
severity=Severity.LOW, # code quality never blocks by default
|
|
50
|
+
file=to_repo_relative(item.get("filename") or "", repo_root),
|
|
51
|
+
line=location.get("row") or 0,
|
|
52
|
+
message=item.get("message") or "",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_ruff_format(files: list[str], repo_root: Path) -> list[Finding]:
|
|
57
|
+
"""Report Python files that are not formatted (``ruff format --check``)."""
|
|
58
|
+
python_files = [f for f in files if f.endswith(".py")]
|
|
59
|
+
if not python_files:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
proc = run_tool(["ruff", "format", "--check", "--", *python_files])
|
|
63
|
+
if proc is None:
|
|
64
|
+
return [] # run_ruff already printed the "not found" message
|
|
65
|
+
|
|
66
|
+
# 0 = all formatted, 1 = some would be reformatted, >1 = execution error.
|
|
67
|
+
if proc.returncode == 0:
|
|
68
|
+
return []
|
|
69
|
+
if proc.returncode not in (0, 1):
|
|
70
|
+
raise RuntimeError(f"ruff format failed: {proc.stderr.strip()}")
|
|
71
|
+
|
|
72
|
+
findings: list[Finding] = []
|
|
73
|
+
seen: set[str] = set()
|
|
74
|
+
for line in f"{proc.stdout}\n{proc.stderr}".splitlines():
|
|
75
|
+
path = _parse_unformatted_path(line)
|
|
76
|
+
if path and path not in seen:
|
|
77
|
+
seen.add(path)
|
|
78
|
+
findings.append(
|
|
79
|
+
Finding(
|
|
80
|
+
tool="ruff",
|
|
81
|
+
rule="format",
|
|
82
|
+
severity=Severity.LOW,
|
|
83
|
+
file=to_repo_relative(path, repo_root),
|
|
84
|
+
line=0,
|
|
85
|
+
message="file is not formatted (run `ruff format`)",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_unformatted_path(line: str) -> str | None:
|
|
92
|
+
"""Pull the file path out of a `ruff format --check` output line."""
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line.startswith("--> "): # newer ruff: " --> path:line:col"
|
|
95
|
+
loc = line[4:]
|
|
96
|
+
head, _, tail = loc.rpartition(":")
|
|
97
|
+
base, _, mid = head.rpartition(":")
|
|
98
|
+
if tail.isdigit() and mid.isdigit() and base:
|
|
99
|
+
return base
|
|
100
|
+
return loc
|
|
101
|
+
if line.startswith("Would reformat: "): # older ruff
|
|
102
|
+
return line[len("Would reformat: ") :]
|
|
103
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Semgrep wrapper: staged files -> list[Finding].
|
|
2
|
+
|
|
3
|
+
Runs Semgrep with our local rule set and maps Semgrep's JSON results to the
|
|
4
|
+
normalized ``Finding`` model.
|
|
5
|
+
|
|
6
|
+
Rule-source decision (milestone 6): we use only the local ``rules/semgrep/``
|
|
7
|
+
directory - fully offline and deterministic, no code or metadata leaves the
|
|
8
|
+
machine. Registry rule packs (``p/python``, ``p/security-audit``) can be
|
|
9
|
+
added as an opt-in config option later.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from git_security.models.finding import Finding, Severity
|
|
16
|
+
from git_security.scanners.base import run_tool, to_repo_relative
|
|
17
|
+
|
|
18
|
+
_NOT_FOUND_MSG = (
|
|
19
|
+
"[git-security-tool] semgrep not found on PATH - skipping (pip install semgrep)"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Semgrep severities -> our severity scale.
|
|
23
|
+
_SEVERITY_MAP = {
|
|
24
|
+
"ERROR": Severity.HIGH,
|
|
25
|
+
"WARNING": Severity.MEDIUM,
|
|
26
|
+
"INFO": Severity.LOW,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_semgrep(files: list[str], repo_root: Path, rules_dir: Path) -> list[Finding]:
|
|
31
|
+
"""Scan *files* with the rules in *rules_dir* and return normalized findings."""
|
|
32
|
+
if not files:
|
|
33
|
+
return []
|
|
34
|
+
if not rules_dir.exists():
|
|
35
|
+
print(f"[git-security-tool] semgrep rules not found at {rules_dir} - skipping")
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
proc = run_tool(
|
|
39
|
+
[
|
|
40
|
+
"semgrep",
|
|
41
|
+
"--config",
|
|
42
|
+
str(rules_dir),
|
|
43
|
+
"--json",
|
|
44
|
+
"--quiet",
|
|
45
|
+
"--metrics=off",
|
|
46
|
+
"--disable-version-check",
|
|
47
|
+
"--",
|
|
48
|
+
*files,
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
if proc is None:
|
|
52
|
+
print(_NOT_FOUND_MSG)
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
# Semgrep exit codes: 0 = no findings, 1 = findings found, >=2 = error.
|
|
56
|
+
if proc.returncode not in (0, 1):
|
|
57
|
+
detail = proc.stderr.strip() or proc.stdout.strip()
|
|
58
|
+
raise RuntimeError(f"semgrep failed: {detail}")
|
|
59
|
+
|
|
60
|
+
if not proc.stdout.strip():
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
data = json.loads(proc.stdout)
|
|
64
|
+
return [_to_finding(r, repo_root) for r in data.get("results", [])]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _to_finding(result: dict, repo_root: Path) -> Finding:
|
|
68
|
+
extra = result.get("extra") or {}
|
|
69
|
+
start = result.get("start") or {}
|
|
70
|
+
# Semgrep prefixes check_id with the rule file's path
|
|
71
|
+
# (e.g. "src.git_security.rules.semgrep.python-dangerous-eval").
|
|
72
|
+
# Keep only the rule's own id.
|
|
73
|
+
check_id = result.get("check_id") or ""
|
|
74
|
+
return Finding(
|
|
75
|
+
tool="semgrep",
|
|
76
|
+
rule=check_id.rsplit(".", 1)[-1],
|
|
77
|
+
severity=_SEVERITY_MAP.get(extra.get("severity", ""), Severity.MEDIUM),
|
|
78
|
+
file=to_repo_relative(result.get("path") or "", repo_root),
|
|
79
|
+
line=start.get("line") or 0,
|
|
80
|
+
message=(extra.get("message") or "").strip(),
|
|
81
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Optional AI remediation suggestions - advisory, never changes the block.
|
|
2
|
+
|
|
3
|
+
Runs only when all of these hold:
|
|
4
|
+
|
|
5
|
+
* ``[ai] enabled = true`` in ``.git-security-tool.toml``
|
|
6
|
+
* the configured provider's API key is set (``ANTHROPIC_API_KEY`` /
|
|
7
|
+
``GEMINI_API_KEY``)
|
|
8
|
+
* for the anthropic provider, the ``anthropic`` package is installed
|
|
9
|
+
|
|
10
|
+
It never modifies files - it prints suggestions for the developer - and it
|
|
11
|
+
always announces before sending code off the machine. Secret-bearing files
|
|
12
|
+
are filtered out before this module is reached (see ``scan.py``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from git_security.config.loader import AIConfig
|
|
16
|
+
from git_security.models.finding import Finding
|
|
17
|
+
from git_security.suggestions.providers import PROVIDERS
|
|
18
|
+
|
|
19
|
+
_PREFIX = "[git-security-tool]"
|
|
20
|
+
|
|
21
|
+
_SYSTEM = (
|
|
22
|
+
"You are a security code-review assistant. You are given one static-analysis "
|
|
23
|
+
"finding and the surrounding code. Reply with exactly two parts: "
|
|
24
|
+
"(1) one sentence naming the risk, then "
|
|
25
|
+
"(2) a minimal safe rewrite of only the affected lines, in a code block. "
|
|
26
|
+
"No preamble, no extra commentary."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def suggestions_available(provider_name: str) -> bool:
|
|
31
|
+
provider = PROVIDERS.get(provider_name)
|
|
32
|
+
return provider is not None and provider.available()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_prompt(finding: Finding, snippet: str) -> str:
|
|
36
|
+
return (
|
|
37
|
+
f"Finding: {finding.tool}:{finding.rule} [{finding.severity.name}]\n"
|
|
38
|
+
f"Location: {finding.file}:{finding.line}\n"
|
|
39
|
+
f"Message: {finding.message}\n\n"
|
|
40
|
+
f"Code:\n{snippet}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def explain(items: list[tuple[Finding, str]], config: AIConfig) -> None:
|
|
45
|
+
"""Print a suggestion for each (finding, code snippet) pair."""
|
|
46
|
+
provider = PROVIDERS[config.provider]
|
|
47
|
+
model = config.model or provider.default_model
|
|
48
|
+
|
|
49
|
+
print(
|
|
50
|
+
f"{_PREFIX} sending {len(items)} finding(s) and code context to "
|
|
51
|
+
f"{provider.name} ({model}) for remediation suggestions..."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
for finding, snippet in items:
|
|
55
|
+
try:
|
|
56
|
+
text = provider.complete(_SYSTEM, build_prompt(finding, snippet), model)
|
|
57
|
+
except RuntimeError as exc:
|
|
58
|
+
print(f"{_PREFIX} suggestion failed for {finding.file}: {exc}")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
print(
|
|
62
|
+
f"\n{_PREFIX} suggestion for {finding.file}:{finding.line} "
|
|
63
|
+
f"({finding.tool}:{finding.rule}):"
|
|
64
|
+
)
|
|
65
|
+
for line in text.strip().splitlines():
|
|
66
|
+
print(f" {line}")
|
|
67
|
+
print()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""LLM providers for remediation suggestions.
|
|
2
|
+
|
|
3
|
+
Each provider exposes the same tiny surface:
|
|
4
|
+
|
|
5
|
+
* ``name`` - identifier used in ``[ai] provider`` and in messages
|
|
6
|
+
* ``default_model`` - used when ``[ai] model`` is left blank
|
|
7
|
+
* ``env_var`` - the API-key environment variable
|
|
8
|
+
* ``available()`` - key present (and any SDK importable)
|
|
9
|
+
* ``complete(system, user, model) -> str`` - one call, text back,
|
|
10
|
+
``RuntimeError`` on any failure
|
|
11
|
+
|
|
12
|
+
This layer is advisory only. The deterministic scanners decide whether a
|
|
13
|
+
commit is blocked; providers just explain findings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
_TIMEOUT = 30
|
|
22
|
+
_MAX_OUTPUT_TOKENS = 2048
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AnthropicProvider:
|
|
26
|
+
name = "anthropic"
|
|
27
|
+
default_model = "claude-opus-5"
|
|
28
|
+
env_var = "ANTHROPIC_API_KEY"
|
|
29
|
+
|
|
30
|
+
def available(self) -> bool:
|
|
31
|
+
if not os.environ.get(self.env_var):
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
import anthropic # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def complete(self, system: str, user: str, model: str) -> str:
|
|
40
|
+
import anthropic
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
client = anthropic.Anthropic()
|
|
44
|
+
response = client.messages.create(
|
|
45
|
+
model=model or self.default_model,
|
|
46
|
+
max_tokens=_MAX_OUTPUT_TOKENS,
|
|
47
|
+
system=system,
|
|
48
|
+
messages=[{"role": "user", "content": user}],
|
|
49
|
+
)
|
|
50
|
+
except anthropic.AnthropicError as exc:
|
|
51
|
+
raise RuntimeError(f"Anthropic API error: {exc}") from exc
|
|
52
|
+
return "".join(b.text for b in response.content if b.type == "text")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GeminiProvider:
|
|
56
|
+
name = "gemini"
|
|
57
|
+
default_model = "gemini-2.5-flash"
|
|
58
|
+
env_var = "GEMINI_API_KEY"
|
|
59
|
+
_URL = (
|
|
60
|
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
61
|
+
"{model}:generateContent"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def available(self) -> bool:
|
|
65
|
+
return bool(os.environ.get(self.env_var))
|
|
66
|
+
|
|
67
|
+
def complete(self, system: str, user: str, model: str) -> str:
|
|
68
|
+
url = self._URL.format(model=model or self.default_model)
|
|
69
|
+
body = json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"systemInstruction": {"parts": [{"text": system}]},
|
|
72
|
+
"contents": [{"parts": [{"text": user}]}],
|
|
73
|
+
"generationConfig": {"maxOutputTokens": _MAX_OUTPUT_TOKENS},
|
|
74
|
+
}
|
|
75
|
+
).encode()
|
|
76
|
+
request = urllib.request.Request( # noqa: S310 - hardcoded https host
|
|
77
|
+
url,
|
|
78
|
+
data=body,
|
|
79
|
+
headers={
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"x-goog-api-key": os.environ[self.env_var],
|
|
82
|
+
},
|
|
83
|
+
method="POST",
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp: # noqa: S310
|
|
87
|
+
payload = json.load(resp)
|
|
88
|
+
except urllib.error.HTTPError as exc:
|
|
89
|
+
detail = exc.read().decode("utf-8", "replace")[:300]
|
|
90
|
+
raise RuntimeError(f"Gemini API error {exc.code}: {detail}") from exc
|
|
91
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
92
|
+
raise RuntimeError(f"Gemini request failed: {exc}") from exc
|
|
93
|
+
|
|
94
|
+
candidates = payload.get("candidates") or []
|
|
95
|
+
if not candidates:
|
|
96
|
+
reason = payload.get("promptFeedback", {}).get(
|
|
97
|
+
"blockReason", "no candidates returned"
|
|
98
|
+
)
|
|
99
|
+
raise RuntimeError(f"Gemini returned nothing ({reason})")
|
|
100
|
+
parts = candidates[0].get("content", {}).get("parts") or []
|
|
101
|
+
text = "".join(p.get("text", "") for p in parts)
|
|
102
|
+
if not text:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"Gemini response had no text "
|
|
105
|
+
f"(finishReason={candidates[0].get('finishReason')})"
|
|
106
|
+
)
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
PROVIDERS = {p.name: p for p in (AnthropicProvider(), GeminiProvider())}
|
|
@@ -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).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
git_security/__init__.py,sha256=9Ib4IpixasuGfmRXPglAU8a7bztRuL2xldeiBi0beYY,92
|
|
2
|
+
git_security/__main__.py,sha256=67q1VsxrDi8_JYkHoBXVJ2NXsDEoiu58Qb4LLU8zhys,100
|
|
3
|
+
git_security/baseline.py,sha256=WwmkzXrq8wUtBHW0h502Z16kPDhK2Nh-9M8DQlD0h0Y,2216
|
|
4
|
+
git_security/cli.py,sha256=SyV3VTK6nUxxCRl4fDmGCyIWSumkZR4ny5K_PhFI7QI,2745
|
|
5
|
+
git_security/ignore.py,sha256=0ORE9hsX3aZTxGSRrXE7Kp2tadFa9mU7-Aawbi2FDFc,882
|
|
6
|
+
git_security/scan.py,sha256=MUk08tkuKYKuAVEDycn-Fe40CxUNIpHUywx16WK7p6o,8870
|
|
7
|
+
git_security/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
git_security/config/loader.py,sha256=mDJOajILb5jS8HA0YeCDu4jEYWMcXfKr5gEOEnCoMc4,4152
|
|
9
|
+
git_security/git/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
git_security/git/diff.py,sha256=filp69ZDfaLJzqAuj-_Tot4YSKs9Z3_XkbG-8fMzK-o,1826
|
|
11
|
+
git_security/git/hooks.py,sha256=3ELh7MU5Zp9fQ96wmXEHn7YfNYA3Oh7-FPNPFq7DM0k,808
|
|
12
|
+
git_security/git/repository.py,sha256=FofI9V0T0MV4z9YxnYSQVlGt5f-FIaqnER4dO-vormg,1152
|
|
13
|
+
git_security/installer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
git_security/installer/dependencies.py,sha256=XJXsAAx3QYY0yZxSPV9kVT2k9vSl7wkVSiLa2UgHwug,453
|
|
15
|
+
git_security/installer/git_hook.py,sha256=LJlG10PW0lmQO4SDESgpnIULF-Z5jGVH2XNq703jNoo,3493
|
|
16
|
+
git_security/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
git_security/models/finding.py,sha256=K05V8_0xIP22RsIvnMnQA8vQxExZK3grJ1VVYQBvP9A,935
|
|
18
|
+
git_security/policy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
git_security/policy/engine.py,sha256=SReP9g9BaDB3tCue3mhLhjolHVX4pvVEU2eNJchx05g,1278
|
|
20
|
+
git_security/reporter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
git_security/reporter/sarif.py,sha256=vAq4Z-MFwzm1VgHma3_zotvm04LRIEARzX7YbNEgE2U,2088
|
|
22
|
+
git_security/reporter/terminal.py,sha256=5tyczZHGDEsM3uKVB_YRwvprmpsd0kirOY2WWSLl8is,1545
|
|
23
|
+
git_security/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
git_security/rules/semgrep/crypto_tls.yml,sha256=q8CzV3TdVnq1vgOWn1bdmXftfcw-yW571KQookimN_c,1408
|
|
25
|
+
git_security/rules/semgrep/deserialization.yml,sha256=ux1ZmYP1dbKLw_uddAyYDx90jLmNOLbd6ARohUuNi20,1450
|
|
26
|
+
git_security/rules/semgrep/filesystem_net.yml,sha256=5-bKkebIABTjEtyeCIHC9Md-tVefhnREw4DDcoGuSbI,1348
|
|
27
|
+
git_security/rules/semgrep/injection.yml,sha256=I5e0fzltTbnxQBhhqCYpC64daE-85cr1hmRrzjAnrik,1393
|
|
28
|
+
git_security/rules/semgrep/web.yml,sha256=v47KwR3JCUyD0Jpy6bpVkZY8VOA7pVPyTa4RutVZY5g,1194
|
|
29
|
+
git_security/scanners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
git_security/scanners/base.py,sha256=DbJAd1eS7ruXReLezgmJ8zjSSTkSftUAFXWetVWlOWw,1119
|
|
31
|
+
git_security/scanners/gitleaks.py,sha256=3tByuHxBVLtlj2ceikYe9Q2TcYffb46RffSQGRilt78,1869
|
|
32
|
+
git_security/scanners/ruff.py,sha256=SDMeTevpZ5I50PTkaeDpVFbuwDXJhpuHKtYbm1HUnaQ,3652
|
|
33
|
+
git_security/scanners/semgrep.py,sha256=zV4-HeVArjx3LgS12SMpEk4pqlHCXF-_yxNjWu9kpiw,2603
|
|
34
|
+
git_security/suggestions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
|
+
git_security/suggestions/llm.py,sha256=0-KOjgHFcfgGENK--8JQBRk7ACiY-IZVu9xM-k1am_E,2409
|
|
36
|
+
git_security/suggestions/providers.py,sha256=ku7G6Iv0fV2ScsN1OC4kNSpnvu77OsgPAZ_gQeQVSyk,3830
|
|
37
|
+
git_security_tool-0.1.0.dist-info/METADATA,sha256=vneuYGLJ3vXQCcMrLF2WRySJZw35cSp0CZkZQDgLbRI,5569
|
|
38
|
+
git_security_tool-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
39
|
+
git_security_tool-0.1.0.dist-info/entry_points.txt,sha256=NXfJ9zp9fFPnybQF1HcwP8msR7qXy6GbAeqAS6eBmbQ,60
|
|
40
|
+
git_security_tool-0.1.0.dist-info/licenses/LICENSE,sha256=ymE5r69euauSBB_4QN1rLQuZ5yEOveh0Ctx3z5SCzw4,1079
|
|
41
|
+
git_security_tool-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|