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,168 @@
|
|
|
1
|
+
"""Recovery points and rollback for UDS-made changes.
|
|
2
|
+
|
|
3
|
+
Every UDS modification (fixes, integration installs) follows:
|
|
4
|
+
|
|
5
|
+
checkpoint = rm.create(label, paths) # snapshot "before"
|
|
6
|
+
... modify files (safe_write) ...
|
|
7
|
+
rm.seal(checkpoint) # record "after" hashes
|
|
8
|
+
|
|
9
|
+
Rollback restores the "before" state only for files whose current content still
|
|
10
|
+
matches the recorded "after" hash, so later user edits are never clobbered
|
|
11
|
+
unless ``force`` is given.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
import uuid
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
CHECKPOINT_ID_RE = re.compile(r"^\d{8}T\d{6}(?:\d{6})?Z-[0-9a-f]{6}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecoveryError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256_file(path: Path) -> str | None:
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
return None
|
|
36
|
+
digest = hashlib.sha256()
|
|
37
|
+
with path.open("rb") as handle:
|
|
38
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
39
|
+
digest.update(chunk)
|
|
40
|
+
return digest.hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_inside(root: Path, relative: str) -> Path:
|
|
44
|
+
"""Resolve ``relative`` under ``root``; reject traversal and symlink escapes."""
|
|
45
|
+
if not relative or "\x00" in relative:
|
|
46
|
+
raise RecoveryError("Empty or invalid path")
|
|
47
|
+
# Treat backslashes as separators and reject drive prefixes on every OS, so the
|
|
48
|
+
# same input is accepted or rejected identically on Windows, Linux and macOS.
|
|
49
|
+
normalized = relative.replace("\\", "/")
|
|
50
|
+
if normalized.startswith("/") or re.match(r"^[A-Za-z]:", normalized):
|
|
51
|
+
raise RecoveryError(f"Absolute paths are not allowed: {relative}")
|
|
52
|
+
candidate = Path(normalized)
|
|
53
|
+
if candidate.is_absolute() or candidate.drive or ".." in candidate.parts:
|
|
54
|
+
raise RecoveryError(f"Path traversal is not allowed: {relative}")
|
|
55
|
+
resolved = (root / candidate).resolve()
|
|
56
|
+
try:
|
|
57
|
+
resolved.relative_to(root.resolve())
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise RecoveryError(f"Path escapes the project directory: {relative}") from exc
|
|
60
|
+
return resolved
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def safe_write(root: Path, relative: str, content: str) -> Path:
|
|
64
|
+
"""Atomically write a text file inside ``root`` (temp file + replace)."""
|
|
65
|
+
target = resolve_inside(root, relative)
|
|
66
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
fd, temp = tempfile.mkstemp(prefix=".uds-tmp-", dir=str(target.parent))
|
|
68
|
+
try:
|
|
69
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
70
|
+
handle.write(content)
|
|
71
|
+
if target.exists():
|
|
72
|
+
shutil.copymode(target, temp)
|
|
73
|
+
os.replace(temp, target)
|
|
74
|
+
except BaseException:
|
|
75
|
+
Path(temp).unlink(missing_ok=True)
|
|
76
|
+
raise
|
|
77
|
+
return target
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RecoveryManager:
|
|
81
|
+
def __init__(self, root: Path) -> None:
|
|
82
|
+
self.root = root.expanduser().resolve()
|
|
83
|
+
self.base = self.root / ".uds" / "backups"
|
|
84
|
+
|
|
85
|
+
def _dir(self, checkpoint_id: str) -> Path:
|
|
86
|
+
if not CHECKPOINT_ID_RE.match(checkpoint_id):
|
|
87
|
+
raise RecoveryError(f"Invalid checkpoint id: {checkpoint_id!r}")
|
|
88
|
+
return self.base / checkpoint_id
|
|
89
|
+
|
|
90
|
+
def create(self, label: str, paths: list[str]) -> str:
|
|
91
|
+
from universal_dev_security.history import ensure_uds_dir
|
|
92
|
+
|
|
93
|
+
ensure_uds_dir(self.root)
|
|
94
|
+
checkpoint_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + "-" + uuid.uuid4().hex[:6]
|
|
95
|
+
directory = self._dir(checkpoint_id)
|
|
96
|
+
files_dir = directory / "files"
|
|
97
|
+
files_dir.mkdir(parents=True)
|
|
98
|
+
entries = []
|
|
99
|
+
for relative in dict.fromkeys(paths):
|
|
100
|
+
source = resolve_inside(self.root, relative)
|
|
101
|
+
if source.is_symlink():
|
|
102
|
+
raise RecoveryError(f"Refusing to manage a symbolic link: {relative}")
|
|
103
|
+
existed = source.is_file()
|
|
104
|
+
if existed:
|
|
105
|
+
backup = files_dir / Path(relative)
|
|
106
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
shutil.copy2(source, backup)
|
|
108
|
+
entries.append({"path": Path(relative).as_posix(), "existed": existed,
|
|
109
|
+
"sha256_before": sha256_file(source), "sha256_after": None})
|
|
110
|
+
manifest = {"id": checkpoint_id, "label": label[:200], "created_at": datetime.now(timezone.utc).isoformat(),
|
|
111
|
+
"sealed": False, "restored": False, "entries": entries}
|
|
112
|
+
self._save(checkpoint_id, manifest)
|
|
113
|
+
return checkpoint_id
|
|
114
|
+
|
|
115
|
+
def seal(self, checkpoint_id: str) -> dict[str, Any]:
|
|
116
|
+
manifest = self.load(checkpoint_id)
|
|
117
|
+
for entry in manifest["entries"]:
|
|
118
|
+
entry["sha256_after"] = sha256_file(resolve_inside(self.root, entry["path"]))
|
|
119
|
+
manifest["sealed"] = True
|
|
120
|
+
self._save(checkpoint_id, manifest)
|
|
121
|
+
return manifest
|
|
122
|
+
|
|
123
|
+
def _save(self, checkpoint_id: str, manifest: dict[str, Any]) -> None:
|
|
124
|
+
path = self._dir(checkpoint_id) / "manifest.json"
|
|
125
|
+
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
126
|
+
|
|
127
|
+
def load(self, checkpoint_id: str) -> dict[str, Any]:
|
|
128
|
+
path = self._dir(checkpoint_id) / "manifest.json"
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
raise RecoveryError(f"Checkpoint not found: {checkpoint_id}")
|
|
131
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
|
|
133
|
+
def list(self) -> list[dict[str, Any]]:
|
|
134
|
+
if not self.base.is_dir():
|
|
135
|
+
return []
|
|
136
|
+
result = []
|
|
137
|
+
for directory in sorted(self.base.iterdir()):
|
|
138
|
+
if CHECKPOINT_ID_RE.match(directory.name) and (directory / "manifest.json").is_file():
|
|
139
|
+
manifest = self.load(directory.name)
|
|
140
|
+
result.append({"id": manifest["id"], "label": manifest["label"], "created_at": manifest["created_at"],
|
|
141
|
+
"files": [e["path"] for e in manifest["entries"]], "restored": manifest.get("restored", False)})
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def restore(self, checkpoint_id: str, *, force: bool = False) -> dict[str, Any]:
|
|
145
|
+
manifest = self.load(checkpoint_id)
|
|
146
|
+
restored, conflicts, unchanged = [], [], []
|
|
147
|
+
for entry in manifest["entries"]:
|
|
148
|
+
target = resolve_inside(self.root, entry["path"])
|
|
149
|
+
current = sha256_file(target)
|
|
150
|
+
if current == entry["sha256_before"]:
|
|
151
|
+
unchanged.append(entry["path"])
|
|
152
|
+
continue
|
|
153
|
+
expected_after = entry.get("sha256_after")
|
|
154
|
+
if not force and manifest.get("sealed") and current != expected_after:
|
|
155
|
+
conflicts.append(entry["path"]) # modified after UDS changed it: keep the user's work
|
|
156
|
+
continue
|
|
157
|
+
if entry["existed"]:
|
|
158
|
+
backup = self._dir(checkpoint_id) / "files" / Path(entry["path"])
|
|
159
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
shutil.copy2(backup, target)
|
|
161
|
+
elif target.is_file():
|
|
162
|
+
target.unlink()
|
|
163
|
+
restored.append(entry["path"])
|
|
164
|
+
if not conflicts:
|
|
165
|
+
manifest["restored"] = True
|
|
166
|
+
self._save(checkpoint_id, manifest)
|
|
167
|
+
return {"checkpoint": checkpoint_id, "restored": restored, "conflicts": conflicts, "unchanged": unchanged,
|
|
168
|
+
"status": "conflicts" if conflicts else "restored"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Secret detection heuristics and redaction for text UDS handles itself.
|
|
2
|
+
|
|
3
|
+
This is *not* a replacement for Gitleaks. It is a small, dependency-free
|
|
4
|
+
safety net used where UDS must make a decision without an external scanner,
|
|
5
|
+
for example when an AI coding tool is about to write a file or run a command,
|
|
6
|
+
and to make sure UDS never echoes likely secrets into logs or reports.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
REDACTED = "[REDACTED]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SecretPattern:
|
|
19
|
+
rule_id: str
|
|
20
|
+
description: str
|
|
21
|
+
regex: re.Pattern[str]
|
|
22
|
+
group: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _p(rule_id: str, description: str, pattern: str, group: int = 0, flags: int = 0) -> SecretPattern:
|
|
26
|
+
return SecretPattern(rule_id, description, re.compile(pattern, flags), group)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# High-confidence, well-documented token formats. Kept conservative to avoid
|
|
30
|
+
# blocking legitimate work with false positives.
|
|
31
|
+
PATTERNS: tuple[SecretPattern, ...] = (
|
|
32
|
+
_p("private-key", "Private key block", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----"),
|
|
33
|
+
_p("aws-access-key-id", "AWS access key ID", r"\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b"),
|
|
34
|
+
_p("github-token", "GitHub token", r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b"),
|
|
35
|
+
_p("github-fine-grained-pat", "GitHub fine-grained token", r"\bgithub_pat_[A-Za-z0-9_]{60,255}\b"),
|
|
36
|
+
# Every quantifier is bounded: these patterns run on untrusted AI-generated
|
|
37
|
+
# content inside a hook, so catastrophic backtracking (ReDoS) must be impossible.
|
|
38
|
+
_p("gitlab-pat", "GitLab personal access token", r"\bglpat-[A-Za-z0-9_\-]{20,100}\b"),
|
|
39
|
+
_p("slack-token", "Slack token", r"\bxox[abposr]-[A-Za-z0-9-]{10,200}\b"),
|
|
40
|
+
_p("stripe-secret-key", "Stripe secret key", r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,200}\b"),
|
|
41
|
+
_p("google-api-key", "Google API key", r"\bAIza[0-9A-Za-z_\-]{35}\b"),
|
|
42
|
+
_p("anthropic-api-key", "Anthropic API key", r"\bsk-ant-[A-Za-z0-9_\-]{20,300}\b"),
|
|
43
|
+
_p("openai-api-key", "OpenAI API key", r"\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_\-]{32,300}\b"),
|
|
44
|
+
_p("npm-token", "npm access token", r"\bnpm_[A-Za-z0-9]{36}\b"),
|
|
45
|
+
_p("pypi-token", "PyPI upload token", r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_\-]{50,400}\b"),
|
|
46
|
+
_p("jwt", "JSON Web Token",
|
|
47
|
+
r"\beyJ[A-Za-z0-9_\-]{10,4000}\.eyJ[A-Za-z0-9_\-]{10,8000}\.[A-Za-z0-9_\-]{10,2000}\b"),
|
|
48
|
+
_p(
|
|
49
|
+
"connection-string-password",
|
|
50
|
+
"Credentials embedded in a connection URL",
|
|
51
|
+
r"\b[a-z][a-z0-9+.\-]{1,20}://[^\s:/@'\"]{1,64}:([^\s@/'\"]{6,128})@[^\s'\"]{1,500}",
|
|
52
|
+
group=1,
|
|
53
|
+
flags=re.IGNORECASE,
|
|
54
|
+
),
|
|
55
|
+
_p(
|
|
56
|
+
"generic-assigned-secret",
|
|
57
|
+
"High-entropy value assigned to a secret-like name",
|
|
58
|
+
r"(?i)\b[\w.\-]{0,40}?(?:secret|passwd|password|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key|client[_-]?secret)[\w.\-]{0,40}\s{0,5}[:=]\s{0,5}[\"']?([A-Za-z0-9+/_\-.~]{16,200})[\"']?",
|
|
59
|
+
group=1,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_PLACEHOLDER_HINTS = (
|
|
64
|
+
"example", "placeholder", "changeme", "change_me", "your_", "your-", "xxxx", "dummy",
|
|
65
|
+
"sample", "fake", "test", "redacted", "<", "${", "{{", "process.env", "os.environ", "getenv",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SecretMatch:
|
|
71
|
+
rule_id: str
|
|
72
|
+
description: str
|
|
73
|
+
line: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def shannon_entropy(value: str) -> float:
|
|
77
|
+
if not value:
|
|
78
|
+
return 0.0
|
|
79
|
+
frequencies = {char: value.count(char) for char in set(value)}
|
|
80
|
+
length = len(value)
|
|
81
|
+
return -sum((count / length) * math.log2(count / length) for count in frequencies.values())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _looks_like_placeholder(value: str) -> bool:
|
|
85
|
+
lowered = value.lower()
|
|
86
|
+
return any(hint in lowered for hint in _PLACEHOLDER_HINTS) or len(set(value)) <= 3
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_secrets(text: str, *, max_chars: int = 2_000_000) -> list[SecretMatch]:
|
|
90
|
+
"""Return locations of likely secrets. Never returns the secret value."""
|
|
91
|
+
if not text:
|
|
92
|
+
return []
|
|
93
|
+
text = text[:max_chars]
|
|
94
|
+
matches: list[SecretMatch] = []
|
|
95
|
+
seen: set[tuple[str, int]] = set()
|
|
96
|
+
for pattern in PATTERNS:
|
|
97
|
+
for match in pattern.regex.finditer(text):
|
|
98
|
+
value = match.group(pattern.group) or ""
|
|
99
|
+
if pattern.rule_id == "generic-assigned-secret":
|
|
100
|
+
if _looks_like_placeholder(value) or shannon_entropy(value) < 3.5:
|
|
101
|
+
continue
|
|
102
|
+
elif pattern.rule_id == "connection-string-password" and _looks_like_placeholder(value):
|
|
103
|
+
continue
|
|
104
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
105
|
+
key = (pattern.rule_id, line)
|
|
106
|
+
if key not in seen:
|
|
107
|
+
seen.add(key)
|
|
108
|
+
matches.append(SecretMatch(pattern.rule_id, pattern.description, line))
|
|
109
|
+
return matches
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def redact(text: str) -> str:
|
|
113
|
+
"""Replace likely secret values in ``text`` with a fixed marker."""
|
|
114
|
+
if not text:
|
|
115
|
+
return text
|
|
116
|
+
result = text
|
|
117
|
+
for pattern in PATTERNS:
|
|
118
|
+
def _replace(match: re.Match[str], pattern: SecretPattern = pattern) -> str:
|
|
119
|
+
value = match.group(pattern.group) or ""
|
|
120
|
+
if pattern.rule_id == "generic-assigned-secret" and (
|
|
121
|
+
_looks_like_placeholder(value) or shannon_entropy(value) < 3.5
|
|
122
|
+
):
|
|
123
|
+
return match.group(0)
|
|
124
|
+
if pattern.group == 0:
|
|
125
|
+
return REDACTED
|
|
126
|
+
whole = match.group(0)
|
|
127
|
+
start = match.start(pattern.group) - match.start(0)
|
|
128
|
+
end = match.end(pattern.group) - match.start(0)
|
|
129
|
+
return whole[:start] + REDACTED + whole[end:]
|
|
130
|
+
|
|
131
|
+
result = pattern.regex.sub(_replace, result)
|
|
132
|
+
return result
|