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.
Files changed (41) hide show
  1. git_security/__init__.py +3 -0
  2. git_security/__main__.py +5 -0
  3. git_security/baseline.py +67 -0
  4. git_security/cli.py +87 -0
  5. git_security/config/__init__.py +0 -0
  6. git_security/config/loader.py +127 -0
  7. git_security/git/__init__.py +0 -0
  8. git_security/git/diff.py +50 -0
  9. git_security/git/hooks.py +25 -0
  10. git_security/git/repository.py +38 -0
  11. git_security/ignore.py +27 -0
  12. git_security/installer/__init__.py +0 -0
  13. git_security/installer/dependencies.py +14 -0
  14. git_security/installer/git_hook.py +122 -0
  15. git_security/models/__init__.py +0 -0
  16. git_security/models/finding.py +33 -0
  17. git_security/policy/__init__.py +0 -0
  18. git_security/policy/engine.py +38 -0
  19. git_security/reporter/__init__.py +0 -0
  20. git_security/reporter/sarif.py +72 -0
  21. git_security/reporter/terminal.py +46 -0
  22. git_security/rules/__init__.py +0 -0
  23. git_security/rules/semgrep/crypto_tls.yml +37 -0
  24. git_security/rules/semgrep/deserialization.yml +38 -0
  25. git_security/rules/semgrep/filesystem_net.yml +38 -0
  26. git_security/rules/semgrep/injection.yml +43 -0
  27. git_security/rules/semgrep/web.yml +32 -0
  28. git_security/scan.py +245 -0
  29. git_security/scanners/__init__.py +0 -0
  30. git_security/scanners/base.py +34 -0
  31. git_security/scanners/gitleaks.py +59 -0
  32. git_security/scanners/ruff.py +103 -0
  33. git_security/scanners/semgrep.py +81 -0
  34. git_security/suggestions/__init__.py +0 -0
  35. git_security/suggestions/llm.py +67 -0
  36. git_security/suggestions/providers.py +110 -0
  37. git_security_tool-0.1.0.dist-info/METADATA +149 -0
  38. git_security_tool-0.1.0.dist-info/RECORD +41 -0
  39. git_security_tool-0.1.0.dist-info/WHEEL +4 -0
  40. git_security_tool-0.1.0.dist-info/entry_points.txt +2 -0
  41. git_security_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,72 @@
1
+ """SARIF 2.1.0 output, for CI / GitHub code scanning.
2
+
3
+ ``git-security-tool scan --all --format sarif`` prints this to stdout; upload
4
+ it with the ``github/codeql-action/upload-sarif`` step to populate the
5
+ repository's Security tab.
6
+ """
7
+
8
+ import json
9
+
10
+ from git_security.models.finding import Finding, Severity
11
+
12
+ _INFO_URI = "https://github.com/MustafaBasit521/commit-guard"
13
+
14
+ _LEVEL = {
15
+ Severity.INFO: "note",
16
+ Severity.LOW: "note",
17
+ Severity.MEDIUM: "warning",
18
+ Severity.HIGH: "error",
19
+ Severity.CRITICAL: "error",
20
+ }
21
+
22
+
23
+ def to_sarif(findings: list[Finding]) -> str:
24
+ rules: dict[str, dict] = {}
25
+ results: list[dict] = []
26
+
27
+ for finding in findings:
28
+ rule_id = f"{finding.tool}.{finding.rule}"
29
+ rules.setdefault(
30
+ rule_id,
31
+ {
32
+ "id": rule_id,
33
+ "name": finding.rule or finding.tool,
34
+ "shortDescription": {"text": finding.rule or finding.tool},
35
+ },
36
+ )
37
+ results.append(
38
+ {
39
+ "ruleId": rule_id,
40
+ "level": _LEVEL.get(finding.severity, "warning"),
41
+ "message": {"text": finding.message},
42
+ "locations": [
43
+ {
44
+ "physicalLocation": {
45
+ "artifactLocation": {"uri": finding.file},
46
+ "region": {"startLine": max(finding.line, 1)},
47
+ }
48
+ }
49
+ ],
50
+ }
51
+ )
52
+
53
+ doc = {
54
+ "version": "2.1.0",
55
+ "$schema": (
56
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/"
57
+ "Schemata/sarif-schema-2.1.0.json"
58
+ ),
59
+ "runs": [
60
+ {
61
+ "tool": {
62
+ "driver": {
63
+ "name": "git-security-tool",
64
+ "informationUri": _INFO_URI,
65
+ "rules": list(rules.values()),
66
+ }
67
+ },
68
+ "results": results,
69
+ }
70
+ ],
71
+ }
72
+ return json.dumps(doc, indent=2)
@@ -0,0 +1,46 @@
1
+ """Turn a policy Decision into human-readable terminal output.
2
+
3
+ All the "how do we present findings" logic lives here, so ``main()`` only
4
+ has to orchestrate and pick an exit code. A JSON / SARIF reporter for CI can
5
+ be added alongside this without touching ``main()``.
6
+ """
7
+
8
+ from git_security.models.finding import Finding
9
+ from git_security.policy.engine import Decision
10
+
11
+ _PREFIX = "[git-security-tool]"
12
+ _MESSAGE_LIMIT = 120
13
+
14
+
15
+ def report(decision: Decision) -> None:
16
+ """Print warnings, then blocking findings, then a one-line summary."""
17
+ if not decision.warnings and not decision.blocking:
18
+ print(f"{_PREFIX} no findings")
19
+ return
20
+
21
+ if decision.warnings:
22
+ _section("warning(s), do not block", decision.warnings)
23
+ if decision.blocking:
24
+ _section("blocking finding(s)", decision.blocking)
25
+
26
+ print(
27
+ f"{_PREFIX} summary: {len(decision.blocking)} blocking, "
28
+ f"{len(decision.warnings)} warning(s)"
29
+ )
30
+
31
+
32
+ def _section(title: str, findings: list[Finding]) -> None:
33
+ print(f"{_PREFIX} {len(findings)} {title}:")
34
+ for f in sorted(findings, key=lambda x: (x.file, x.line, x.tool)):
35
+ print(
36
+ f" [{f.severity.name}] {f.tool}:{f.rule} "
37
+ f"{f.file}:{f.line} - {_one_line(f.message)}"
38
+ )
39
+
40
+
41
+ def _one_line(message: str, limit: int = _MESSAGE_LIMIT) -> str:
42
+ """Collapse whitespace/newlines and truncate long messages."""
43
+ message = " ".join(message.split())
44
+ if len(message) <= limit:
45
+ return message
46
+ return message[: limit - 1] + "…"
File without changes
@@ -0,0 +1,37 @@
1
+ # Weak cryptography and disabled transport security.
2
+ rules:
3
+ - id: python-weak-hash
4
+ languages: [python]
5
+ severity: WARNING
6
+ message: >
7
+ MD5 and SHA-1 are broken for security use (practical collisions). Use
8
+ SHA-256 or stronger. For a non-security checksum, pass
9
+ usedforsecurity=False to make the intent explicit.
10
+ patterns:
11
+ - pattern-either:
12
+ - pattern: hashlib.md5(...)
13
+ - pattern: hashlib.sha1(...)
14
+ - pattern-not: hashlib.md5(..., usedforsecurity=False)
15
+ - pattern-not: hashlib.sha1(..., usedforsecurity=False)
16
+
17
+ - id: python-tls-verification-disabled
18
+ languages: [python]
19
+ severity: ERROR
20
+ message: >
21
+ TLS certificate verification is disabled (verify=False), which exposes
22
+ the connection to man-in-the-middle attacks. Remove it, or pin a CA
23
+ bundle with verify="/path/to/ca.pem".
24
+ pattern-either:
25
+ - pattern: requests.$M(..., verify=False, ...)
26
+ - pattern: httpx.$M(..., verify=False, ...)
27
+ - pattern: httpx.Client(..., verify=False, ...)
28
+
29
+ - id: python-ssl-unverified-context
30
+ languages: [python]
31
+ severity: ERROR
32
+ message: >
33
+ ssl._create_unverified_context() / _create_stdlib_context disables
34
+ certificate validation for every connection that uses it.
35
+ pattern-either:
36
+ - pattern: ssl._create_unverified_context(...)
37
+ - pattern: ssl._create_stdlib_context(...)
@@ -0,0 +1,38 @@
1
+ # Deserialization of untrusted data -> arbitrary object construction / RCE.
2
+ rules:
3
+ - id: python-pickle-load
4
+ languages: [python]
5
+ severity: ERROR
6
+ message: >
7
+ pickle can execute arbitrary code while deserializing. Never unpickle
8
+ data you do not fully trust; use JSON or a schema-validated format.
9
+ pattern-either:
10
+ - pattern: pickle.load(...)
11
+ - pattern: pickle.loads(...)
12
+ - pattern: cPickle.load(...)
13
+ - pattern: cPickle.loads(...)
14
+
15
+ - id: python-yaml-unsafe-load
16
+ languages: [python]
17
+ severity: ERROR
18
+ message: >
19
+ yaml.load() without a safe loader can construct arbitrary Python objects
20
+ from the input. Use yaml.safe_load(...) or pass Loader=yaml.SafeLoader.
21
+ patterns:
22
+ - pattern: yaml.load(...)
23
+ - pattern-not: yaml.load(..., Loader=yaml.SafeLoader)
24
+ - pattern-not: yaml.load(..., Loader=yaml.CSafeLoader)
25
+
26
+ - id: python-insecure-xml-parser
27
+ languages: [python]
28
+ severity: WARNING
29
+ message: >
30
+ Parsing XML with the standard library is exposed to entity-expansion
31
+ ("billion laughs") and, with some parsers, external-entity attacks. Use
32
+ the defusedxml package for untrusted input.
33
+ pattern-either:
34
+ - pattern: xml.etree.ElementTree.parse(...)
35
+ - pattern: xml.etree.ElementTree.fromstring(...)
36
+ - pattern: xml.dom.minidom.parse(...)
37
+ - pattern: xml.dom.minidom.parseString(...)
38
+ - pattern: xml.sax.parse(...)
@@ -0,0 +1,38 @@
1
+ # Filesystem and network hygiene.
2
+ rules:
3
+ - id: python-archive-extractall
4
+ languages: [python]
5
+ severity: ERROR
6
+ message: >
7
+ extractall()/extract() on an untrusted archive can write files outside
8
+ the target directory (path traversal - Zip Slip, CVE-2007-4559).
9
+ Validate each member's path before extracting.
10
+ pattern-either:
11
+ - pattern: $ARCHIVE.extractall(...)
12
+ - pattern: $ARCHIVE.extract(...)
13
+
14
+ - id: python-tempfile-mktemp
15
+ languages: [python]
16
+ severity: WARNING
17
+ message: >
18
+ tempfile.mktemp() only returns a name - there is a race between that and
19
+ creating the file. Use tempfile.mkstemp() or NamedTemporaryFile().
20
+ pattern: tempfile.mktemp(...)
21
+
22
+ - id: python-requests-no-timeout
23
+ languages: [python]
24
+ severity: WARNING
25
+ message: >
26
+ A requests call with no timeout can hang forever, which is a
27
+ denial-of-service risk. Pass timeout=<seconds>.
28
+ patterns:
29
+ - pattern-either:
30
+ - pattern: requests.get(...)
31
+ - pattern: requests.post(...)
32
+ - pattern: requests.put(...)
33
+ - pattern: requests.patch(...)
34
+ - pattern: requests.delete(...)
35
+ - pattern: requests.head(...)
36
+ - pattern: requests.options(...)
37
+ - pattern: requests.request(...)
38
+ - pattern-not: requests.$M(..., timeout=$T)
@@ -0,0 +1,43 @@
1
+ # Command / code injection: input reaching an interpreter or a shell.
2
+ rules:
3
+ - id: python-dangerous-eval
4
+ languages: [python]
5
+ severity: ERROR
6
+ message: >
7
+ eval() executes arbitrary code from its argument. If the input is ever
8
+ attacker-influenced this is remote code execution. Parse or dispatch
9
+ explicitly instead.
10
+ pattern: eval(...)
11
+
12
+ - id: python-dangerous-exec
13
+ languages: [python]
14
+ severity: ERROR
15
+ message: >
16
+ exec() runs arbitrary code. Avoid it; use explicit logic or a narrow
17
+ dispatch table.
18
+ pattern: exec(...)
19
+
20
+ - id: python-os-system
21
+ languages: [python]
22
+ severity: ERROR
23
+ message: >
24
+ os.system() runs its argument through the shell - command injection if
25
+ any part is dynamic. Use subprocess.run([...]) with an argument list.
26
+ pattern: os.system(...)
27
+
28
+ - id: python-os-popen
29
+ languages: [python]
30
+ severity: ERROR
31
+ message: >
32
+ os.popen() runs its argument through the shell. Use subprocess with an
33
+ argument list instead.
34
+ pattern: os.popen(...)
35
+
36
+ - id: python-subprocess-shell-true
37
+ languages: [python]
38
+ severity: WARNING
39
+ message: >
40
+ subprocess called with shell=True runs the command through a shell,
41
+ enabling shell injection if any argument is dynamic. Pass an argument
42
+ list and omit shell=True.
43
+ pattern: subprocess.$FUNC(..., shell=True, ...)
@@ -0,0 +1,32 @@
1
+ # Web-framework footguns: debug servers and disabled output escaping.
2
+ rules:
3
+ - id: python-flask-debug-true
4
+ languages: [python]
5
+ severity: ERROR
6
+ message: >
7
+ Running a web app with debug=True exposes the Werkzeug interactive
8
+ debugger, which allows remote code execution. Never enable it outside
9
+ local development.
10
+ pattern: $APP.run(..., debug=True, ...)
11
+
12
+ - id: python-jinja-autoescape-disabled
13
+ languages: [python]
14
+ severity: WARNING
15
+ message: >
16
+ A Jinja2 Environment without autoescaping does not HTML-escape template
17
+ output, risking XSS. Pass autoescape=True or
18
+ autoescape=select_autoescape(...).
19
+ patterns:
20
+ - pattern: jinja2.Environment(...)
21
+ - pattern-not: jinja2.Environment(..., autoescape=True)
22
+ - pattern-not: jinja2.Environment(..., autoescape=select_autoescape(...))
23
+
24
+ - id: python-django-mark-safe
25
+ languages: [python]
26
+ severity: WARNING
27
+ message: >
28
+ mark_safe() turns off Django's HTML autoescaping for this value. If any
29
+ part of it is user-controlled, this is an XSS sink.
30
+ pattern-either:
31
+ - pattern: mark_safe(...)
32
+ - pattern: django.utils.safestring.mark_safe(...)
git_security/scan.py ADDED
@@ -0,0 +1,245 @@
1
+ """The scan pipeline.
2
+
3
+ Gather the files to check, run every scanner, evaluate policy, report, and
4
+ return an exit code.
5
+
6
+ * ``run_scan("staged")`` - the pre-commit path: scans the exact staged blob
7
+ content and can offer AI suggestions.
8
+ * ``run_scan("all")`` - the CI / audit path: scans every tracked file in the
9
+ working tree; supports ``--format sarif``.
10
+ * ``write_baseline_file()`` - records the current findings so a repo can
11
+ adopt the tool without fixing everything first.
12
+
13
+ Escape hatch (staged mode only): set GIT_SECURITY_NO_BLOCK=1 to report
14
+ findings but never block.
15
+ """
16
+
17
+ import os
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+ from collections.abc import Callable
22
+ from importlib.resources import files
23
+ from pathlib import Path
24
+
25
+ from git_security.baseline import apply_baseline, load_baseline, write_baseline
26
+ from git_security.config.loader import AIConfig, Config, ConfigError, load_config
27
+ from git_security.git.diff import (
28
+ get_staged_file_content,
29
+ get_staged_files,
30
+ get_tracked_files,
31
+ materialize_staged,
32
+ )
33
+ from git_security.git.repository import get_repo_root
34
+ from git_security.ignore import filter_ignored, is_ignored
35
+ from git_security.models.finding import Finding
36
+ from git_security.policy.engine import evaluate
37
+ from git_security.reporter.sarif import to_sarif
38
+ from git_security.reporter.terminal import report
39
+ from git_security.scanners.gitleaks import run_gitleaks
40
+ from git_security.scanners.ruff import run_ruff, run_ruff_format
41
+ from git_security.scanners.semgrep import run_semgrep
42
+
43
+ _PREFIX = "[git-security-tool]"
44
+
45
+ # Config files a scanner may look for when deciding how to lint a project.
46
+ _PROJECT_CONFIG_FILES = ("pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg")
47
+
48
+
49
+ def _copy_project_config(repo_root: Path, dest: Path) -> None:
50
+ """Place the repo's lint config at the root of the materialized tree.
51
+
52
+ Scanners resolve their configuration by walking up from each file. The
53
+ throwaway directory has none, so without this Ruff (etc.) would silently
54
+ use built-in defaults instead of the project's settings.
55
+ """
56
+ for name in _PROJECT_CONFIG_FILES:
57
+ src = repo_root / name
58
+ if src.is_file():
59
+ shutil.copy2(src, dest / name)
60
+
61
+
62
+ def _safe_scan(name: str, run: Callable[[], list[Finding]], log) -> list[Finding]:
63
+ """Run one scanner, turning a crash into a clear message + empty result."""
64
+ try:
65
+ return run()
66
+ except RuntimeError as exc:
67
+ log(f"{_PREFIX} {name} failed to run - skipping it: {exc}")
68
+ return []
69
+
70
+
71
+ def _run_file_scanners(
72
+ paths: list[str], root: Path, rules_dir: Path, enabled: frozenset[str], log
73
+ ) -> list[Finding]:
74
+ findings: list[Finding] = []
75
+ if "ruff" in enabled:
76
+ findings += _safe_scan("ruff", lambda: run_ruff(paths, root), log)
77
+ findings += _safe_scan("ruff format", lambda: run_ruff_format(paths, root), log)
78
+ if "semgrep" in enabled:
79
+ findings += _safe_scan(
80
+ "semgrep", lambda: run_semgrep(paths, root, rules_dir), log
81
+ )
82
+ return findings
83
+
84
+
85
+ def _semgrep_rules_dir() -> Path:
86
+ """Directory of bundled Semgrep rules (shipped inside the package)."""
87
+ return Path(str(files("git_security") / "rules" / "semgrep"))
88
+
89
+
90
+ def _collect_findings(scope: str, log) -> tuple[list[Finding], Path, Config]:
91
+ """Run every enabled scanner for *scope*. Raises ConfigError."""
92
+ staged = scope == "staged"
93
+ repo_root = get_repo_root()
94
+ config = load_config(repo_root)
95
+
96
+ candidates = get_staged_files() if staged else get_tracked_files()
97
+ scannable = filter_ignored(candidates, config.ignore_paths)
98
+
99
+ if not scannable:
100
+ if not candidates:
101
+ log(
102
+ f"{_PREFIX} "
103
+ + ("nothing staged" if staged else "no tracked files to scan")
104
+ )
105
+ else:
106
+ log(f"{_PREFIX} nothing to scan after ignores")
107
+ return [], repo_root, config
108
+
109
+ ignored = len(candidates) - len(scannable)
110
+ suffix = f" ({ignored} ignored by config)" if ignored else ""
111
+ log(f"{_PREFIX} {len(scannable)} file(s) to scan{suffix}")
112
+
113
+ rules_dir = _semgrep_rules_dir()
114
+ enabled = config.enabled_scanners
115
+ findings: list[Finding] = []
116
+
117
+ if staged:
118
+ # Scan the exact staged blob content, not the working tree.
119
+ with tempfile.TemporaryDirectory(prefix="git-security-tool-") as tmp:
120
+ root = Path(tmp)
121
+ materialize_staged(scannable, root)
122
+ _copy_project_config(repo_root, root)
123
+ paths = [str(root / f) for f in scannable]
124
+ findings += _run_file_scanners(paths, root, rules_dir, enabled, log)
125
+ else:
126
+ paths = [str(repo_root / f) for f in scannable]
127
+ findings += _run_file_scanners(paths, repo_root, rules_dir, enabled, log)
128
+
129
+ if "gitleaks" in enabled:
130
+ leaks = _safe_scan("gitleaks", lambda: run_gitleaks(staged=staged), log)
131
+ findings += [f for f in leaks if not is_ignored(f.file, config.ignore_paths)]
132
+
133
+ return findings, repo_root, config
134
+
135
+
136
+ # --- AI suggestions ---------------------------------------------------------
137
+
138
+
139
+ def _staged_snippet(finding: Finding, context: int = 5) -> str:
140
+ """A few lines of the staged file around the finding, with line numbers."""
141
+ try:
142
+ lines = get_staged_file_content(finding.file).splitlines()
143
+ except RuntimeError:
144
+ return ""
145
+ if finding.line <= 0:
146
+ window = lines[: 2 * context + 1]
147
+ start = 1
148
+ else:
149
+ start = max(1, finding.line - context)
150
+ window = lines[start - 1 : finding.line + context]
151
+ return "\n".join(f"{start + i}: {text}" for i, text in enumerate(window))
152
+
153
+
154
+ def _run_ai_suggestions(
155
+ blocking: list[Finding], all_findings: list[Finding], ai: AIConfig
156
+ ) -> None:
157
+ from git_security.suggestions.llm import explain, suggestions_available
158
+
159
+ if not suggestions_available(ai.provider):
160
+ print(
161
+ f"{_PREFIX} AI suggestions are enabled but the '{ai.provider}' "
162
+ "provider is unavailable - check its API key "
163
+ "(ANTHROPIC_API_KEY / GEMINI_API_KEY)"
164
+ )
165
+ return
166
+
167
+ # Never send secret-bearing code off the machine: skip Gitleaks findings
168
+ # and skip any file that Gitleaks flagged.
169
+ secret_files = {f.file for f in all_findings if f.tool == "gitleaks"}
170
+ targets = [
171
+ f for f in blocking if f.tool != "gitleaks" and f.file not in secret_files
172
+ ][: ai.max_findings]
173
+
174
+ if not targets:
175
+ print(
176
+ f"{_PREFIX} no findings eligible for AI suggestions "
177
+ "(secret-bearing code is never sent)"
178
+ )
179
+ return
180
+ explain([(f, _staged_snippet(f)) for f in targets], ai)
181
+
182
+
183
+ # --- entry points ----------------------------------------------------------
184
+
185
+
186
+ def run_scan(scope: str = "staged", output_format: str = "text") -> int:
187
+ staged = scope == "staged"
188
+ sarif = output_format == "sarif"
189
+ # In sarif mode stdout must be pure JSON, so progress goes to stderr.
190
+ log: Callable[[str], None] = (
191
+ (lambda m: print(m, file=sys.stderr)) if sarif else print
192
+ )
193
+ log(
194
+ f"{_PREFIX} " + ("pre-commit security scan" if staged else "full security scan")
195
+ )
196
+
197
+ try:
198
+ findings, repo_root, config = _collect_findings(scope, log)
199
+ except ConfigError as exc:
200
+ log(f"{_PREFIX} config error: {exc}")
201
+ return 1
202
+
203
+ baseline = load_baseline(repo_root)
204
+ if baseline:
205
+ findings, suppressed = apply_baseline(findings, baseline)
206
+ if suppressed:
207
+ log(f"{_PREFIX} {suppressed} finding(s) suppressed by baseline")
208
+
209
+ decision = evaluate(findings, config.policy)
210
+
211
+ if sarif:
212
+ print(to_sarif(decision.blocking + decision.warnings))
213
+ else:
214
+ report(decision)
215
+
216
+ if staged and config.ai.enabled and decision.blocking:
217
+ _run_ai_suggestions(decision.blocking, findings, config.ai)
218
+
219
+ passed = "commit allowed" if staged else "scan passed"
220
+ blocked = "commit blocked" if staged else "scan failed"
221
+
222
+ if not decision.blocked:
223
+ log(f"{_PREFIX} {passed}")
224
+ return 0
225
+
226
+ if staged and os.environ.get("GIT_SECURITY_NO_BLOCK") == "1":
227
+ log(f"{_PREFIX} would block, but GIT_SECURITY_NO_BLOCK=1 is set - {passed}")
228
+ return 0
229
+
230
+ override = ", or set GIT_SECURITY_NO_BLOCK=1 to override" if staged else ""
231
+ log(f"{_PREFIX} {blocked} - fix the blocking findings above{override}")
232
+ return 1
233
+
234
+
235
+ def write_baseline_file() -> int:
236
+ """Record the current full-repo findings into the baseline file."""
237
+ try:
238
+ findings, repo_root, _ = _collect_findings("all", print)
239
+ except ConfigError as exc:
240
+ print(f"{_PREFIX} config error: {exc}")
241
+ return 1
242
+ path = write_baseline(repo_root, findings)
243
+ print(f"{_PREFIX} recorded {len(findings)} finding(s) in {path.name}")
244
+ print(f"{_PREFIX} commit this file; future scans will ignore those findings")
245
+ return 0
File without changes
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,59 @@
1
+ """Gitleaks wrapper: staged changes (or the whole tree) -> list[Finding].
2
+
3
+ Gitleaks is Git-aware, so this wrapper takes no file list:
4
+
5
+ * ``staged=True`` -> ``gitleaks git --staged`` (the staged diff, pre-commit)
6
+ * ``staged=False`` -> ``gitleaks dir .`` (every file on disk, CI / audit)
7
+
8
+ It maps Gitleaks' JSON to the normalized ``Finding`` model and nothing else.
9
+ """
10
+
11
+ import json
12
+
13
+ from git_security.models.finding import Finding, Severity
14
+ from git_security.scanners.base import run_tool
15
+
16
+ _NOT_FOUND_MSG = (
17
+ "[git-security-tool] gitleaks not found on PATH - skipping "
18
+ "(https://github.com/gitleaks/gitleaks#installing)"
19
+ )
20
+
21
+
22
+ def run_gitleaks(staged: bool = True) -> list[Finding]:
23
+ """Scan for secrets and return normalized findings."""
24
+ mode = ["git", "--staged"] if staged else ["dir", "."]
25
+ proc = run_tool(
26
+ [
27
+ "gitleaks",
28
+ *mode,
29
+ "--report-format",
30
+ "json",
31
+ "--report-path",
32
+ "-", # write the JSON report to stdout
33
+ "--redact", # never echo the actual secret value
34
+ "--no-banner",
35
+ ]
36
+ )
37
+ if proc is None:
38
+ print(_NOT_FOUND_MSG)
39
+ return []
40
+
41
+ # Gitleaks exit codes: 0 = no leaks, 1 = leaks found, other = error.
42
+ if proc.returncode not in (0, 1):
43
+ raise RuntimeError(f"gitleaks failed: {proc.stderr.strip()}")
44
+
45
+ if not proc.stdout.strip():
46
+ return []
47
+
48
+ return [_to_finding(item) for item in json.loads(proc.stdout)]
49
+
50
+
51
+ def _to_finding(item: dict) -> Finding:
52
+ return Finding(
53
+ tool="gitleaks",
54
+ rule=item.get("RuleID") or "",
55
+ severity=Severity.CRITICAL, # a committed secret always blocks
56
+ file=item.get("File") or "", # Gitleaks paths are already repo-relative
57
+ line=item.get("StartLine") or 0,
58
+ message=item.get("Description") or "",
59
+ )