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,130 @@
|
|
|
1
|
+
"""Semgrep adapter: static code analysis (https://semgrep.dev)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from universal_dev_security.detector import has_source_code, recommended_semgrep_configs
|
|
10
|
+
from universal_dev_security.findings import Category, Finding, Severity
|
|
11
|
+
from universal_dev_security.projectfiles import ProjectInventory
|
|
12
|
+
from universal_dev_security.redaction import redact
|
|
13
|
+
from universal_dev_security.scanners.base import (
|
|
14
|
+
ScannerAdapter,
|
|
15
|
+
ScannerExecutionError,
|
|
16
|
+
ScanOptions,
|
|
17
|
+
relative_path,
|
|
18
|
+
run_tool,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Semgrep rule severities. Current Semgrep rules may use CRITICAL/HIGH/MEDIUM/LOW
|
|
22
|
+
# directly; legacy values are documented by Semgrep as ERROR=High,
|
|
23
|
+
# WARNING=Medium, INFO=Low. Anything else stays unclassified.
|
|
24
|
+
SEVERITY_MAP = {
|
|
25
|
+
"CRITICAL": Severity.CRITICAL,
|
|
26
|
+
"HIGH": Severity.HIGH,
|
|
27
|
+
"ERROR": Severity.HIGH,
|
|
28
|
+
"MEDIUM": Severity.MEDIUM,
|
|
29
|
+
"WARNING": Severity.MEDIUM,
|
|
30
|
+
"LOW": Severity.LOW,
|
|
31
|
+
"INFO": Severity.LOW,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _strings(value: Any, limit: int = 10) -> list[str]:
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
return [value]
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [str(v) for v in value if isinstance(v, (str, int, float))][:limit]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize(data: dict[str, Any], root: Path) -> list[Finding]:
|
|
44
|
+
results = data.get("results")
|
|
45
|
+
if not isinstance(results, list):
|
|
46
|
+
raise ScannerExecutionError("Semgrep output has an unexpected format.")
|
|
47
|
+
findings = []
|
|
48
|
+
for item in results:
|
|
49
|
+
if not isinstance(item, dict):
|
|
50
|
+
continue
|
|
51
|
+
extra = item.get("extra") if isinstance(item.get("extra"), dict) else {}
|
|
52
|
+
metadata = extra.get("metadata") if isinstance(extra.get("metadata"), dict) else {}
|
|
53
|
+
raw_severity = str(extra.get("severity") or "").upper()
|
|
54
|
+
severity = SEVERITY_MAP.get(raw_severity, Severity.UNCLASSIFIED)
|
|
55
|
+
start = item.get("start") if isinstance(item.get("start"), dict) else {}
|
|
56
|
+
end = item.get("end") if isinstance(item.get("end"), dict) else {}
|
|
57
|
+
rule_id = str(item.get("check_id") or "unknown")[:300]
|
|
58
|
+
cwe = ", ".join(_strings(metadata.get("cwe"), 3))
|
|
59
|
+
references = _strings(metadata.get("references")) + _strings(metadata.get("source"), 1)
|
|
60
|
+
message = redact(str(extra.get("message") or ""))[:800] # messages can interpolate matched code
|
|
61
|
+
findings.append(Finding(
|
|
62
|
+
tool="semgrep",
|
|
63
|
+
category=Category.CODE,
|
|
64
|
+
rule_id=rule_id,
|
|
65
|
+
title=rule_id.rsplit(".", 1)[-1].replace("-", " ").capitalize()[:200],
|
|
66
|
+
message=message,
|
|
67
|
+
file=relative_path(item.get("path"), root),
|
|
68
|
+
start_line=start.get("line"),
|
|
69
|
+
end_line=end.get("line"),
|
|
70
|
+
severity=severity,
|
|
71
|
+
severity_source="scanner" if severity is not Severity.UNCLASSIFIED else "none",
|
|
72
|
+
scanner_severity=raw_severity or None,
|
|
73
|
+
references=references,
|
|
74
|
+
remediation=("Review the flagged code and apply the rule guidance" + (f" ({cwe})." if cwe else ".")),
|
|
75
|
+
))
|
|
76
|
+
return findings
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SemgrepAdapter(ScannerAdapter):
|
|
80
|
+
name = "semgrep"
|
|
81
|
+
display_name = "Semgrep"
|
|
82
|
+
category = Category.CODE
|
|
83
|
+
executable_names = ("semgrep",)
|
|
84
|
+
min_version = (1, 0, 0)
|
|
85
|
+
downloads_data = True
|
|
86
|
+
network_note = ("downloads rule packs from the Semgrep Registry when registry configs are used; "
|
|
87
|
+
"UDS disables Semgrep metrics and does not upload source code.")
|
|
88
|
+
docs_url = "https://semgrep.dev/docs/getting-started/quickstart"
|
|
89
|
+
install_hints = {
|
|
90
|
+
"windows": "python -m pip install semgrep (inside the UDS virtual environment)",
|
|
91
|
+
"macos": "brew install semgrep or python3 -m pip install semgrep",
|
|
92
|
+
"linux": "python3 -m pip install semgrep",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def applicable(self, inventory: ProjectInventory) -> tuple[bool, str]:
|
|
96
|
+
if has_source_code(inventory):
|
|
97
|
+
return True, "Source code files detected."
|
|
98
|
+
return False, "No source code files supported by Semgrep were found."
|
|
99
|
+
|
|
100
|
+
def configs(self, options: ScanOptions) -> list[str]:
|
|
101
|
+
configs = options.semgrep_configs or recommended_semgrep_configs(options.technologies)
|
|
102
|
+
# `--config auto` requires sending metrics to Semgrep; replace it with the default pack.
|
|
103
|
+
return [("p/default" if c.strip().lower() == "auto" else c.strip()) for c in configs if c.strip()][:20]
|
|
104
|
+
|
|
105
|
+
def build_command(self, executable: str, options: ScanOptions) -> list[str]:
|
|
106
|
+
command = [executable, "scan", "--json", "--metrics=off", "--disable-version-check", "--quiet",
|
|
107
|
+
"--timeout", "30"]
|
|
108
|
+
for config in self.configs(options):
|
|
109
|
+
command += ["--config", config]
|
|
110
|
+
for excluded in options.excluded_dirs:
|
|
111
|
+
if excluded != ".git":
|
|
112
|
+
command += ["--exclude", excluded]
|
|
113
|
+
return command + ["."]
|
|
114
|
+
|
|
115
|
+
def execute(self, executable: str, version: str, target: Path, options: ScanOptions) -> list[Finding]:
|
|
116
|
+
env = {"SEMGREP_SEND_METRICS": "off", "SEMGREP_ENABLE_VERSION_CHECK": "0"}
|
|
117
|
+
if os.name == "nt":
|
|
118
|
+
env["PYTHONUTF8"] = "1"
|
|
119
|
+
completed = run_tool(self.build_command(executable, options), cwd=target,
|
|
120
|
+
timeout=options.timeout_seconds, env_overrides=env)
|
|
121
|
+
# 0 = success, 1 = findings with --error. Everything else is a failure.
|
|
122
|
+
if completed.returncode not in (0, 1):
|
|
123
|
+
raise ScannerExecutionError(f"Semgrep exited with code {completed.returncode}; raw output was suppressed.")
|
|
124
|
+
try:
|
|
125
|
+
data = json.loads(completed.stdout or "")
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise ScannerExecutionError("Semgrep output could not be parsed; raw output was suppressed.") from exc
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
raise ScannerExecutionError("Semgrep output has an unexpected format.")
|
|
130
|
+
return normalize(data, target)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Trivy adapter: dependency vulnerabilities and misconfiguration (https://trivy.dev)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from universal_dev_security.detector import has_dependency_manifests, has_iac
|
|
10
|
+
from universal_dev_security.findings import Category, Finding, Severity
|
|
11
|
+
from universal_dev_security.projectfiles import ProjectInventory
|
|
12
|
+
from universal_dev_security.scanners.base import (
|
|
13
|
+
ScannerAdapter,
|
|
14
|
+
ScannerExecutionError,
|
|
15
|
+
ScanOptions,
|
|
16
|
+
relative_path,
|
|
17
|
+
run_tool,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Trivy reports CRITICAL/HIGH/MEDIUM/LOW/UNKNOWN natively.
|
|
21
|
+
SEVERITY_MAP = {
|
|
22
|
+
"CRITICAL": Severity.CRITICAL,
|
|
23
|
+
"HIGH": Severity.HIGH,
|
|
24
|
+
"MEDIUM": Severity.MEDIUM,
|
|
25
|
+
"LOW": Severity.LOW,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _refs(item: dict[str, Any]) -> list[str]:
|
|
30
|
+
refs = []
|
|
31
|
+
if item.get("PrimaryURL"):
|
|
32
|
+
refs.append(str(item["PrimaryURL"]))
|
|
33
|
+
refs.extend(str(r) for r in (item.get("References") or [])[:5] if isinstance(r, str))
|
|
34
|
+
return refs
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _severity(item: dict[str, Any]) -> tuple[Severity, str | None]:
|
|
38
|
+
raw = str(item.get("Severity") or "").upper()
|
|
39
|
+
return SEVERITY_MAP.get(raw, Severity.UNCLASSIFIED), raw or None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize(data: dict[str, Any], root: Path) -> list[Finding]:
|
|
43
|
+
results = data.get("Results")
|
|
44
|
+
if results is None:
|
|
45
|
+
return []
|
|
46
|
+
if not isinstance(results, list):
|
|
47
|
+
raise ScannerExecutionError("Trivy output has an unexpected format.")
|
|
48
|
+
findings: list[Finding] = []
|
|
49
|
+
for result in results:
|
|
50
|
+
if not isinstance(result, dict):
|
|
51
|
+
continue
|
|
52
|
+
target_file = relative_path(result.get("Target"), root)
|
|
53
|
+
for vuln in result.get("Vulnerabilities") or []:
|
|
54
|
+
if not isinstance(vuln, dict):
|
|
55
|
+
continue
|
|
56
|
+
severity, raw = _severity(vuln)
|
|
57
|
+
pkg = str(vuln.get("PkgName") or "unknown")[:200]
|
|
58
|
+
fixed = str(vuln.get("FixedVersion") or "")[:200] or None
|
|
59
|
+
findings.append(Finding(
|
|
60
|
+
tool="trivy", category=Category.DEPENDENCY,
|
|
61
|
+
rule_id=str(vuln.get("VulnerabilityID") or "unknown")[:100],
|
|
62
|
+
title=str(vuln.get("Title") or f"Known vulnerability in {pkg}")[:300],
|
|
63
|
+
message=str(vuln.get("Description") or "")[:800],
|
|
64
|
+
file=target_file, package=pkg,
|
|
65
|
+
installed_version=str(vuln.get("InstalledVersion") or "")[:100] or None,
|
|
66
|
+
fixed_version=fixed, severity=severity,
|
|
67
|
+
severity_source="scanner" if severity is not Severity.UNCLASSIFIED else "none",
|
|
68
|
+
scanner_severity=raw, references=_refs(vuln),
|
|
69
|
+
remediation=(f"Upgrade {pkg} to {fixed} or later, then run the test suite."
|
|
70
|
+
if fixed else f"No fixed version is listed; assess exposure of {pkg}."),
|
|
71
|
+
))
|
|
72
|
+
for misconf in result.get("Misconfigurations") or []:
|
|
73
|
+
if not isinstance(misconf, dict) or str(misconf.get("Status") or "FAIL").upper() == "PASS":
|
|
74
|
+
continue
|
|
75
|
+
severity, raw = _severity(misconf)
|
|
76
|
+
cause = misconf.get("CauseMetadata") if isinstance(misconf.get("CauseMetadata"), dict) else {}
|
|
77
|
+
findings.append(Finding(
|
|
78
|
+
tool="trivy", category=Category.CONFIGURATION,
|
|
79
|
+
rule_id=str(misconf.get("AVDID") or misconf.get("ID") or "unknown")[:100],
|
|
80
|
+
title=str(misconf.get("Title") or "Misconfiguration")[:300],
|
|
81
|
+
message=str(misconf.get("Message") or misconf.get("Description") or "")[:800],
|
|
82
|
+
file=target_file, start_line=cause.get("StartLine"), end_line=cause.get("EndLine"),
|
|
83
|
+
severity=severity,
|
|
84
|
+
severity_source="scanner" if severity is not Severity.UNCLASSIFIED else "none",
|
|
85
|
+
scanner_severity=raw, references=_refs(misconf),
|
|
86
|
+
remediation=str(misconf.get("Resolution") or "Apply the documented secure configuration.")[:500],
|
|
87
|
+
))
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TrivyAdapter(ScannerAdapter):
|
|
92
|
+
name = "trivy"
|
|
93
|
+
display_name = "Trivy"
|
|
94
|
+
category = Category.CONFIGURATION
|
|
95
|
+
executable_names = ("trivy",)
|
|
96
|
+
min_version = (0, 45, 0)
|
|
97
|
+
downloads_data = True
|
|
98
|
+
network_note = ("downloads its vulnerability database and misconfiguration checks; "
|
|
99
|
+
"project files are analysed locally and are not uploaded.")
|
|
100
|
+
docs_url = "https://trivy.dev/latest/getting-started/installation/"
|
|
101
|
+
install_hints = {
|
|
102
|
+
"windows": "winget install --id AquaSecurity.Trivy -e",
|
|
103
|
+
"macos": "brew install trivy",
|
|
104
|
+
"linux": "Use the official repository or release from https://trivy.dev/latest/getting-started/installation/",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def applicable(self, inventory: ProjectInventory) -> tuple[bool, str]:
|
|
108
|
+
reasons = []
|
|
109
|
+
if has_dependency_manifests(inventory):
|
|
110
|
+
reasons.append("dependency manifests")
|
|
111
|
+
if has_iac(inventory):
|
|
112
|
+
reasons.append("container / infrastructure-as-code files")
|
|
113
|
+
if reasons:
|
|
114
|
+
return True, "Detected " + " and ".join(reasons) + "."
|
|
115
|
+
return False, "No dependency manifests or infrastructure-as-code files were found."
|
|
116
|
+
|
|
117
|
+
def build_command(self, executable: str, report: Path, options: ScanOptions) -> list[str]:
|
|
118
|
+
# Secret scanning is handled by Gitleaks; Trivy's secret output includes matched text.
|
|
119
|
+
command = [executable, "fs", "--quiet", "--format", "json", "--output", str(report),
|
|
120
|
+
"--scanners", "vuln,misconfig", "--exit-code", "0"]
|
|
121
|
+
skip = [d for d in options.excluded_dirs if d != ".git"]
|
|
122
|
+
if skip:
|
|
123
|
+
command += ["--skip-dirs", ",".join(skip)]
|
|
124
|
+
return command + ["."]
|
|
125
|
+
|
|
126
|
+
def execute(self, executable: str, version: str, target: Path, options: ScanOptions) -> list[Finding]:
|
|
127
|
+
with tempfile.TemporaryDirectory(prefix="uds-trivy-") as temp_dir:
|
|
128
|
+
report = Path(temp_dir) / "trivy.json"
|
|
129
|
+
completed = run_tool(self.build_command(executable, report, options), cwd=target,
|
|
130
|
+
timeout=options.timeout_seconds)
|
|
131
|
+
if completed.returncode != 0:
|
|
132
|
+
raise ScannerExecutionError(f"Trivy exited with code {completed.returncode}; raw output was suppressed.")
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(report.read_text(encoding="utf-8") or "{}")
|
|
135
|
+
except (OSError, ValueError, UnicodeDecodeError) as exc:
|
|
136
|
+
raise ScannerExecutionError("Trivy report could not be parsed; raw output was suppressed.") from exc
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise ScannerExecutionError("Trivy output has an unexpected format.")
|
|
139
|
+
return normalize(data, target)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Project test detection and execution (used by `uds test` and after fixes)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from universal_dev_security.config import ProjectConfig
|
|
15
|
+
from universal_dev_security.redaction import redact
|
|
16
|
+
|
|
17
|
+
NPM_DEFAULT_TEST = 'echo "Error: no test specified" && exit 1'
|
|
18
|
+
OUTPUT_TAIL = 6000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _project_python(root: Path) -> str:
|
|
22
|
+
for candidate in (root / ".venv" / "Scripts" / "python.exe", root / ".venv" / "bin" / "python",
|
|
23
|
+
root / "venv" / "Scripts" / "python.exe", root / "venv" / "bin" / "python"):
|
|
24
|
+
if candidate.is_file():
|
|
25
|
+
return str(candidate)
|
|
26
|
+
return sys.executable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _uses_pytest(root: Path) -> bool:
|
|
30
|
+
if (root / "pytest.ini").is_file() or (root / "conftest.py").is_file():
|
|
31
|
+
return True
|
|
32
|
+
pyproject = root / "pyproject.toml"
|
|
33
|
+
if pyproject.is_file():
|
|
34
|
+
try:
|
|
35
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
36
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
37
|
+
data = {}
|
|
38
|
+
if "pytest" in data.get("tool", {}):
|
|
39
|
+
return True
|
|
40
|
+
tests = root / "tests"
|
|
41
|
+
return tests.is_dir() and any(tests.glob("test_*.py"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_test_command(root: Path, config: ProjectConfig | None = None) -> tuple[list[str] | None, str]:
|
|
45
|
+
"""Return (command, explanation). Commands are argument lists; no shell is used."""
|
|
46
|
+
root = root.expanduser().resolve()
|
|
47
|
+
if config and config.test_command:
|
|
48
|
+
return list(config.test_command), "configured in .uds/config.toml"
|
|
49
|
+
if _uses_pytest(root):
|
|
50
|
+
return [_project_python(root), "-m", "pytest", "-q"], "pytest project detected"
|
|
51
|
+
package = root / "package.json"
|
|
52
|
+
if package.is_file():
|
|
53
|
+
try:
|
|
54
|
+
scripts = json.loads(package.read_text(encoding="utf-8")).get("scripts", {})
|
|
55
|
+
except (OSError, ValueError, AttributeError):
|
|
56
|
+
scripts = {}
|
|
57
|
+
if isinstance(scripts, dict) and scripts.get("test") and scripts["test"].strip() != NPM_DEFAULT_TEST:
|
|
58
|
+
return ["npm", "test", "--silent"], "package.json test script"
|
|
59
|
+
if any(root.glob("*.sln")) or any(root.glob("*.csproj")):
|
|
60
|
+
return ["dotnet", "test"], ".NET solution/project"
|
|
61
|
+
if (root / "pom.xml").is_file():
|
|
62
|
+
return ["mvn", "-q", "test"], "Maven project"
|
|
63
|
+
if (root / "gradlew").is_file() or (root / "gradlew.bat").is_file():
|
|
64
|
+
return [str(root / ("gradlew.bat" if os.name == "nt" else "gradlew")), "test"], "Gradle wrapper"
|
|
65
|
+
if (root / "go.mod").is_file():
|
|
66
|
+
return ["go", "test", "./..."], "Go module"
|
|
67
|
+
if (root / "Cargo.toml").is_file():
|
|
68
|
+
return ["cargo", "test"], "Cargo project"
|
|
69
|
+
if (root / "vendor" / "bin" / "phpunit").is_file():
|
|
70
|
+
return [str(root / "vendor" / "bin" / "phpunit")], "PHPUnit"
|
|
71
|
+
return None, "No test suite detected. Configure [testing] command in .uds/config.toml."
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_tests(root: Path, config: ProjectConfig | None = None, timeout: int | None = None) -> dict[str, Any]:
|
|
75
|
+
root = root.expanduser().resolve()
|
|
76
|
+
command, why = detect_test_command(root, config)
|
|
77
|
+
if not command:
|
|
78
|
+
return {"status": "not_configured", "passed": None, "command": None, "message": why}
|
|
79
|
+
executable = command[0]
|
|
80
|
+
resolved = executable if Path(executable).is_file() else shutil.which(executable)
|
|
81
|
+
if not resolved:
|
|
82
|
+
return {"status": "error", "passed": False, "command": command, "message": f"{executable!r} was not found on PATH."}
|
|
83
|
+
started = time.monotonic()
|
|
84
|
+
try:
|
|
85
|
+
# The command is the project's own, fixed test runner (no user-supplied arguments are
|
|
86
|
+
# interpolated), so running Windows .cmd shims such as npm.cmd is acceptable here.
|
|
87
|
+
completed = subprocess.run( # noqa: S603
|
|
88
|
+
[resolved, *command[1:]], cwd=str(root), stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
89
|
+
encoding="utf-8", errors="replace", timeout=timeout or (config.test_timeout_seconds if config else 900),
|
|
90
|
+
check=False, shell=False,
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
return {"status": "timeout", "passed": False, "command": command, "message": "Test run timed out."}
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
return {"status": "error", "passed": False, "command": command, "message": f"Could not start tests: {type(exc).__name__}"}
|
|
96
|
+
output = redact((completed.stdout or "") + (completed.stderr or ""))[-OUTPUT_TAIL:]
|
|
97
|
+
passed = completed.returncode == 0
|
|
98
|
+
return {"status": "passed" if passed else "failed", "passed": passed, "command": command, "reason": why,
|
|
99
|
+
"exit_code": completed.returncode, "duration_seconds": round(time.monotonic() - started, 2),
|
|
100
|
+
"output_tail": output}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Update checks for UDS and its scanners.
|
|
2
|
+
|
|
3
|
+
UDS never updates itself or installs scanners. ``check_uds_update`` contacts a
|
|
4
|
+
network source only when one is given explicitly, sends nothing but an HTTPS
|
|
5
|
+
GET request, and only reports what is available.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from universal_dev_security import __version__
|
|
16
|
+
from universal_dev_security.scanners import available_scanners, get_scanner
|
|
17
|
+
from universal_dev_security.scanners.base import parse_version
|
|
18
|
+
|
|
19
|
+
PYPI_PROJECT = "universal-dev-security"
|
|
20
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9-]{1,39}/[A-Za-z0-9._-]{1,100}$")
|
|
21
|
+
MAX_RESPONSE = 2 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _HttpsOnlyRedirects(urllib.request.HTTPRedirectHandler):
|
|
25
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
26
|
+
if not str(newurl).startswith("https://"):
|
|
27
|
+
raise urllib.error.URLError("Refusing a redirect to a non-HTTPS URL")
|
|
28
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fetch_json(url: str, timeout: int = 15) -> Any:
|
|
32
|
+
if not url.startswith("https://"):
|
|
33
|
+
raise ValueError("Only HTTPS update sources are allowed")
|
|
34
|
+
request = urllib.request.Request(url, headers={"User-Agent": f"uds-update-check/{__version__}",
|
|
35
|
+
"Accept": "application/json"})
|
|
36
|
+
opener = urllib.request.build_opener(_HttpsOnlyRedirects())
|
|
37
|
+
with opener.open(request, timeout=timeout) as response: # https enforced, including redirects
|
|
38
|
+
return json.loads(response.read(MAX_RESPONSE).decode("utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_tuple(text: str) -> tuple[int, ...]:
|
|
42
|
+
"""Release-ordering key. Pre-releases (rc/a/b) sort before the final release."""
|
|
43
|
+
parsed = parse_version(text) or (0, 0, 0)
|
|
44
|
+
pre = 0 if re.search(r"\d[.-]?(?:rc|a|b|alpha|beta|dev)\d*$", text.strip().lower()) else 1
|
|
45
|
+
return (*parsed, pre)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_uds_update(source: str | None, fetch=_fetch_json) -> dict[str, Any]:
|
|
49
|
+
if not source:
|
|
50
|
+
return {"status": "source_not_configured", "current": __version__,
|
|
51
|
+
"message": "No update source given. Use --source pypi or --source github:OWNER/REPO. "
|
|
52
|
+
"UDS does not contact the network unless you ask it to."}
|
|
53
|
+
try:
|
|
54
|
+
if source == "pypi":
|
|
55
|
+
data = fetch(f"https://pypi.org/pypi/{PYPI_PROJECT}/json")
|
|
56
|
+
latest = str(data["info"]["version"])
|
|
57
|
+
url = f"https://pypi.org/project/{PYPI_PROJECT}/"
|
|
58
|
+
elif source.startswith("github:"):
|
|
59
|
+
repo = source.split(":", 1)[1]
|
|
60
|
+
if not _REPO_RE.match(repo):
|
|
61
|
+
raise ValueError("Invalid GitHub repository; expected OWNER/REPO")
|
|
62
|
+
data = fetch(f"https://api.github.com/repos/{repo}/releases/latest")
|
|
63
|
+
latest = str(data["tag_name"]).lstrip("v")
|
|
64
|
+
url = str(data.get("html_url") or f"https://github.com/{repo}/releases")
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("Unknown source; use 'pypi' or 'github:OWNER/REPO'")
|
|
67
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
68
|
+
return {"status": "error", "current": __version__, "message": f"Update check failed: {type(exc).__name__}"}
|
|
69
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
70
|
+
return {"status": "error", "current": __version__, "message": f"Update check failed: {exc}"}
|
|
71
|
+
newer = _version_tuple(latest) > _version_tuple(__version__)
|
|
72
|
+
return {"status": "update_available" if newer else "up_to_date", "current": __version__, "latest": latest,
|
|
73
|
+
"url": url, "message": ("A newer version is available. Review the changelog, then upgrade with "
|
|
74
|
+
"`python -m pip install --upgrade <verified package>` and run `uds doctor`.")
|
|
75
|
+
if newer else "UDS is up to date."}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scanner_status() -> list[dict[str, Any]]:
|
|
79
|
+
rows = []
|
|
80
|
+
for name in available_scanners():
|
|
81
|
+
info = get_scanner(name).info()
|
|
82
|
+
rows.append({k: info[k] for k in ("name", "installed", "version", "minimum_version", "compatible",
|
|
83
|
+
"docs_url", "install_hints")})
|
|
84
|
+
return rows
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: universal-dev-security
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: One security system for every project: secrets, code, dependency and configuration scanning with a central policy engine, Git and AI-agent guards, and a local security dashboard.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/jet1x/Universal-Dev-Security
|
|
7
|
+
Project-URL: Repository, https://github.com/jet1x/Universal-Dev-Security.git
|
|
8
|
+
Project-URL: Issues, https://github.com/jet1x/Universal-Dev-Security/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/jet1x/Universal-Dev-Security/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Security, https://github.com/jet1x/Universal-Dev-Security/blob/main/SECURITY.md
|
|
11
|
+
Keywords: security,devsecops,gitleaks,semgrep,osv-scanner,trivy,sast,sca,claude-code,secrets
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Universal Dev Security (UDS)
|
|
34
|
+
|
|
35
|
+
**One Security System. Every Project.**
|
|
36
|
+
|
|
37
|
+
UDS is an open-source, local-first security toolkit for developers. It runs
|
|
38
|
+
industry-standard scanners on any project, normalizes their results into one
|
|
39
|
+
schema, enforces a central security policy for Git and AI coding agents, and
|
|
40
|
+
produces a dark, self-contained HTML security dashboard.
|
|
41
|
+
|
|
42
|
+
> **Status:** `1.0.0` — first stable release. See
|
|
43
|
+
> [DEVELOPMENT_STATUS.md](DEVELOPMENT_STATUS.md) for verified functionality,
|
|
44
|
+
> known issues and test results. UDS reports what its scanners find; a clean
|
|
45
|
+
> result is never proof that a project is secure.
|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
| Area | Capability |
|
|
50
|
+
|---|---|
|
|
51
|
+
| Secrets | [Gitleaks](https://github.com/gitleaks/gitleaks) directory, staged and outgoing-commit scanning (values always redacted) |
|
|
52
|
+
| Code | [Semgrep](https://semgrep.dev) static analysis with technology-based rule packs (metrics disabled) |
|
|
53
|
+
| Dependencies | [OSV-Scanner](https://google.github.io/osv-scanner/) and [Trivy](https://trivy.dev) vulnerability scanning |
|
|
54
|
+
| Configuration | Trivy misconfiguration scanning (Dockerfile, Kubernetes, Terraform, …) |
|
|
55
|
+
| Policy | Central engine: approval for `git push`, denial of hook bypasses and secret writes, project rules can only add restrictions |
|
|
56
|
+
| Git | `pre-commit` / `pre-push` hooks that block secrets (fail closed) |
|
|
57
|
+
| AI agents | Claude Code `PreToolUse` hooks; generic `uds guard` JSON protocol for any agent |
|
|
58
|
+
| GitHub | Generated PR workflow with checksum-verified scanners and SARIF upload |
|
|
59
|
+
| Reports | JSON, SARIF 2.1.0 and an offline dark HTML dashboard with filters, history and scan comparison |
|
|
60
|
+
| Recovery | Checkpoints before every UDS change; rollback that never clobbers later user edits |
|
|
61
|
+
| Fixes | Low-risk, reversible fixes verified by your test suite, rolled back automatically if tests fail |
|
|
62
|
+
|
|
63
|
+
Scanner states are explicit — `clean`, `findings`, `not_installed`,
|
|
64
|
+
`not_applicable`, `skipped`, `error` — and the overall status becomes
|
|
65
|
+
`incomplete` when an applicable scanner did not run. Severity is shown only
|
|
66
|
+
when the scanner supplies it or a published scale (CVSS) defines it; everything
|
|
67
|
+
else is `unclassified`. No security score is invented.
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
Requirements: Python 3.11+, Windows, Linux or macOS. Git is recommended.
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
# Windows (from a release folder containing the wheel and SHA256SUMS)
|
|
75
|
+
.\scripts\install.ps1 -Wheel .\dist\1.0.0\universal_dev_security-1.0.0-py3-none-any.whl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Linux / macOS
|
|
80
|
+
sh scripts/install.sh --wheel dist/1.0.0/universal_dev_security-1.0.0-py3-none-any.whl
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then, inside a project:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uds doctor # runtime, config and scanner availability (+ official install sources)
|
|
87
|
+
uds init # create .uds/config.toml (never overwrites)
|
|
88
|
+
uds scan # run applicable scanners, write JSON + HTML reports, record history
|
|
89
|
+
uds dashboard --open # open the dark HTML dashboard
|
|
90
|
+
uds hooks install --apply # block secrets at commit and push time
|
|
91
|
+
uds integrate claude-code --apply # enforce the UDS policy inside Claude Code
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Every command that changes files is a **dry run by default** and shows a diff;
|
|
95
|
+
`--apply` creates a recovery checkpoint first. See [docs/installation.md](docs/installation.md)
|
|
96
|
+
for scanner installation and upgrades, and [docs/commands.md](docs/commands.md) for all commands.
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
- [Installation and upgrades](docs/installation.md)
|
|
101
|
+
- [Commands](docs/commands.md)
|
|
102
|
+
- [Scanners, severity and privacy](docs/scanners.md)
|
|
103
|
+
- [Integrations: Claude Code, GitHub, VS Code, other agents](docs/integrations.md)
|
|
104
|
+
- [Architecture](docs/architecture.md)
|
|
105
|
+
- [Security model](docs/security-model.md) and [global policy](policies/GLOBAL_SECURITY_POLICY.md)
|
|
106
|
+
- [Dashboard design system](docs/dashboard-design-system.md)
|
|
107
|
+
- [Release process](docs/release.md)
|
|
108
|
+
|
|
109
|
+
## Safety guarantees
|
|
110
|
+
|
|
111
|
+
- No telemetry. The core never contacts the network; scanners' network use is documented and
|
|
112
|
+
OSV-Scanner (which sends package names/versions to osv.dev) runs only with explicit approval.
|
|
113
|
+
- UDS never installs scanners, never commits, never pushes and never publishes.
|
|
114
|
+
- Existing configuration is merged, never silently overwritten; invalid files are left untouched.
|
|
115
|
+
- Secret values, matched text and raw scanner output never appear in output, reports or history.
|
|
116
|
+
- Global policies cannot be weakened by project configuration or AI tools.
|
|
117
|
+
|
|
118
|
+
Local Git hooks and AI-agent hooks are defense in depth — keep CI scanning and
|
|
119
|
+
repository-level protections (for example GitHub push protection) enabled too.
|
|
120
|
+
|
|
121
|
+
## Contributing and security
|
|
122
|
+
|
|
123
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
|
124
|
+
Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
Apache License 2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
universal_dev_security/__init__.py,sha256=b5Kl-jI1dq6h4flmJzGOz-7yvG4HNBmMvsRdoJLlp3I,101
|
|
2
|
+
universal_dev_security/__main__.py,sha256=RQ8MzzL5ITUXhnOLEODb1QGzdvgXiiXcv05Dn5G7FSk,120
|
|
3
|
+
universal_dev_security/cli.py,sha256=xCYUtrLp8fH-de6WurD0h8V8gxlNyk2kz_byes_rrtc,17011
|
|
4
|
+
universal_dev_security/cli_extra.py,sha256=XpuMUkjNlS0UQNVCwIiVBBddCTh-3q6x6p6kvlz6mQY,10871
|
|
5
|
+
universal_dev_security/config.py,sha256=vqwKZKKR_we4oohjfZMHwgP0W4dtSYZoH0POCYwo8TU,7992
|
|
6
|
+
universal_dev_security/detector.py,sha256=KnBbTJFX2uthr0d8dl-wepckvTk7K-njNwBzZgIGpwo,10103
|
|
7
|
+
universal_dev_security/engine.py,sha256=Dz5UJjxmoZh_SEEjWkNjQcAeUjdpcoUrnWvta4UYWv4,8973
|
|
8
|
+
universal_dev_security/findings.py,sha256=8-RrA0U8p0j40ZCxOutlunoPLmCl2auayiDshH5zQfI,6736
|
|
9
|
+
universal_dev_security/fixes.py,sha256=ha1_NmteDt3kNOtg9CUwcuPY3gsTyajX6tG2ugzzpJA,6214
|
|
10
|
+
universal_dev_security/gitguard.py,sha256=2kuQBQ5rqpCqfNTs7KxBAxDKPToRmOSlUBOPYe6taDI,11263
|
|
11
|
+
universal_dev_security/history.py,sha256=QkPHIMY9dP-bcnuVscIhp2dvVj12Hm6QNgEYtQPIJT4,6084
|
|
12
|
+
universal_dev_security/initializer.py,sha256=npH_3USo2jhkfWMO65XDWPYZsW0-CKLVoj6-MEh_YsY,4259
|
|
13
|
+
universal_dev_security/policy.py,sha256=9ItX9ANR6vT4o5C8edorjz4TbsQhiQWco-4MqfRrDWE,20018
|
|
14
|
+
universal_dev_security/projectfiles.py,sha256=WYrg5coVY6y3ZaDTa0PxwGcIiS-Toal4kejhA3nUIYc,2983
|
|
15
|
+
universal_dev_security/recovery.py,sha256=C-O2_xNfZoTTrWFnA_7bDiWiXaETyCHuh3c7xlwPmAc,7234
|
|
16
|
+
universal_dev_security/redaction.py,sha256=TkwrwM7YQF2PGJ7N6PcWXzx2cQTg6hLA-ylz7NLJ6wg,5595
|
|
17
|
+
universal_dev_security/reports.py,sha256=-H38BMyaLTK9VY-wh5hjUKgeMtmB4gATt6MMqINZG-Y,31640
|
|
18
|
+
universal_dev_security/scanner.py,sha256=IbasoQc49eIjKgS1SSiNrgPcbWUXbNgpgGGM5Tf2UV0,1308
|
|
19
|
+
universal_dev_security/testing.py,sha256=dWeCEhgkqODzHmE0eCRBXWgA2xi24MP8mMkARaF0C_g,4804
|
|
20
|
+
universal_dev_security/updates.py,sha256=UqRWAl4Km8XNxnnXKxa2a-LH-cuqfQKc6KB5lRbBkUg,4190
|
|
21
|
+
universal_dev_security/integrations/__init__.py,sha256=cp0s3KYhIpk5w7bYmGaW8ZPp61aneBivGEDGbwZrcDY,2013
|
|
22
|
+
universal_dev_security/integrations/agents.py,sha256=bCom-QgbACFT-PEErxb1VCzQc2D5EvGlRB4mVnQoVTs,1869
|
|
23
|
+
universal_dev_security/integrations/claude_code.py,sha256=-0hPYrayogCTRdtYw1g71EudrpxViW_9iexS4f4_ipA,9369
|
|
24
|
+
universal_dev_security/integrations/github.py,sha256=3kHUqF5-smbSgwE4UyoeLSGAujUB09qFVJCbRNyN-IQ,6500
|
|
25
|
+
universal_dev_security/integrations/vscode.py,sha256=JAreeGLGglQg7J1MdpMpH--KGDji1t4W-92nA55Ycb8,2076
|
|
26
|
+
universal_dev_security/scanners/__init__.py,sha256=DYc2eNyhj3g-8zebajxc7sbOgVHzGfc_e8moJYlsDDI,1268
|
|
27
|
+
universal_dev_security/scanners/base.py,sha256=Zms8fwESZEOJueLDrm648TexcFQcUI1mBzEwyP9Z4Lo,11186
|
|
28
|
+
universal_dev_security/scanners/gitleaks.py,sha256=WYMiGy4hO3exQF5YbdE76vTTyVqgSPXPg-hz42Noynw,6211
|
|
29
|
+
universal_dev_security/scanners/osv.py,sha256=WbDzC4Z73PwmElNaeLs2hZxGDFnPMnP3gkHkjEazp78,7852
|
|
30
|
+
universal_dev_security/scanners/semgrep.py,sha256=PpSxUe8dz222EK3mCzUR8zBTbjL8QHJ_-Y2DTB4Ca_A,5959
|
|
31
|
+
universal_dev_security/scanners/trivy.py,sha256=ii4dMDiuw2v6taw90Gr1aOZb_T-iwMInIadwkWoiqg0,6685
|
|
32
|
+
universal_dev_security-1.0.0.dist-info/licenses/LICENSE,sha256=ReLrsC1BngysN-NSvsmNb5WaZ_1UOweZEZne3VkEzFY,9156
|
|
33
|
+
universal_dev_security-1.0.0.dist-info/METADATA,sha256=383DW1f9ULS0X1W0WGH_y5XGYowNbrIUgwu-_N43hjs,6531
|
|
34
|
+
universal_dev_security-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
35
|
+
universal_dev_security-1.0.0.dist-info/entry_points.txt,sha256=3KgyciEul9-L9tn_V8fAIf7R8qtJdbyg77ApiwO2iVs,56
|
|
36
|
+
universal_dev_security-1.0.0.dist-info/top_level.txt,sha256=FOgKdp-7KYU_0VL00cer496y6qMWuSSy3b8RjzFyv3Y,23
|
|
37
|
+
universal_dev_security-1.0.0.dist-info/RECORD,,
|