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,3 @@
1
+ """git-security-tool: a local Git security and code-quality gate."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Enable `python -m git_security`."""
2
+
3
+ from git_security.cli import main
4
+
5
+ raise SystemExit(main())
@@ -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
@@ -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