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
git_security/__init__.py
ADDED
git_security/__main__.py
ADDED
git_security/baseline.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Suppress findings already recorded in a baseline file.
|
|
2
|
+
|
|
3
|
+
A baseline lets a repository adopt the tool without fixing every pre-existing
|
|
4
|
+
issue first: ``git-security-tool baseline`` records the current findings into
|
|
5
|
+
``.git-security-tool-baseline.json``, and later scans hide anything already
|
|
6
|
+
on that list. Newly introduced issues still surface and still block.
|
|
7
|
+
|
|
8
|
+
Fingerprint is ``(tool, rule, file, message)`` - deliberately line-independent
|
|
9
|
+
so unrelated edits to a file don't resurface a baselined finding. Regenerate
|
|
10
|
+
the baseline after large refactors.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from git_security.models.finding import Finding
|
|
17
|
+
|
|
18
|
+
BASELINE_FILENAME = ".git-security-tool-baseline.json"
|
|
19
|
+
_VERSION = 1
|
|
20
|
+
|
|
21
|
+
Fingerprint = tuple[str, str, str, str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fingerprint(finding: Finding) -> Fingerprint:
|
|
25
|
+
return (finding.tool, finding.rule, finding.file, finding.message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_baseline(repo_root: Path) -> set[Fingerprint]:
|
|
29
|
+
"""Fingerprints recorded in the baseline file, or an empty set."""
|
|
30
|
+
path = repo_root / BASELINE_FILENAME
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
return set()
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text())
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
return set()
|
|
37
|
+
return {
|
|
38
|
+
(
|
|
39
|
+
entry.get("tool", ""),
|
|
40
|
+
entry.get("rule", ""),
|
|
41
|
+
entry.get("file", ""),
|
|
42
|
+
entry.get("message", ""),
|
|
43
|
+
)
|
|
44
|
+
for entry in data.get("findings", [])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def apply_baseline(
|
|
49
|
+
findings: list[Finding], baseline: set[Fingerprint]
|
|
50
|
+
) -> tuple[list[Finding], int]:
|
|
51
|
+
"""Return (findings not in the baseline, count suppressed)."""
|
|
52
|
+
kept = [f for f in findings if _fingerprint(f) not in baseline]
|
|
53
|
+
return kept, len(findings) - len(kept)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_baseline(repo_root: Path, findings: list[Finding]) -> Path:
|
|
57
|
+
"""Write *findings* to the baseline file and return its path."""
|
|
58
|
+
entries = sorted({_fingerprint(f) for f in findings})
|
|
59
|
+
payload = {
|
|
60
|
+
"version": _VERSION,
|
|
61
|
+
"findings": [
|
|
62
|
+
{"tool": t, "rule": r, "file": f, "message": m} for (t, r, f, m) in entries
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
path = repo_root / BASELINE_FILENAME
|
|
66
|
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
67
|
+
return path
|
git_security/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command-line interface for git-security-tool.
|
|
2
|
+
|
|
3
|
+
Thin layer: parse arguments, dispatch to an action, return an exit code.
|
|
4
|
+
All real work lives in the modules this calls. argparse (stdlib) is enough
|
|
5
|
+
for the handful of subcommands we have - no CLI framework needed yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
|
|
10
|
+
from git_security import __version__
|
|
11
|
+
from git_security.installer.git_hook import install, status, uninstall
|
|
12
|
+
from git_security.scan import run_scan, write_baseline_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="git-security-tool",
|
|
18
|
+
description="Local Git security & code-quality gate (pre-commit).",
|
|
19
|
+
)
|
|
20
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
scan_parser = subparsers.add_parser(
|
|
23
|
+
"scan",
|
|
24
|
+
help="scan staged changes (or --all tracked files); non-zero if blocked",
|
|
25
|
+
)
|
|
26
|
+
scan_parser.add_argument(
|
|
27
|
+
"--all",
|
|
28
|
+
action="store_true",
|
|
29
|
+
dest="scan_all",
|
|
30
|
+
help="scan every tracked file in the working tree (for CI / audits)",
|
|
31
|
+
)
|
|
32
|
+
scan_parser.add_argument(
|
|
33
|
+
"--format",
|
|
34
|
+
choices=("text", "sarif"),
|
|
35
|
+
default="text",
|
|
36
|
+
dest="output_format",
|
|
37
|
+
help="output format (sarif goes to stdout for GitHub code scanning)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
subparsers.add_parser(
|
|
41
|
+
"baseline",
|
|
42
|
+
help="record current findings so future scans ignore them",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
install_parser = subparsers.add_parser(
|
|
46
|
+
"install", help="install the pre-commit hook into this repository"
|
|
47
|
+
)
|
|
48
|
+
install_parser.add_argument(
|
|
49
|
+
"--force",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="replace an existing hook not managed by git-security-tool",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
subparsers.add_parser(
|
|
55
|
+
"uninstall", help="remove the pre-commit hook from this repository"
|
|
56
|
+
)
|
|
57
|
+
subparsers.add_parser("check", help="report pre-commit hook and scanner status")
|
|
58
|
+
subparsers.add_parser("version", help="print the version and exit")
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
|
|
65
|
+
if args.command == "scan":
|
|
66
|
+
return run_scan(
|
|
67
|
+
scope="all" if args.scan_all else "staged",
|
|
68
|
+
output_format=args.output_format,
|
|
69
|
+
)
|
|
70
|
+
if args.command == "baseline":
|
|
71
|
+
return write_baseline_file()
|
|
72
|
+
if args.command == "install":
|
|
73
|
+
return install(force=args.force)
|
|
74
|
+
if args.command == "uninstall":
|
|
75
|
+
return uninstall()
|
|
76
|
+
if args.command == "check":
|
|
77
|
+
return status()
|
|
78
|
+
if args.command == "version":
|
|
79
|
+
print(f"git-security-tool {__version__}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
# argparse enforces `required=True`, so this is unreachable.
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Load repo-level configuration from ``.git-security-tool.toml``.
|
|
2
|
+
|
|
3
|
+
The file is optional. When it is absent - or a key within it is missing -
|
|
4
|
+
the built-in defaults apply. Parsing uses ``tomllib`` from the standard
|
|
5
|
+
library (Python 3.11+), so this adds no dependency.
|
|
6
|
+
|
|
7
|
+
A malformed file raises :class:`ConfigError`; callers turn that into a clean
|
|
8
|
+
message rather than a traceback.
|
|
9
|
+
|
|
10
|
+
Example ``.git-security-tool.toml`` at the repo root::
|
|
11
|
+
|
|
12
|
+
[policy]
|
|
13
|
+
block_threshold = "CRITICAL" # INFO | LOW | MEDIUM | HIGH | CRITICAL
|
|
14
|
+
|
|
15
|
+
[scanners]
|
|
16
|
+
gitleaks = false # disable a scanner
|
|
17
|
+
|
|
18
|
+
[ignore]
|
|
19
|
+
paths = ["tests/fixtures/", "*.generated.py"]
|
|
20
|
+
|
|
21
|
+
[ai]
|
|
22
|
+
enabled = true # off by default
|
|
23
|
+
provider = "gemini" # "anthropic" | "gemini"
|
|
24
|
+
model = "" # blank = the provider's default model
|
|
25
|
+
max_findings = 3
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import tomllib
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from git_security.models.finding import Severity
|
|
33
|
+
from git_security.policy.engine import PolicyConfig
|
|
34
|
+
|
|
35
|
+
CONFIG_FILENAME = ".git-security-tool.toml"
|
|
36
|
+
|
|
37
|
+
_ALL_SCANNERS = ("ruff", "gitleaks", "semgrep")
|
|
38
|
+
_AI_PROVIDERS = ("anthropic", "gemini")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(Exception):
|
|
42
|
+
"""Raised when ``.git-security-tool.toml`` is present but malformed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AIConfig:
|
|
47
|
+
enabled: bool = False
|
|
48
|
+
provider: str = "anthropic"
|
|
49
|
+
model: str = "" # blank = provider default
|
|
50
|
+
max_findings: int = 3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Config:
|
|
55
|
+
policy: PolicyConfig = field(default_factory=PolicyConfig)
|
|
56
|
+
enabled_scanners: frozenset[str] = frozenset(_ALL_SCANNERS)
|
|
57
|
+
ignore_paths: tuple[str, ...] = ()
|
|
58
|
+
ai: AIConfig = field(default_factory=AIConfig)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config(repo_root: Path) -> Config:
|
|
62
|
+
"""Read ``.git-security-tool.toml`` from *repo_root*, or return defaults."""
|
|
63
|
+
path = repo_root / CONFIG_FILENAME
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return Config()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with path.open("rb") as handle:
|
|
69
|
+
raw = tomllib.load(handle)
|
|
70
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
71
|
+
raise ConfigError(f"could not read {CONFIG_FILENAME}: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
return Config(
|
|
74
|
+
policy=_load_policy(raw.get("policy", {})),
|
|
75
|
+
enabled_scanners=_load_scanners(raw.get("scanners", {})),
|
|
76
|
+
ignore_paths=_load_ignore(raw.get("ignore", {})),
|
|
77
|
+
ai=_load_ai(raw.get("ai", {})),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_policy(section: dict) -> PolicyConfig:
|
|
82
|
+
name = section.get("block_threshold")
|
|
83
|
+
if name is None:
|
|
84
|
+
return PolicyConfig()
|
|
85
|
+
try:
|
|
86
|
+
threshold = Severity[name.upper()]
|
|
87
|
+
except (KeyError, AttributeError):
|
|
88
|
+
valid = ", ".join(s.name for s in Severity)
|
|
89
|
+
raise ConfigError(
|
|
90
|
+
f"invalid policy.block_threshold: {name!r} (expected one of {valid})"
|
|
91
|
+
) from None
|
|
92
|
+
return PolicyConfig(block_threshold=threshold)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_scanners(section: dict) -> frozenset[str]:
|
|
96
|
+
enabled = set(_ALL_SCANNERS)
|
|
97
|
+
for name in _ALL_SCANNERS:
|
|
98
|
+
if section.get(name) is False:
|
|
99
|
+
enabled.discard(name)
|
|
100
|
+
return frozenset(enabled)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_ignore(section: dict) -> tuple[str, ...]:
|
|
104
|
+
paths = section.get("paths", [])
|
|
105
|
+
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
|
106
|
+
raise ConfigError("ignore.paths must be a list of strings")
|
|
107
|
+
return tuple(paths)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_ai(section: dict) -> AIConfig:
|
|
111
|
+
provider = section.get("provider", "anthropic")
|
|
112
|
+
model = section.get("model", "")
|
|
113
|
+
max_findings = section.get("max_findings", 3)
|
|
114
|
+
if provider not in _AI_PROVIDERS:
|
|
115
|
+
raise ConfigError(f"ai.provider must be one of {', '.join(_AI_PROVIDERS)}")
|
|
116
|
+
if not isinstance(model, str):
|
|
117
|
+
raise ConfigError("ai.model must be a string")
|
|
118
|
+
if isinstance(max_findings, bool) or not isinstance(max_findings, int):
|
|
119
|
+
raise ConfigError("ai.max_findings must be an integer")
|
|
120
|
+
if max_findings < 1:
|
|
121
|
+
raise ConfigError("ai.max_findings must be >= 1")
|
|
122
|
+
return AIConfig(
|
|
123
|
+
enabled=section.get("enabled", False) is True,
|
|
124
|
+
provider=provider,
|
|
125
|
+
model=model,
|
|
126
|
+
max_findings=max_findings,
|
|
127
|
+
)
|
|
File without changes
|
git_security/git/diff.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Read what is currently staged for the next commit.
|
|
2
|
+
|
|
3
|
+
Thin queries on top of :func:`git_security.git.repository.run_git`, plus
|
|
4
|
+
:func:`materialize_staged` which writes the exact staged content to disk so
|
|
5
|
+
scanners analyse what will be committed rather than the working tree.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from git_security.git.repository import run_git
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_staged_files() -> list[str]:
|
|
14
|
+
"""Return the paths staged for the next commit.
|
|
15
|
+
|
|
16
|
+
``--diff-filter=ACM`` keeps Added / Copied / Modified files and drops
|
|
17
|
+
deletions, since there is nothing to scan in a file being removed.
|
|
18
|
+
"""
|
|
19
|
+
output = run_git(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
|
|
20
|
+
return [line for line in output.splitlines() if line]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_staged_diff() -> str:
|
|
24
|
+
"""Return the full unified diff of everything staged for the next commit."""
|
|
25
|
+
return run_git(["diff", "--cached"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_staged_file_content(path: str) -> str:
|
|
29
|
+
"""Return the staged (index) content of a single file as text."""
|
|
30
|
+
return run_git(["show", f":{path}"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_tracked_files() -> list[str]:
|
|
34
|
+
"""Every file tracked by Git in this repository (for a full-repo scan)."""
|
|
35
|
+
return [line for line in run_git(["ls-files"]).splitlines() if line]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def materialize_staged(files: list[str], dest: Path) -> None:
|
|
39
|
+
"""Write the staged (index) content of *files* under *dest*.
|
|
40
|
+
|
|
41
|
+
Relative paths are preserved and missing sub-directories are created.
|
|
42
|
+
This is the staged blob - the exact bytes that will be committed - not
|
|
43
|
+
the working-tree copy, which may have diverged since ``git add``.
|
|
44
|
+
*dest* must already exist.
|
|
45
|
+
"""
|
|
46
|
+
if not files:
|
|
47
|
+
return
|
|
48
|
+
# --prefix is prepended literally, so it must end with a separator.
|
|
49
|
+
prefix = f"{dest}/"
|
|
50
|
+
run_git(["checkout-index", f"--prefix={prefix}", "--", *files])
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Locate this repository's hooks directory and its pre-commit hook.
|
|
2
|
+
|
|
3
|
+
Git's hooks directory is usually ``.git/hooks`` but can be moved with the
|
|
4
|
+
``core.hooksPath`` config or differ inside worktrees. ``git rev-parse
|
|
5
|
+
--git-path hooks`` resolves all of that for us.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from git_security.git.repository import run_git
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hooks_dir() -> Path:
|
|
14
|
+
"""Absolute path to the hooks directory Git will actually use."""
|
|
15
|
+
raw = run_git(["rev-parse", "--git-path", "hooks"]).strip()
|
|
16
|
+
path = Path(raw)
|
|
17
|
+
if not path.is_absolute():
|
|
18
|
+
top = run_git(["rev-parse", "--show-toplevel"]).strip()
|
|
19
|
+
path = Path(top) / path
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pre_commit_hook() -> Path:
|
|
24
|
+
"""Absolute path to this repository's ``pre-commit`` hook file."""
|
|
25
|
+
return hooks_dir() / "pre-commit"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Low-level access to the current Git repository.
|
|
2
|
+
|
|
3
|
+
Every call that shells out to ``git`` goes through :func:`run_git` here.
|
|
4
|
+
Higher-level modules (``diff.py``) build on it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_git(args: list[str]) -> str:
|
|
12
|
+
"""Run ``git <args>`` and return stdout as text.
|
|
13
|
+
|
|
14
|
+
Argument list, no shell - nothing to quote, no injection risk. Raises
|
|
15
|
+
``RuntimeError`` with git's own stderr if git is missing or exits
|
|
16
|
+
non-zero, so callers never get a silent empty result.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=False,
|
|
24
|
+
)
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
raise RuntimeError("git executable not found on PATH")
|
|
27
|
+
|
|
28
|
+
if result.returncode != 0:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"git {' '.join(args)} failed (exit {result.returncode}): "
|
|
31
|
+
f"{result.stderr.strip()}"
|
|
32
|
+
)
|
|
33
|
+
return result.stdout
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_repo_root() -> Path:
|
|
37
|
+
"""Absolute path to the top level of the current working tree."""
|
|
38
|
+
return Path(run_git(["rev-parse", "--show-toplevel"]).strip())
|
git_security/ignore.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Skip staged paths that match the config's ignore patterns.
|
|
2
|
+
|
|
3
|
+
Patterns are matched against repo-relative POSIX paths:
|
|
4
|
+
|
|
5
|
+
* ``tests/fixtures/`` - trailing slash means "anything under this directory"
|
|
6
|
+
* ``*.generated.py`` - fnmatch glob (``*`` also spans ``/``)
|
|
7
|
+
* ``migrations`` - a plain name matches that path and everything under it
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from fnmatch import fnmatch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _matches(path: str, pattern: str) -> bool:
|
|
14
|
+
pattern = pattern.rstrip("/")
|
|
15
|
+
if not pattern:
|
|
16
|
+
return False
|
|
17
|
+
return fnmatch(path, pattern) or fnmatch(path, f"{pattern}/*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_ignored(path: str, patterns: tuple[str, ...]) -> bool:
|
|
21
|
+
return any(_matches(path, pattern) for pattern in patterns)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_ignored(files: list[str], patterns: tuple[str, ...]) -> list[str]:
|
|
25
|
+
if not patterns:
|
|
26
|
+
return list(files)
|
|
27
|
+
return [f for f in files if not is_ignored(f, patterns)]
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Check which external scanners are available on PATH.
|
|
2
|
+
|
|
3
|
+
The tool never installs anything itself - it just tells the user what is
|
|
4
|
+
present and what is missing. Missing scanners are skipped at scan time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
SCANNERS = ("ruff", "gitleaks", "semgrep")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_dependencies() -> dict[str, bool]:
|
|
13
|
+
"""Map each scanner name to whether its executable is on PATH."""
|
|
14
|
+
return {name: shutil.which(name) is not None for name in SCANNERS}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Install, remove, and report on the git-security-tool pre-commit hook.
|
|
2
|
+
|
|
3
|
+
The hook itself is tiny and lives in ``.git/hooks/`` (not version-controlled),
|
|
4
|
+
so every repo needs this command to set it up. We only ever touch a hook we
|
|
5
|
+
created - identified by MARKER - unless the user passes --force.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from git_security.git.hooks import pre_commit_hook
|
|
12
|
+
from git_security.git.repository import run_git
|
|
13
|
+
from git_security.installer.dependencies import check_dependencies
|
|
14
|
+
|
|
15
|
+
_PREFIX = "[git-security-tool]"
|
|
16
|
+
|
|
17
|
+
MARKER = "git-security-tool managed hook"
|
|
18
|
+
|
|
19
|
+
HOOK_CONTENT = f"""\
|
|
20
|
+
#!/bin/sh
|
|
21
|
+
# {MARKER} - do not edit; regenerate with `git-security-tool install`
|
|
22
|
+
if ! command -v git-security-tool >/dev/null 2>&1; then
|
|
23
|
+
echo "git-security-tool not on PATH - skipping scan (is your venv active?)" >&2
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
exec git-security-tool scan
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_EXEC_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _inside_work_tree() -> bool:
|
|
33
|
+
try:
|
|
34
|
+
return run_git(["rev-parse", "--is-inside-work-tree"]).strip() == "true"
|
|
35
|
+
except RuntimeError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_ours(path: Path) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
return path.is_file() and MARKER in path.read_text()
|
|
42
|
+
except (OSError, UnicodeDecodeError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_dependencies() -> None:
|
|
47
|
+
deps = check_dependencies()
|
|
48
|
+
for name, present in deps.items():
|
|
49
|
+
print(f" {name}: {'ok' if present else 'missing'}")
|
|
50
|
+
if not all(deps.values()):
|
|
51
|
+
print(
|
|
52
|
+
f"{_PREFIX} missing scanners are skipped at scan time; "
|
|
53
|
+
"install them for full coverage"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install(force: bool = False) -> int:
|
|
58
|
+
if not _inside_work_tree():
|
|
59
|
+
print(f"{_PREFIX} not inside a Git repository")
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
hook = pre_commit_hook()
|
|
63
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
if hook.exists() and not _is_ours(hook) and not force:
|
|
66
|
+
print(
|
|
67
|
+
f"{_PREFIX} a pre-commit hook already exists at {hook} and was not "
|
|
68
|
+
f"created by git-security-tool.\n"
|
|
69
|
+
f"{_PREFIX} re-run with --force to replace it."
|
|
70
|
+
)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
hook.write_text(HOOK_CONTENT)
|
|
74
|
+
hook.chmod(hook.stat().st_mode | _EXEC_BITS)
|
|
75
|
+
print(f"{_PREFIX} installed pre-commit hook at {hook}")
|
|
76
|
+
_print_dependencies()
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def uninstall() -> int:
|
|
81
|
+
if not _inside_work_tree():
|
|
82
|
+
print(f"{_PREFIX} not inside a Git repository")
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
hook = pre_commit_hook()
|
|
86
|
+
if not hook.exists():
|
|
87
|
+
print(f"{_PREFIX} no pre-commit hook to remove")
|
|
88
|
+
return 0
|
|
89
|
+
if not _is_ours(hook):
|
|
90
|
+
print(
|
|
91
|
+
f"{_PREFIX} pre-commit hook at {hook} was not created by "
|
|
92
|
+
"git-security-tool - leaving it alone"
|
|
93
|
+
)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
hook.unlink()
|
|
97
|
+
print(f"{_PREFIX} removed pre-commit hook at {hook}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def status() -> int:
|
|
102
|
+
if not _inside_work_tree():
|
|
103
|
+
print(f"{_PREFIX} not inside a Git repository")
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
hook = pre_commit_hook()
|
|
107
|
+
if _is_ours(hook):
|
|
108
|
+
print(f"{_PREFIX} pre-commit hook: installed ({hook})")
|
|
109
|
+
installed = True
|
|
110
|
+
elif hook.exists():
|
|
111
|
+
print(
|
|
112
|
+
f"{_PREFIX} pre-commit hook: present but not managed by "
|
|
113
|
+
f"git-security-tool ({hook})"
|
|
114
|
+
)
|
|
115
|
+
installed = False
|
|
116
|
+
else:
|
|
117
|
+
print(f"{_PREFIX} pre-commit hook: not installed")
|
|
118
|
+
installed = False
|
|
119
|
+
|
|
120
|
+
print(f"{_PREFIX} scanners:")
|
|
121
|
+
_print_dependencies()
|
|
122
|
+
return 0 if installed else 1
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The normalized finding model.
|
|
2
|
+
|
|
3
|
+
Every scanner converts its tool's native output into ``Finding`` objects.
|
|
4
|
+
From here on, the rest of git-security-tool (policy, reporting) deals only
|
|
5
|
+
with ``Finding`` - never a raw tool dict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Severity(IntEnum):
|
|
13
|
+
"""Ordered severity levels.
|
|
14
|
+
|
|
15
|
+
Subclassing ``IntEnum`` means the members compare like numbers, so policy
|
|
16
|
+
can ask ``finding.severity >= Severity.HIGH`` directly. Higher = worse.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
INFO = 1
|
|
20
|
+
LOW = 2
|
|
21
|
+
MEDIUM = 3
|
|
22
|
+
HIGH = 4
|
|
23
|
+
CRITICAL = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Finding:
|
|
28
|
+
tool: str # scanner that produced it: "ruff", "gitleaks", ...
|
|
29
|
+
rule: str # tool-specific rule id: "F401", "aws-access-token", ...
|
|
30
|
+
severity: Severity
|
|
31
|
+
file: str # repo-relative path
|
|
32
|
+
line: int # 1-based line number; 0 when the tool reports none
|
|
33
|
+
message: str # human-readable description
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Turn a list of findings into an allow/block decision.
|
|
2
|
+
|
|
3
|
+
The policy layer is the *only* place that decides whether the commit should
|
|
4
|
+
proceed. Scanners just report; ``main()`` just obeys the returned Decision.
|
|
5
|
+
Keeping the rule here means we can change blocking behaviour without touching
|
|
6
|
+
any scanner.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from git_security.models.finding import Finding, Severity
|
|
12
|
+
|
|
13
|
+
# Severities that abort the commit. Anything below this is shown as a warning
|
|
14
|
+
# but does not block. Made configurable from a file in a later milestone.
|
|
15
|
+
DEFAULT_BLOCK_THRESHOLD = Severity.HIGH
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class PolicyConfig:
|
|
20
|
+
block_threshold: Severity = DEFAULT_BLOCK_THRESHOLD
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Decision:
|
|
25
|
+
blocked: bool
|
|
26
|
+
blocking: list[Finding] = field(default_factory=list)
|
|
27
|
+
warnings: list[Finding] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def evaluate(findings: list[Finding], config: PolicyConfig) -> Decision:
|
|
31
|
+
"""Split findings into blocking vs warning by severity threshold."""
|
|
32
|
+
blocking = [f for f in findings if f.severity >= config.block_threshold]
|
|
33
|
+
warnings = [f for f in findings if f.severity < config.block_threshold]
|
|
34
|
+
return Decision(
|
|
35
|
+
blocked=bool(blocking),
|
|
36
|
+
blocking=blocking,
|
|
37
|
+
warnings=warnings,
|
|
38
|
+
)
|
|
File without changes
|