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,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