universal-dev-security 1.0.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 (37) hide show
  1. universal_dev_security/__init__.py +3 -0
  2. universal_dev_security/__main__.py +4 -0
  3. universal_dev_security/cli.py +376 -0
  4. universal_dev_security/cli_extra.py +244 -0
  5. universal_dev_security/config.py +171 -0
  6. universal_dev_security/detector.py +201 -0
  7. universal_dev_security/engine.py +203 -0
  8. universal_dev_security/findings.py +199 -0
  9. universal_dev_security/fixes.py +138 -0
  10. universal_dev_security/gitguard.py +269 -0
  11. universal_dev_security/history.py +149 -0
  12. universal_dev_security/initializer.py +112 -0
  13. universal_dev_security/integrations/__init__.py +46 -0
  14. universal_dev_security/integrations/agents.py +45 -0
  15. universal_dev_security/integrations/claude_code.py +192 -0
  16. universal_dev_security/integrations/github.py +149 -0
  17. universal_dev_security/integrations/vscode.py +44 -0
  18. universal_dev_security/policy.py +384 -0
  19. universal_dev_security/projectfiles.py +80 -0
  20. universal_dev_security/recovery.py +168 -0
  21. universal_dev_security/redaction.py +132 -0
  22. universal_dev_security/reports.py +466 -0
  23. universal_dev_security/scanner.py +36 -0
  24. universal_dev_security/scanners/__init__.py +36 -0
  25. universal_dev_security/scanners/base.py +269 -0
  26. universal_dev_security/scanners/gitleaks.py +132 -0
  27. universal_dev_security/scanners/osv.py +156 -0
  28. universal_dev_security/scanners/semgrep.py +130 -0
  29. universal_dev_security/scanners/trivy.py +139 -0
  30. universal_dev_security/testing.py +100 -0
  31. universal_dev_security/updates.py +84 -0
  32. universal_dev_security-1.0.0.dist-info/METADATA +128 -0
  33. universal_dev_security-1.0.0.dist-info/RECORD +37 -0
  34. universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
  35. universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
  36. universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
  37. universal_dev_security-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,199 @@
1
+ """Unified UDS findings schema shared by every scanner adapter and report.
2
+
3
+ Design rules:
4
+
5
+ * Severity is only assigned when the source scanner supplies one, or when a
6
+ documented public standard maps the scanner's value (for example the CVSS
7
+ v3 qualitative rating scale). Everything else is ``unclassified``.
8
+ * Findings never carry secret values, matched text, or raw scanner output.
9
+ * Scanner states distinguish "clean" from "not executed" so a scan is never
10
+ reported as successful when it did not run.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from dataclasses import asdict, dataclass, field
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+
20
+ class Severity(str, Enum):
21
+ CRITICAL = "critical"
22
+ HIGH = "high"
23
+ MEDIUM = "medium"
24
+ LOW = "low"
25
+ UNCLASSIFIED = "unclassified"
26
+
27
+ @property
28
+ def rank(self) -> int:
29
+ """Higher rank = more severe. Unclassified ranks below low."""
30
+ return _SEVERITY_RANK[self]
31
+
32
+ @classmethod
33
+ def parse(cls, value: Any) -> "Severity":
34
+ """Parse an already-normalized UDS severity label (never a guess)."""
35
+ try:
36
+ return cls(str(value).strip().lower())
37
+ except ValueError:
38
+ return cls.UNCLASSIFIED
39
+
40
+
41
+ _SEVERITY_RANK = {
42
+ Severity.CRITICAL: 4,
43
+ Severity.HIGH: 3,
44
+ Severity.MEDIUM: 2,
45
+ Severity.LOW: 1,
46
+ Severity.UNCLASSIFIED: 0,
47
+ }
48
+
49
+ SEVERITY_ORDER = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.UNCLASSIFIED]
50
+
51
+
52
+ class ScanStatus(str, Enum):
53
+ CLEAN = "clean" # executed, no findings
54
+ FINDINGS = "findings" # executed, findings returned
55
+ NOT_INSTALLED = "not_installed" # executable not found
56
+ NOT_APPLICABLE = "not_applicable" # nothing in the project this scanner covers
57
+ SKIPPED = "skipped" # deliberately not executed (policy, version, user choice)
58
+ ERROR = "error" # attempted but failed
59
+
60
+ @property
61
+ def executed(self) -> bool:
62
+ return self in (ScanStatus.CLEAN, ScanStatus.FINDINGS)
63
+
64
+
65
+ class Category(str, Enum):
66
+ SECRET = "secret"
67
+ CODE = "code"
68
+ DEPENDENCY = "dependency"
69
+ CONFIGURATION = "configuration"
70
+
71
+
72
+ def cvss_score_to_severity(score: Any) -> Severity:
73
+ """Map a numeric CVSS v3/v4 base score using the published qualitative scale.
74
+
75
+ FIRST CVSS v3.1 specification, section 5: 0.1-3.9 Low, 4.0-6.9 Medium,
76
+ 7.0-8.9 High, 9.0-10.0 Critical. 0.0 ("None") and unparsable values are
77
+ left unclassified rather than guessed.
78
+ """
79
+ try:
80
+ value = float(score)
81
+ except (TypeError, ValueError):
82
+ return Severity.UNCLASSIFIED
83
+ if value >= 9.0:
84
+ return Severity.CRITICAL
85
+ if value >= 7.0:
86
+ return Severity.HIGH
87
+ if value >= 4.0:
88
+ return Severity.MEDIUM
89
+ if value > 0.0:
90
+ return Severity.LOW
91
+ return Severity.UNCLASSIFIED
92
+
93
+
94
+ @dataclass
95
+ class Finding:
96
+ tool: str
97
+ category: Category
98
+ rule_id: str
99
+ title: str
100
+ file: str = ""
101
+ start_line: int | None = None
102
+ end_line: int | None = None
103
+ severity: Severity = Severity.UNCLASSIFIED
104
+ severity_source: str = "none" # e.g. "scanner", "cvss-v3-qualitative", "none"
105
+ scanner_severity: str | None = None
106
+ message: str = ""
107
+ package: str | None = None
108
+ installed_version: str | None = None
109
+ fixed_version: str | None = None
110
+ commit: str | None = None
111
+ references: list[str] = field(default_factory=list)
112
+ remediation: str = ""
113
+ fingerprint: str = ""
114
+ tracked_in_git: bool | None = None # secrets only: True = committed, False = local-only, None = unknown
115
+
116
+ def __post_init__(self) -> None:
117
+ self.category = Category(self.category)
118
+ self.severity = Severity.parse(self.severity) if not isinstance(self.severity, Severity) else self.severity
119
+ self.start_line = _int_or_none(self.start_line)
120
+ self.end_line = _int_or_none(self.end_line)
121
+ self.file = self.file.replace("\\", "/")
122
+ self.references = list(dict.fromkeys(ref for ref in self.references if _is_http_url(ref)))[:10]
123
+ if not self.fingerprint:
124
+ self.fingerprint = self.compute_fingerprint()
125
+
126
+ def compute_fingerprint(self) -> str:
127
+ # Line numbers are excluded on purpose so moving code does not turn an
128
+ # existing finding into a "new" one when comparing scans.
129
+ basis = "|".join([
130
+ self.tool, self.rule_id, self.file, self.package or "", self.installed_version or "",
131
+ ])
132
+ return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
133
+
134
+ def to_dict(self) -> dict[str, Any]:
135
+ data = asdict(self)
136
+ data["category"] = self.category.value
137
+ data["severity"] = self.severity.value
138
+ return data
139
+
140
+ @classmethod
141
+ def from_dict(cls, data: dict[str, Any]) -> "Finding":
142
+ allowed = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
143
+ return cls(**{key: value for key, value in data.items() if key in allowed})
144
+
145
+
146
+ @dataclass
147
+ class ScanResult:
148
+ tool: str
149
+ status: ScanStatus
150
+ findings: list[Finding] = field(default_factory=list)
151
+ version: str | None = None
152
+ message: str = ""
153
+ reason: str | None = None
154
+ duration_seconds: float | None = None
155
+
156
+ def __post_init__(self) -> None:
157
+ self.status = ScanStatus(self.status)
158
+ if self.status is ScanStatus.CLEAN and self.findings:
159
+ self.status = ScanStatus.FINDINGS
160
+ if not self.status.executed and self.findings:
161
+ raise ValueError("A scanner that did not execute cannot return findings")
162
+
163
+ @property
164
+ def finding_count(self) -> int:
165
+ return len(self.findings)
166
+
167
+ def to_dict(self) -> dict[str, Any]:
168
+ return {
169
+ "tool": self.tool,
170
+ "status": self.status.value,
171
+ "executed": self.status.executed,
172
+ "version": self.version,
173
+ "message": self.message,
174
+ "reason": self.reason,
175
+ "duration_seconds": self.duration_seconds,
176
+ "finding_count": self.finding_count,
177
+ "findings": [finding.to_dict() for finding in self.findings],
178
+ }
179
+
180
+
181
+ def severity_counts(findings: list[Finding]) -> dict[str, int]:
182
+ counts = {severity.value: 0 for severity in SEVERITY_ORDER}
183
+ for finding in findings:
184
+ counts[finding.severity.value] += 1
185
+ return counts
186
+
187
+
188
+ def _int_or_none(value: Any) -> int | None:
189
+ if isinstance(value, bool):
190
+ return None
191
+ try:
192
+ number = int(value)
193
+ except (TypeError, ValueError):
194
+ return None
195
+ return number if number >= 0 else None
196
+
197
+
198
+ def _is_http_url(value: Any) -> bool:
199
+ return isinstance(value, str) and value.lower().startswith(("https://", "http://")) and len(value) < 2048
@@ -0,0 +1,138 @@
1
+ """Safe, reversible automatic fixes.
2
+
3
+ Risk policy (mandatory global policy, section 10):
4
+ * low risk – may be applied with `uds fix --apply` (and config permitting);
5
+ * medium risk – applied only with explicit `--include-medium`;
6
+ * high risk – never automatic; reported as manual recommendations.
7
+
8
+ Every applied fix creates a recovery checkpoint, then the project's tests are
9
+ run. If the tests fail, the checkpoint is restored automatically.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from universal_dev_security.config import ProjectConfig
19
+ from universal_dev_security.recovery import RecoveryManager, safe_write
20
+ from universal_dev_security.testing import run_tests
21
+
22
+ GITIGNORE_SECRET_ENTRIES = [
23
+ ".env", ".env.*", "!.env.example", "!.env.sample", "!.env.template",
24
+ "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_ed25519",
25
+ ".uds/reports/", ".uds/history/", ".uds/backups/", ".uds/cache/",
26
+ ]
27
+ _ENV_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=")
28
+
29
+
30
+ @dataclass
31
+ class Fix:
32
+ fix_id: str
33
+ risk: str
34
+ title: str
35
+ files: list[str]
36
+ plan: Callable[[Path], dict[str, str]] # returns {relative_path: new_content}
37
+
38
+
39
+ def _gitignore_plan(root: Path) -> dict[str, str]:
40
+ path = root / ".gitignore"
41
+ current = path.read_text(encoding="utf-8") if path.is_file() else ""
42
+ existing = {line.strip() for line in current.splitlines()}
43
+ missing = [entry for entry in GITIGNORE_SECRET_ENTRIES if entry not in existing]
44
+ if not missing:
45
+ return {}
46
+ prefix = current if current.endswith("\n") or not current else current + "\n"
47
+ block = "\n# Added by Universal Dev Security: keep credentials and UDS artifacts out of Git\n" + "\n".join(missing) + "\n"
48
+ return {".gitignore": prefix + block}
49
+
50
+
51
+ def _env_example_plan(root: Path) -> dict[str, str]:
52
+ env, example = root / ".env", root / ".env.example"
53
+ if not env.is_file() or example.exists():
54
+ return {}
55
+ keys = []
56
+ for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
57
+ match = _ENV_LINE.match(line)
58
+ if match and match.group(1) not in keys:
59
+ keys.append(match.group(1))
60
+ if not keys:
61
+ return {}
62
+ # Only variable names are copied; values are never read into the output.
63
+ body = "# Generated by UDS from .env variable names. Values intentionally omitted.\n"
64
+ return {".env.example": body + "".join(f"{key}=\n" for key in keys)}
65
+
66
+
67
+ FIXES = [
68
+ Fix("gitignore-secrets", "low", "Ignore credential files and UDS artifacts in .gitignore", [".gitignore"], _gitignore_plan),
69
+ Fix("env-example", "medium", "Create .env.example with variable names only (no values)", [".env.example"], _env_example_plan),
70
+ ]
71
+
72
+
73
+ def manual_recommendations(scan: dict[str, Any] | None) -> list[dict[str, str]]:
74
+ """High-risk remediations that UDS never applies automatically."""
75
+ if not scan:
76
+ return []
77
+ recs = []
78
+ for f in scan.get("findings", []):
79
+ if f.get("category") == "dependency" and f.get("fixed_version"):
80
+ recs.append({"risk": "high", "title": f"Upgrade {f.get('package')} {f.get('installed_version')} → {f.get('fixed_version')}",
81
+ "reason": "Dependency upgrades can break compatibility; review, upgrade and test manually."})
82
+ elif f.get("category") == "secret":
83
+ recs.append({"risk": "critical", "title": f"Rotate the credential found in {f.get('file')}",
84
+ "reason": "Credential rotation and history rewrites require explicit human action."})
85
+ unique = {r["title"]: r for r in recs}
86
+ return list(unique.values())[:100]
87
+
88
+
89
+ def plan_fixes(root: Path) -> list[dict[str, Any]]:
90
+ root = root.expanduser().resolve()
91
+ planned = []
92
+ for fix in FIXES:
93
+ changes = fix.plan(root)
94
+ if changes:
95
+ planned.append({"id": fix.fix_id, "risk": fix.risk, "title": fix.title, "files": sorted(changes)})
96
+ return planned
97
+
98
+
99
+ def apply_fixes(root: Path, config: ProjectConfig, *, include_medium: bool = False, run_test_suite: bool = True,
100
+ approved: bool = False) -> dict[str, Any]:
101
+ root = root.expanduser().resolve()
102
+ allowed_risks = {"low"} | ({"medium"} if include_medium else set())
103
+ if not config.allow_automatic_low_risk_fixes and not approved:
104
+ return {"status": "approval_required", "applied": [],
105
+ "message": "Automatic fixes are disabled by policy.allow_automatic_low_risk_fixes; "
106
+ "re-run with --approve to confirm."}
107
+ selected = [f for f in FIXES if f.risk in allowed_risks]
108
+ changes: dict[str, str] = {}
109
+ applied = []
110
+ for fix in selected:
111
+ planned = fix.plan(root)
112
+ if planned:
113
+ changes.update(planned)
114
+ applied.append(fix.fix_id)
115
+ if not changes:
116
+ return {"status": "nothing_to_do", "applied": [], "checkpoint": None}
117
+ if run_test_suite:
118
+ baseline = run_tests(root, config)
119
+ if baseline["passed"] is False:
120
+ # A fix cannot be verified against a suite that already fails.
121
+ return {"status": "baseline_tests_failing", "applied": [], "checkpoint": None,
122
+ "tests": {k: v for k, v in baseline.items() if k != "output_tail"},
123
+ "message": "Tests fail before any change; fix them first or re-run with --skip-tests."}
124
+ manager = RecoveryManager(root)
125
+ checkpoint = manager.create("uds fix: " + ", ".join(applied), sorted(changes))
126
+ for relative, content in changes.items():
127
+ safe_write(root, relative, content)
128
+ manager.seal(checkpoint)
129
+ result: dict[str, Any] = {"status": "applied", "applied": applied, "files": sorted(changes), "checkpoint": checkpoint}
130
+ if run_test_suite:
131
+ tests = run_tests(root, config)
132
+ result["tests"] = {k: v for k, v in tests.items() if k != "output_tail"}
133
+ if tests["passed"] is False:
134
+ restore = manager.restore(checkpoint)
135
+ result["status"] = "rolled_back"
136
+ result["rollback"] = restore
137
+ result["tests"]["output_tail"] = tests.get("output_tail", "")
138
+ return result
@@ -0,0 +1,269 @@
1
+ """Git protection: pre-commit and pre-push secret scanning hooks.
2
+
3
+ Local hooks are defense in depth — they can be bypassed with ``--no-verify``
4
+ (which the UDS policy denies for AI agents). Use CI scanning as well.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from universal_dev_security.integrations import apply_changes
19
+ from universal_dev_security.redaction import find_secrets
20
+ from universal_dev_security.scanners.base import ScannerExecutionError, run_tool
21
+ from universal_dev_security.scanners.gitleaks import GitleaksAdapter, build_exclusion_config
22
+
23
+ HOOK_MARKER = "UDS-MANAGED-HOOK"
24
+ HOOK_NAMES = ("pre-commit", "pre-push")
25
+ _SHA_RE = re.compile(r"^[0-9a-f]{40}([0-9a-f]{24})?$")
26
+ _REMOTE_RE = re.compile(r"^[A-Za-z0-9._/-]{1,100}$")
27
+ _SAFE_SH_PATH = re.compile(r"^[A-Za-z0-9 _.:/\\()+-]+$")
28
+ MAX_STAGED_FILE_BYTES = 2 * 1024 * 1024
29
+
30
+
31
+ class GitError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _git_executable() -> str:
36
+ git = shutil.which("git")
37
+ if not git:
38
+ raise GitError("Git is not installed or not on PATH.")
39
+ return git
40
+
41
+
42
+ def git(root: Path, *args: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
43
+ try:
44
+ return run_tool([_git_executable(), *args], cwd=root, timeout=timeout)
45
+ except ScannerExecutionError as exc:
46
+ raise GitError(str(exc)) from exc
47
+
48
+
49
+ def repo_root(path: Path) -> Path:
50
+ completed = git(path, "rev-parse", "--show-toplevel")
51
+ if completed.returncode != 0:
52
+ raise GitError(f"Not a Git repository: {path}")
53
+ return Path(completed.stdout.strip()).resolve()
54
+
55
+
56
+ def hooks_path_config(root: Path) -> str | None:
57
+ completed = git(root, "config", "--get", "core.hooksPath")
58
+ value = completed.stdout.strip()
59
+ return value or None
60
+
61
+
62
+ def hooks_dir(root: Path) -> Path:
63
+ completed = git(root, "rev-parse", "--git-path", "hooks")
64
+ if completed.returncode != 0:
65
+ raise GitError("Could not determine the Git hooks directory.")
66
+ path = Path(completed.stdout.strip())
67
+ return (root / path).resolve() if not path.is_absolute() else path.resolve()
68
+
69
+
70
+ def _sh_quote_path(path: str) -> str | None:
71
+ if not _SAFE_SH_PATH.match(path):
72
+ return None
73
+ return '"' + path.replace("\\", "/") + '"'
74
+
75
+
76
+ def hook_script(hook: str, python: str | None = None) -> str:
77
+ quoted = _sh_quote_path(python) if python else None
78
+ lines = [
79
+ "#!/bin/sh",
80
+ f"# {HOOK_MARKER} v1 - installed by Universal Dev Security.",
81
+ "# Remove with `uds hooks uninstall`. Do not edit: UDS may update this file.",
82
+ ]
83
+ if quoted:
84
+ lines += [f"UDS_PY={quoted}",
85
+ 'if [ -f "$UDS_PY" ]; then exec "$UDS_PY" -m universal_dev_security hook git-' + hook + ' "$@"; fi']
86
+ lines += [
87
+ "if command -v uds >/dev/null 2>&1; then exec uds hook git-" + hook + ' "$@"; fi',
88
+ f'echo "UDS: the uds command was not found, so the {hook} security check cannot run. Blocking." >&2',
89
+ 'echo "UDS: reinstall UDS or run \'uds hooks uninstall\' to remove this hook." >&2',
90
+ "exit 1",
91
+ ]
92
+ return "\n".join(lines) + "\n"
93
+
94
+
95
+ def _hook_relpaths(root: Path) -> dict[str, str]:
96
+ directory = hooks_dir(root)
97
+ try:
98
+ relative = directory.relative_to(root)
99
+ except ValueError as exc:
100
+ raise GitError(f"The hooks directory {directory} is outside the repository; UDS will not modify it.") from exc
101
+ return {hook: (relative / hook).as_posix() for hook in HOOK_NAMES}
102
+
103
+
104
+ def hooks_status(root: Path) -> dict[str, Any]:
105
+ root = repo_root(root)
106
+ custom = hooks_path_config(root)
107
+ status: dict[str, Any] = {"repository": str(root), "core_hooks_path": custom, "hooks": {}}
108
+ try:
109
+ paths = _hook_relpaths(root)
110
+ except GitError as exc:
111
+ status["error"] = str(exc)
112
+ return status
113
+ for hook, relative in paths.items():
114
+ path = root / relative
115
+ if not path.exists():
116
+ state = "missing"
117
+ elif HOOK_MARKER in path.read_text(encoding="utf-8", errors="replace"):
118
+ state = "uds"
119
+ else:
120
+ state = "foreign"
121
+ status["hooks"][hook] = {"path": relative, "state": state}
122
+ return status
123
+
124
+
125
+ def install_hooks(path: Path, *, apply: bool = False, uninstall: bool = False,
126
+ python: str | None = None) -> dict[str, Any]:
127
+ root = repo_root(path)
128
+ custom = hooks_path_config(root)
129
+ if custom:
130
+ raise GitError(
131
+ f"core.hooksPath is set to {custom!r} (another hook manager is in use). UDS will not change it. "
132
+ "Call `uds hook git-pre-commit` and `uds hook git-pre-push` from your existing hooks instead."
133
+ )
134
+ paths = _hook_relpaths(root)
135
+ changes: dict[str, str | None] = {}
136
+ conflicts = []
137
+ for hook, relative in paths.items():
138
+ target = root / relative
139
+ existing = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else None
140
+ if existing is not None and HOOK_MARKER not in existing:
141
+ conflicts.append({"hook": hook, "path": relative,
142
+ "message": "An existing non-UDS hook is present and was left unchanged. "
143
+ f"Add `uds hook git-{hook} \"$@\"` to it manually."})
144
+ continue
145
+ if uninstall:
146
+ if existing is not None:
147
+ changes[relative] = None
148
+ else:
149
+ changes[relative] = hook_script(hook, python)
150
+ result = apply_changes(root, changes, "git hooks " + ("uninstall" if uninstall else "install"), apply)
151
+ if result["applied"] and not uninstall and os.name != "nt":
152
+ for relative in changes:
153
+ p = root / relative
154
+ p.chmod(p.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
155
+ result["conflicts"] = conflicts
156
+ result["repository"] = str(root)
157
+ return result
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Hook runtime
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def _gitleaks_git(root: Path, extra: list[str]) -> list[dict[str, Any]] | None:
165
+ """Run `gitleaks git` and return sanitized findings, or None if Gitleaks is unavailable."""
166
+ adapter = GitleaksAdapter()
167
+ executable, _ = adapter.locate()
168
+ if not executable or not adapter.is_compatible(adapter.detect_version(executable, root)):
169
+ return None
170
+ with tempfile.TemporaryDirectory(prefix="uds-hook-") as temp:
171
+ report = Path(temp) / "report.json"
172
+ command = [executable, "git", "--no-banner", "--redact", "--exit-code", "1",
173
+ "--report-format", "json", "--report-path", str(report)]
174
+ if not (root / ".gitleaks.toml").is_file():
175
+ config = Path(temp) / "uds.toml"
176
+ config.write_text(build_exclusion_config([]), encoding="utf-8")
177
+ command += ["--config", str(config)]
178
+ completed = run_tool(command + extra + ["."], cwd=root, timeout=600)
179
+ if completed.returncode not in (0, 1):
180
+ raise GitError(f"Gitleaks failed with exit code {completed.returncode}; output suppressed.")
181
+ if not report.is_file():
182
+ return []
183
+ data = json.loads(report.read_text(encoding="utf-8") or "[]")
184
+ return [{"rule_id": str(i.get("RuleID")), "file": str(i.get("File", "")), "line": i.get("StartLine"),
185
+ "commit": str(i.get("Commit") or "")[:12]} for i in data if isinstance(i, dict)]
186
+
187
+
188
+ def _builtin_staged_scan(root: Path) -> list[dict[str, Any]]:
189
+ listing = git(root, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR")
190
+ findings = []
191
+ for name in [n for n in listing.stdout.split("\0") if n]:
192
+ shown = git(root, "show", f":{name}")
193
+ if shown.returncode != 0 or len(shown.stdout) > MAX_STAGED_FILE_BYTES or "\x00" in shown.stdout[:8000]:
194
+ continue
195
+ for match in find_secrets(shown.stdout):
196
+ findings.append({"rule_id": match.rule_id, "file": name, "line": match.line, "commit": ""})
197
+ return findings
198
+
199
+
200
+ def _builtin_range_scan(root: Path, rev_args: list[str]) -> list[dict[str, Any]]:
201
+ log = git(root, "log", "-p", "--no-color", "--no-ext-diff", "--format=commit %H", *rev_args)
202
+ findings, commit, file = [], "", ""
203
+ added: list[str] = []
204
+
205
+ def flush() -> None:
206
+ if added:
207
+ for match in find_secrets("\n".join(added)):
208
+ findings.append({"rule_id": match.rule_id, "file": file, "line": None, "commit": commit[:12]})
209
+ added.clear()
210
+
211
+ for line in log.stdout.splitlines():
212
+ if line.startswith("commit "):
213
+ flush()
214
+ commit = line.split(" ", 1)[1]
215
+ elif line.startswith("+++ "):
216
+ flush()
217
+ file = line[6:] if line.startswith("+++ b/") else line[4:]
218
+ elif line.startswith("+") and not line.startswith("+++"):
219
+ added.append(line[1:])
220
+ flush()
221
+ return findings
222
+
223
+
224
+ def _report(findings: list[dict[str, Any]], context: str, engine: str) -> str:
225
+ lines = [f"UDS: {len(findings)} potential secret(s) found in {context} ({engine}). Blocked."]
226
+ for f in findings[:50]:
227
+ where = f["file"] + (f":{f['line']}" if f.get("line") else "") + (f" @ {f['commit']}" if f.get("commit") else "")
228
+ lines.append(f" - {f['rule_id']}: {where}")
229
+ lines.append("UDS: remove the secret, rotate it if it was ever pushed, and use environment variables or a secret manager.")
230
+ return "\n".join(lines)
231
+
232
+
233
+ def pre_commit(path: Path) -> tuple[int, str]:
234
+ root = repo_root(path)
235
+ findings = _gitleaks_git(root, ["--pre-commit", "--staged"])
236
+ engine = "Gitleaks"
237
+ if findings is None:
238
+ findings, engine = _builtin_staged_scan(root), "UDS built-in patterns; install Gitleaks for full coverage"
239
+ if findings:
240
+ return 1, _report(findings, "staged changes", engine)
241
+ return 0, f"UDS: no secrets detected in staged changes ({engine})."
242
+
243
+
244
+ def pre_push(path: Path, remote: str, stdin_text: str) -> tuple[int, str]:
245
+ root = repo_root(path)
246
+ if not _REMOTE_RE.match(remote or "origin"):
247
+ return 1, "UDS: unexpected remote name; push blocked."
248
+ all_findings: list[dict[str, Any]] = []
249
+ engine = "Gitleaks"
250
+ for raw in stdin_text.splitlines():
251
+ parts = raw.split()
252
+ if len(parts) != 4:
253
+ continue
254
+ _, local_sha, _, remote_sha = parts
255
+ if not (_SHA_RE.match(local_sha) and _SHA_RE.match(remote_sha)):
256
+ return 1, "UDS: unexpected pre-push input; push blocked."
257
+ if set(local_sha) == {"0"}:
258
+ continue # branch deletion: nothing outgoing
259
+ if set(remote_sha) == {"0"}:
260
+ rev_args = [local_sha, "--not", f"--remotes={remote or 'origin'}"]
261
+ else:
262
+ rev_args = [f"{remote_sha}..{local_sha}"]
263
+ findings = _gitleaks_git(root, ["--log-opts=" + " ".join(rev_args)])
264
+ if findings is None:
265
+ findings, engine = _builtin_range_scan(root, rev_args), "UDS built-in patterns; install Gitleaks for full coverage"
266
+ all_findings.extend(findings)
267
+ if all_findings:
268
+ return 1, _report(all_findings, "outgoing commits", engine)
269
+ return 0, f"UDS: no secrets detected in outgoing commits ({engine})."