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,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
|
+
)
|