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.
- universal_dev_security/__init__.py +3 -0
- universal_dev_security/__main__.py +4 -0
- universal_dev_security/cli.py +376 -0
- universal_dev_security/cli_extra.py +244 -0
- universal_dev_security/config.py +171 -0
- universal_dev_security/detector.py +201 -0
- universal_dev_security/engine.py +203 -0
- universal_dev_security/findings.py +199 -0
- universal_dev_security/fixes.py +138 -0
- universal_dev_security/gitguard.py +269 -0
- universal_dev_security/history.py +149 -0
- universal_dev_security/initializer.py +112 -0
- universal_dev_security/integrations/__init__.py +46 -0
- universal_dev_security/integrations/agents.py +45 -0
- universal_dev_security/integrations/claude_code.py +192 -0
- universal_dev_security/integrations/github.py +149 -0
- universal_dev_security/integrations/vscode.py +44 -0
- universal_dev_security/policy.py +384 -0
- universal_dev_security/projectfiles.py +80 -0
- universal_dev_security/recovery.py +168 -0
- universal_dev_security/redaction.py +132 -0
- universal_dev_security/reports.py +466 -0
- universal_dev_security/scanner.py +36 -0
- universal_dev_security/scanners/__init__.py +36 -0
- universal_dev_security/scanners/base.py +269 -0
- universal_dev_security/scanners/gitleaks.py +132 -0
- universal_dev_security/scanners/osv.py +156 -0
- universal_dev_security/scanners/semgrep.py +130 -0
- universal_dev_security/scanners/trivy.py +139 -0
- universal_dev_security/testing.py +100 -0
- universal_dev_security/updates.py +84 -0
- universal_dev_security-1.0.0.dist-info/METADATA +128 -0
- universal_dev_security-1.0.0.dist-info/RECORD +37 -0
- universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
- universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
- universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
- 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})."
|