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,149 @@
1
+ """GitHub integration: a pull-request security workflow for user projects.
2
+
3
+ The generated workflow:
4
+ * runs on pull requests, pushes to the default branch and manual dispatch;
5
+ * uses least-privilege permissions (contents: read; security-events: write only
6
+ for SARIF upload);
7
+ * installs scanners from official release assets with SHA-256 verification;
8
+ * runs `uds scan` and uploads SARIF (code scanning) and the HTML/JSON reports;
9
+ * never pushes, publishes or comments with write tokens.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from universal_dev_security.integrations import apply_changes
18
+
19
+ WORKFLOW_PATH = ".github/workflows/uds-security.yml"
20
+ DEFAULT_GITLEAKS_VERSION = "8.30.1"
21
+ DEFAULT_OSV_VERSION = "2.7.1"
22
+ DEFAULT_TRIVY_VERSION = "0.76.2"
23
+ _VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
24
+ _SPEC_RE = re.compile(r"^[A-Za-z0-9_.\-\[\]=<>~!@:/+#, ]{1,300}$")
25
+
26
+ TEMPLATE = """# Generated by Universal Dev Security (UDS). Review before committing.
27
+ # Scanner versions are pinned and release assets are verified with SHA-256.
28
+ name: UDS security scan
29
+
30
+ on:
31
+ pull_request:
32
+ push:
33
+ branches: [__BRANCH__]
34
+ workflow_dispatch:
35
+
36
+ permissions:
37
+ contents: read
38
+
39
+ concurrency:
40
+ group: uds-${{ github.workflow }}-${{ github.ref }}
41
+ cancel-in-progress: true
42
+
43
+ jobs:
44
+ uds-scan:
45
+ name: UDS security scan
46
+ runs-on: ubuntu-latest
47
+ timeout-minutes: 30
48
+ permissions:
49
+ contents: read
50
+ security-events: write # upload SARIF to GitHub code scanning
51
+ env:
52
+ GITLEAKS_VERSION: "__GITLEAKS__"
53
+ OSV_SCANNER_VERSION: "__OSV__"
54
+ TRIVY_VERSION: "__TRIVY__"
55
+ steps:
56
+ - name: Check out repository
57
+ uses: actions/checkout@v4
58
+ with:
59
+ fetch-depth: 0
60
+ persist-credentials: false
61
+
62
+ - name: Set up Python
63
+ uses: actions/setup-python@v5
64
+ with:
65
+ python-version: "3.12"
66
+
67
+ - name: Install UDS and Semgrep
68
+ run: |
69
+ python -m pip install --upgrade pip
70
+ python -m pip install "__UDS_SPEC__" semgrep
71
+
72
+ - name: Install scanners (official releases, checksum verified)
73
+ run: |
74
+ set -euo pipefail
75
+ mkdir -p "$HOME/.local/bin" /tmp/uds-tools && cd /tmp/uds-tools
76
+ base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
77
+ curl -sSfLO "$base/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
78
+ curl -sSfLO "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
79
+ grep " gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz\\$" "gitleaks_${GITLEAKS_VERSION}_checksums.txt" | sha256sum -c -
80
+ tar -xzf "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" gitleaks
81
+ install -m 0755 gitleaks "$HOME/.local/bin/gitleaks"
82
+ base="https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}"
83
+ curl -sSfLO "$base/osv-scanner_linux_amd64"
84
+ curl -sSfLO "$base/osv-scanner_SHA256SUMS"
85
+ grep " osv-scanner_linux_amd64\\$" osv-scanner_SHA256SUMS | sha256sum -c -
86
+ install -m 0755 osv-scanner_linux_amd64 "$HOME/.local/bin/osv-scanner"
87
+ base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}"
88
+ curl -sSfLO "$base/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
89
+ curl -sSfLO "$base/trivy_${TRIVY_VERSION}_checksums.txt"
90
+ grep " trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz\\$" "trivy_${TRIVY_VERSION}_checksums.txt" | sha256sum -c -
91
+ tar -xzf "trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" trivy
92
+ install -m 0755 trivy "$HOME/.local/bin/trivy"
93
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
94
+
95
+ - name: Run UDS scan
96
+ # --allow-network lets OSV-Scanner send package names/versions (not source code) to osv.dev.
97
+ run: >-
98
+ uds scan . --no-history --allow-network --require-complete
99
+ --format json,html,sarif --report-dir uds-reports --fail-on __FAIL_ON__
100
+
101
+ - name: Upload SARIF to code scanning
102
+ if: always() && hashFiles('uds-reports/security-report.sarif') != ''
103
+ uses: github/codeql-action/upload-sarif@v3
104
+ with:
105
+ sarif_file: uds-reports/security-report.sarif
106
+ category: uds
107
+
108
+ - name: Upload UDS reports
109
+ if: always()
110
+ uses: actions/upload-artifact@v4
111
+ with:
112
+ name: uds-security-report
113
+ path: uds-reports/
114
+ retention-days: 14
115
+ if-no-files-found: ignore
116
+ """
117
+
118
+
119
+ def render_workflow(*, branch: str = "main", fail_on: str = "high", uds_spec: str = "universal-dev-security",
120
+ gitleaks: str = DEFAULT_GITLEAKS_VERSION, osv: str = DEFAULT_OSV_VERSION,
121
+ trivy: str = DEFAULT_TRIVY_VERSION) -> str:
122
+ if not re.match(r"^[A-Za-z0-9._/-]{1,100}$", branch):
123
+ raise ValueError("Invalid branch name")
124
+ if fail_on not in ("none", "unclassified", "low", "medium", "high", "critical"):
125
+ raise ValueError("Invalid fail-on level")
126
+ if not _SPEC_RE.match(uds_spec) or '"' in uds_spec:
127
+ raise ValueError("Invalid UDS install specification")
128
+ for value in (gitleaks, osv, trivy):
129
+ if not _VERSION_RE.match(value):
130
+ raise ValueError(f"Invalid scanner version: {value!r}")
131
+ return (TEMPLATE.replace("__BRANCH__", branch).replace("__FAIL_ON__", fail_on).replace("__UDS_SPEC__", uds_spec)
132
+ .replace("__GITLEAKS__", gitleaks).replace("__OSV__", osv).replace("__TRIVY__", trivy))
133
+
134
+
135
+ def install(root: Path, *, apply: bool = False, remove: bool = False, **options: Any) -> dict[str, Any]:
136
+ root = root.expanduser().resolve()
137
+ path = root / WORKFLOW_PATH
138
+ if path.is_file() and "Generated by Universal Dev Security" not in path.read_text(encoding="utf-8", errors="replace"):
139
+ raise ValueError(f"{WORKFLOW_PATH} exists and was not generated by UDS; refusing to overwrite it.")
140
+ content = None if remove else render_workflow(**options)
141
+ result = apply_changes(root, {WORKFLOW_PATH: content}, "github workflow " + ("remove" if remove else "install"), apply)
142
+ result["workflow"] = WORKFLOW_PATH
143
+ result["notes"] = [
144
+ "Review the workflow, then commit it yourself — UDS never commits or pushes.",
145
+ "Until UDS is published on PyPI, set --uds-spec to a pinned Git URL or wheel location.",
146
+ "Enable GitHub secret scanning and push protection in the repository settings as an independent control.",
147
+ "Consider pinning third-party actions to full commit SHAs.",
148
+ ]
149
+ return result
@@ -0,0 +1,44 @@
1
+ """VS Code integration: tasks for scanning and opening the dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from universal_dev_security.integrations import apply_changes
9
+
10
+ TASKS_PATH = ".vscode/tasks.json"
11
+ LABEL_PREFIX = "UDS: "
12
+
13
+ UDS_TASKS = [
14
+ {"label": "UDS: Security scan", "type": "process", "command": "uds", "args": ["scan", "${workspaceFolder}"],
15
+ "group": "test", "problemMatcher": []},
16
+ {"label": "UDS: Open dashboard", "type": "process", "command": "uds",
17
+ "args": ["dashboard", "${workspaceFolder}", "--open"], "problemMatcher": []},
18
+ {"label": "UDS: Doctor", "type": "process", "command": "uds", "args": ["doctor", "${workspaceFolder}"],
19
+ "problemMatcher": []},
20
+ ]
21
+
22
+
23
+ def install(root: Path, *, apply: bool = False, remove: bool = False) -> dict[str, Any]:
24
+ root = root.expanduser().resolve()
25
+ path = root / TASKS_PATH
26
+ data: dict[str, Any] = {"version": "2.0.0", "tasks": []}
27
+ if path.is_file():
28
+ try:
29
+ data = json.loads(path.read_text(encoding="utf-8"))
30
+ except ValueError as exc:
31
+ # tasks.json may contain comments (JSONC); never rewrite what we cannot parse faithfully.
32
+ raise ValueError(f"{TASKS_PATH} is not plain JSON (it may contain comments); refusing to modify it. "
33
+ "Add the UDS tasks manually (see docs/integrations.md).") from exc
34
+ if not isinstance(data, dict) or not isinstance(data.get("tasks", []), list):
35
+ raise ValueError(f"{TASKS_PATH} has an unexpected structure; refusing to modify it.")
36
+ tasks = [t for t in data.get("tasks", []) if not (isinstance(t, dict) and str(t.get("label", "")).startswith(LABEL_PREFIX))]
37
+ if not remove:
38
+ tasks.extend(UDS_TASKS)
39
+ data["tasks"] = tasks
40
+ data.setdefault("version", "2.0.0")
41
+ content = json.dumps(data, indent=2) + "\n"
42
+ result = apply_changes(root, {TASKS_PATH: content}, "vscode tasks " + ("remove" if remove else "install"), apply)
43
+ result["tasks_file"] = TASKS_PATH
44
+ return result
@@ -0,0 +1,384 @@
1
+ """Central UDS security policy engine.
2
+
3
+ Every integration (CLI, Git hooks, Claude Code hooks, generic AI agents)
4
+ describes what it is about to do as an :class:`Action`. The engine evaluates it
5
+ against the immutable global rules plus any *additional* restrictions from the
6
+ project configuration and returns a :class:`PolicyDecision`.
7
+
8
+ Precedence: mandatory global policy > technology profile > project config.
9
+ Project configuration can add deny/approval rules and protected paths, but it
10
+ cannot remove or downgrade a global rule.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Any, Callable
20
+
21
+ from universal_dev_security.config import ProjectConfig
22
+ from universal_dev_security.redaction import find_secrets
23
+
24
+
25
+ class Decision(str, Enum):
26
+ ALLOW = "allow"
27
+ ASK = "ask" # explicit user approval required
28
+ DENY = "deny"
29
+
30
+ @property
31
+ def rank(self) -> int:
32
+ return {"allow": 0, "ask": 1, "deny": 2}[self.value]
33
+
34
+
35
+ class Risk(str, Enum):
36
+ LOW = "low"
37
+ MEDIUM = "medium"
38
+ HIGH = "high"
39
+ CRITICAL = "critical"
40
+
41
+ @property
42
+ def rank(self) -> int:
43
+ return {"low": 0, "medium": 1, "high": 2, "critical": 3}[self.value]
44
+
45
+
46
+ class ActionKind(str, Enum):
47
+ SHELL = "shell"
48
+ WRITE_FILE = "write_file"
49
+ EDIT_FILE = "edit_file"
50
+ READ_FILE = "read_file"
51
+ DELETE_FILE = "delete_file"
52
+ GIT_COMMIT = "git_commit"
53
+ GIT_PUSH = "git_push"
54
+ PUBLISH = "publish"
55
+
56
+
57
+ @dataclass
58
+ class Action:
59
+ kind: ActionKind
60
+ command: str = ""
61
+ path: str = ""
62
+ content: str = ""
63
+ actor: str = "ai" # "ai", "user", "ci", "hook"
64
+ project_root: str = ""
65
+
66
+ def __post_init__(self) -> None:
67
+ self.kind = ActionKind(self.kind)
68
+ # Bound the amount of text inspected to keep evaluation fast.
69
+ self.command = (self.command or "")[:100_000]
70
+ self.content = (self.content or "")[:2_000_000]
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: dict[str, Any]) -> "Action":
74
+ if not isinstance(data, dict):
75
+ raise ValueError("Action must be a JSON object")
76
+ kind = data.get("action") or data.get("kind")
77
+ try:
78
+ action_kind = ActionKind(kind)
79
+ except ValueError as exc:
80
+ raise ValueError(f"Unknown action kind: {kind!r}") from exc
81
+ return cls(
82
+ kind=action_kind,
83
+ command=str(data.get("command", "") or ""),
84
+ path=str(data.get("path", "") or ""),
85
+ content=str(data.get("content", "") or ""),
86
+ actor=str(data.get("actor", "ai") or "ai"),
87
+ project_root=str(data.get("project_root", "") or ""),
88
+ )
89
+
90
+
91
+ @dataclass
92
+ class RuleMatch:
93
+ rule_id: str
94
+ decision: Decision
95
+ risk: Risk
96
+ reason: str
97
+ source: str = "global" # "global" or "project"
98
+
99
+ def to_dict(self) -> dict[str, str]:
100
+ return {
101
+ "rule_id": self.rule_id,
102
+ "decision": self.decision.value,
103
+ "risk": self.risk.value,
104
+ "reason": self.reason,
105
+ "source": self.source,
106
+ }
107
+
108
+
109
+ @dataclass
110
+ class PolicyDecision:
111
+ decision: Decision
112
+ risk: Risk
113
+ matches: list[RuleMatch] = field(default_factory=list)
114
+ warnings: list[str] = field(default_factory=list)
115
+
116
+ @property
117
+ def reason(self) -> str:
118
+ if not self.matches:
119
+ return "No UDS policy rule matched."
120
+ relevant = [m for m in self.matches if m.decision is self.decision] or self.matches
121
+ return "; ".join(f"[{m.rule_id}] {m.reason}" for m in relevant)
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ return {
125
+ "decision": self.decision.value,
126
+ "risk": self.risk.value,
127
+ "reason": self.reason,
128
+ "matches": [match.to_dict() for match in self.matches],
129
+ "warnings": self.warnings,
130
+ }
131
+
132
+
133
+ # --------------------------------------------------------------------------
134
+ # Shell command rules
135
+ # --------------------------------------------------------------------------
136
+
137
+ MAX_ANALYZED_COMMAND_CHARS = 20_000
138
+
139
+ _GIT = r"\bgit(?:\.exe)?(?:\s+(?:-C\s+\S+|-c\s+\S+|--[\w-]+(?:=\S+)?))*\s+"
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class CommandRule:
144
+ rule_id: str
145
+ pattern: re.Pattern[str]
146
+ decision: Decision
147
+ risk: Risk
148
+ reason: str
149
+
150
+
151
+ def _rule(rule_id: str, pattern: str, decision: Decision, risk: Risk, reason: str) -> CommandRule:
152
+ return CommandRule(rule_id, re.compile(pattern, re.IGNORECASE), decision, risk, reason)
153
+
154
+
155
+ GLOBAL_COMMAND_RULES: tuple[CommandRule, ...] = (
156
+ _rule("git-hook-bypass", _GIT + r"(?:(?:commit|merge|am)\b.{0,1000}(?:--no-verify|\s-[a-z]*n[a-z]*\b(?!\s*=))|(?:push|rebase)\b.{0,1000}--no-verify)",
157
+ Decision.DENY, Risk.CRITICAL, "Bypassing Git hooks disables UDS protections."),
158
+ _rule("git-hooks-path-change", _GIT + r"config\b.{0,1000}\bcore\.hooksPath\b",
159
+ Decision.ASK, Risk.HIGH, "Changing core.hooksPath can disable UDS Git protection."),
160
+ _rule("git-force-push", _GIT + r"push\b.{0,1000}(?:--force\b|--force-with-lease\b|--force-if-includes\b|\s-f\b|\s\+\S+)",
161
+ Decision.ASK, Risk.CRITICAL, "Force push rewrites remote history and requires explicit approval."),
162
+ _rule("git-push", _GIT + r"push\b",
163
+ Decision.ASK, Risk.HIGH, "Git push always requires explicit user approval."),
164
+ _rule("git-commit", _GIT + r"commit\b",
165
+ Decision.ASK, Risk.MEDIUM, "Commits require explicit user approval (staged content should be scanned)."),
166
+ _rule("git-history-rewrite", _GIT + r"(?:filter-branch|filter-repo|replace)\b|" + _GIT + r"rebase\b|" + _GIT + r"reset\s+--hard\b",
167
+ Decision.ASK, Risk.HIGH, "History rewrites and hard resets can destroy work."),
168
+ _rule("git-discard-changes", _GIT + r"(?:clean\s+-[a-z]*f|checkout\s+(?:--\s+)?\.(?:\s|$)|restore\s+(?:--\S+\s+)*\.(?:\s|$)|stash\s+(?:drop|clear)\b|branch\s+-D\b)",
169
+ Decision.ASK, Risk.HIGH, "This Git command discards uncommitted work or branches."),
170
+ _rule("git-global-config", _GIT + r"config\s+(?:--global|--system)\b",
171
+ Decision.ASK, Risk.HIGH, "Global Git configuration changes affect every project on this machine."),
172
+ _rule("delete-root", r"\brm\s+(?:-[a-z]*\s+)*(?:/|~|\$HOME|/\*|[a-z]:\\?)\s*$|\bRemove-Item\b.{0,1000}\s(?:[a-z]:\\|~)\s*(?:-|$)|\bformat\s+[a-z]:|\bmkfs(?:\.\w+)?\b|\bdd\s+if=",
173
+ Decision.DENY, Risk.CRITICAL, "Command could destroy a whole drive or home directory."),
174
+ _rule("recursive-delete", r"\brm\s+(?:-[a-z]*r[a-z]*|-[a-z]*R[a-z]*|--recursive)\b|\bRemove-Item\b.{0,1000}-Recurse|\b(?:rd|rmdir)\s+/s\b|\bdel\s+(?:/[a-z]\s+)*/s\b|\bshutil\.rmtree\b",
175
+ Decision.ASK, Risk.HIGH, "Recursive deletion requires explicit approval."),
176
+ _rule("remote-code-execution", r"\b(?:curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|]{0,1000}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|python\d*|iex|Invoke-Expression|pwsh|powershell)\b|\biex\s*\(.{0,1000}DownloadString|Invoke-Expression\s*\(.{0,1000}(?:DownloadString|Invoke-WebRequest|irm|iwr)",
177
+ Decision.ASK, Risk.CRITICAL, "Piping downloaded content into an interpreter executes unreviewed code."),
178
+ _rule("system-security-settings", r"\bSet-MpPreference\b|\bAdd-MpPreference\b.{0,1000}-Exclusion|\bnetsh\s+(?:adv)?firewall\b.{0,1000}\b(?:off|disable)\b|\bSet-NetFirewallProfile\b.{0,1000}-Enabled\s+False|\bSet-ExecutionPolicy\b.{0,1000}\b(?:Unrestricted|Bypass)\b.{0,1000}-Scope\s+LocalMachine|\bbcdedit\b|\bsetenforce\s+0\b|\bufw\s+disable\b|\bspctl\s+--master-disable\b|\bcsrutil\s+disable\b",
179
+ Decision.DENY, Risk.CRITICAL, "Changing machine security settings is outside the UDS authorization boundary."),
180
+ _rule("system-wide-install", r"\b(?:npm|pnpm|yarn)\s+(?:install|i|add)\s+(?:.{0,1000}\s)?(?:-g|--global)\b|\b(?:winget|choco|scoop|brew|apt|apt-get|dnf|yum|pacman|zypper|snap)\s+install\b|\bsudo\b|\bpip3?\s+install\b.{0,1000}--user\b|\bmsiexec\b",
181
+ Decision.ASK, Risk.HIGH, "System-wide or privileged installation requires approval."),
182
+ _rule("publish", r"\b(?:npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b|\b(?:poetry|uv|flit|hatch)\s+publish\b|\bcargo\s+publish\b|\bgem\s+push\b|\bdotnet\s+nuget\s+push\b|\bdocker\s+push\b|\bgh\s+release\s+create\b|\bgh\s+repo\s+create\b|\bgh\s+pr\s+(?:create|merge)\b|\bmvn\b.{0,1000}\bdeploy\b|\bcomposer\s+publish\b",
183
+ Decision.ASK, Risk.HIGH, "Publishing or uploading requires explicit approval."),
184
+ _rule("secret-file-access", r"(?:^|[\s\"'=<>/\\])(?:\.env(?!\.(?:example|sample|template|dist)\b)(?:\.[\w.-]+)?|id_rsa|id_ed25519|id_ecdsa|[\w.-]+\.pem|[\w.-]+\.p12|[\w.-]+\.pfx|[\w.-]+\.key|credentials(?:\.json)?|\.npmrc|\.pypirc|\.netrc)(?=$|[\s\"'|;&>)])",
185
+ Decision.ASK, Risk.MEDIUM, "Command touches a file that commonly contains credentials."),
186
+ _rule("security-control-modification", r"(?:^|[\s\"'=/\\])(?:\.uds[/\\]|\.claude[/\\]settings(?:\.local)?\.json|\.git[/\\]hooks\b|\.gitleaks\.toml|\.gitleaksignore)",
187
+ Decision.ASK, Risk.HIGH, "Command touches UDS, Claude Code, or Git hook security configuration."),
188
+ _rule("uds-protection-removal", r"\buds(?:\.exe)?\s+(?:hooks\s+uninstall|integrate\s+\S+\s+--remove|rollback)\b",
189
+ Decision.ASK, Risk.HIGH, "Removing UDS protections or rolling back requires explicit approval."),
190
+ _rule("world-writable-permissions", r"\bchmod\s+(?:-R\s+)?(?:0?777|a\+w|o\+w)\b|\bicacls\b.{0,1000}\bEveryone:\(?F",
191
+ Decision.ASK, Risk.MEDIUM, "Granting world-writable permissions weakens file security."),
192
+ )
193
+
194
+ # --------------------------------------------------------------------------
195
+ # File rules
196
+ # --------------------------------------------------------------------------
197
+
198
+ SECRET_FILE_GLOBS = (
199
+ ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_rsa*", "id_ed25519*", "id_ecdsa*",
200
+ ".npmrc", ".pypirc", ".netrc", "credentials", "credentials.json", "*.keystore", "*.jks",
201
+ )
202
+ SECRET_FILE_ALLOW = (".env.example", ".env.sample", ".env.template", ".env.dist")
203
+
204
+ SECURITY_CONTROL_GLOBS = (
205
+ ".uds/*", ".uds/**", ".claude/settings.json", ".claude/settings.local.json", ".git/hooks/*",
206
+ ".git/config", ".gitleaks.toml", ".gitleaksignore", ".semgrepignore", ".trivyignore",
207
+ )
208
+ CI_GLOBS = (".github/workflows/*", ".github/workflows/**", ".gitlab-ci.yml", "azure-pipelines.yml")
209
+
210
+
211
+ def _relative_posix(path: str, project_root: str) -> tuple[str, bool]:
212
+ """Return (relative posix path, inside_project)."""
213
+ if not path:
214
+ return "", True
215
+ candidate = Path(path)
216
+ if project_root:
217
+ root = Path(project_root).expanduser().resolve()
218
+ absolute = candidate if candidate.is_absolute() else root / candidate
219
+ try:
220
+ resolved = absolute.resolve()
221
+ except (OSError, RuntimeError):
222
+ return path.replace("\\", "/"), False
223
+ try:
224
+ return resolved.relative_to(root).as_posix(), True
225
+ except ValueError:
226
+ return resolved.as_posix(), False
227
+ normalized = path.replace("\\", "/")
228
+ inside = not candidate.is_absolute() and ".." not in PurePosixPath(normalized).parts
229
+ return normalized.lstrip("./") if normalized.startswith("./") else normalized, inside
230
+
231
+
232
+ def _matches_any(relative: str, globs: tuple[str, ...] | list[str]) -> bool:
233
+ name = PurePosixPath(relative).name
234
+ lowered = relative.lower()
235
+ for glob in globs:
236
+ glob_l = glob.lower()
237
+ if fnmatch.fnmatchcase(lowered, glob_l) or fnmatch.fnmatchcase(name.lower(), glob_l):
238
+ return True
239
+ if glob_l.endswith("/**") and (lowered + "/").startswith(glob_l[:-2]):
240
+ return True
241
+ return False
242
+
243
+
244
+ # --------------------------------------------------------------------------
245
+ # Engine
246
+ # --------------------------------------------------------------------------
247
+
248
+ class PolicyEngine:
249
+ def __init__(self, config: ProjectConfig | None = None) -> None:
250
+ self.config = config or ProjectConfig()
251
+ self._project_command_rules: list[CommandRule] = []
252
+ self.warnings: list[str] = list(self.config.warnings)
253
+ for pattern in self.config.deny_command_patterns:
254
+ self._add_project_rule("project-deny", pattern, Decision.DENY, Risk.HIGH)
255
+ for pattern in self.config.approval_command_patterns:
256
+ self._add_project_rule("project-approval", pattern, Decision.ASK, Risk.MEDIUM)
257
+
258
+ def _add_project_rule(self, rule_id: str, pattern: str, decision: Decision, risk: Risk) -> None:
259
+ try:
260
+ compiled = re.compile(pattern, re.IGNORECASE)
261
+ except re.error:
262
+ self.warnings.append(f"Invalid project command pattern ignored: {pattern!r}")
263
+ return
264
+ self._project_command_rules.append(
265
+ CommandRule(rule_id, compiled, decision, risk, f"Project policy matched pattern {pattern!r}.")
266
+ )
267
+
268
+ # -- public API -----------------------------------------------------
269
+
270
+ def evaluate(self, action: Action) -> PolicyDecision:
271
+ handlers: dict[ActionKind, Callable[[Action], list[RuleMatch]]] = {
272
+ ActionKind.SHELL: self._evaluate_shell,
273
+ ActionKind.WRITE_FILE: self._evaluate_write,
274
+ ActionKind.EDIT_FILE: self._evaluate_write,
275
+ ActionKind.READ_FILE: self._evaluate_read,
276
+ ActionKind.DELETE_FILE: self._evaluate_delete,
277
+ ActionKind.GIT_COMMIT: lambda a: [RuleMatch("git-commit", Decision.ASK, Risk.MEDIUM,
278
+ "Commits require explicit user approval.")],
279
+ ActionKind.GIT_PUSH: lambda a: [RuleMatch("git-push", Decision.ASK, Risk.HIGH,
280
+ "Git push always requires explicit user approval.")],
281
+ ActionKind.PUBLISH: lambda a: [RuleMatch("publish", Decision.ASK, Risk.HIGH,
282
+ "Publishing requires explicit approval.")],
283
+ }
284
+ matches = handlers[action.kind](action)
285
+ return self._combine(matches)
286
+
287
+ def _combine(self, matches: list[RuleMatch]) -> PolicyDecision:
288
+ if not matches:
289
+ return PolicyDecision(Decision.ALLOW, Risk.LOW, [], list(self.warnings))
290
+ if self.config.security_mode == "strict":
291
+ for match in matches:
292
+ if match.risk is Risk.CRITICAL and match.decision is Decision.ASK:
293
+ match.decision = Decision.DENY
294
+ match.reason += " (strict mode: denied)"
295
+ decision = max((m.decision for m in matches), key=lambda d: d.rank)
296
+ risk = max((m.risk for m in matches), key=lambda r: r.rank)
297
+ return PolicyDecision(decision, risk, matches, list(self.warnings))
298
+
299
+ # -- shell ----------------------------------------------------------
300
+
301
+ def _evaluate_shell(self, action: Action) -> list[RuleMatch]:
302
+ command = action.command
303
+ matches: list[RuleMatch] = []
304
+ seen: set[str] = set()
305
+ if len(command) > MAX_ANALYZED_COMMAND_CHARS:
306
+ # Too long to analyze reliably and cheaply: never allow silently.
307
+ return [RuleMatch("command-too-long", Decision.ASK, Risk.HIGH,
308
+ f"Command exceeds {MAX_ANALYZED_COMMAND_CHARS} characters and was not analyzed.")]
309
+ parts = [s for s in re.split(r"\r?\n|;|&&|\|\||(?<!\|)&(?!&)", command) if s.strip()]
310
+ segments = parts if len(parts) > 1 else [command]
311
+ if len(parts) > 1 and "\n" in command:
312
+ segments.append(command) # keep cross-line context (e.g. heredocs piped to an interpreter)
313
+ for segment in segments:
314
+ normalized = " ".join(segment.split())
315
+ rules = list(GLOBAL_COMMAND_RULES) + self._project_command_rules
316
+ for rule in rules:
317
+ if rule.rule_id in seen:
318
+ continue
319
+ if rule.pattern.search(normalized):
320
+ # A force push also matches the generic push rule; keep the stronger one.
321
+ if rule.rule_id == "git-push" and "git-force-push" in seen:
322
+ continue
323
+ seen.add(rule.rule_id)
324
+ source = "project" if rule.rule_id.startswith("project-") else "global"
325
+ matches.append(RuleMatch(rule.rule_id, rule.decision, rule.risk, rule.reason, source))
326
+ if find_secrets(command):
327
+ matches.append(RuleMatch("secret-in-command", Decision.ASK, Risk.HIGH,
328
+ "Command contains a likely credential; it would be stored in shell history and logs."))
329
+ for protected in self.config.protected_paths:
330
+ if protected and protected.replace("\\", "/").lower() in command.replace("\\", "/").lower():
331
+ matches.append(RuleMatch("project-protected-path", Decision.ASK, Risk.HIGH,
332
+ f"Command references protected path {protected!r}.", "project"))
333
+ break
334
+ return matches
335
+
336
+ # -- files ----------------------------------------------------------
337
+
338
+ def _path_matches(self, action: Action, *, write: bool) -> list[RuleMatch]:
339
+ relative, inside = _relative_posix(action.path, action.project_root)
340
+ matches: list[RuleMatch] = []
341
+ if not relative:
342
+ return matches
343
+ if not inside:
344
+ matches.append(RuleMatch("outside-project", Decision.ASK, Risk.HIGH,
345
+ "Path is outside the project directory."))
346
+ if _matches_any(relative, SECURITY_CONTROL_GLOBS):
347
+ matches.append(RuleMatch("security-control-modification" if write else "security-control-read",
348
+ Decision.ASK if write else Decision.ALLOW, Risk.HIGH if write else Risk.LOW,
349
+ "File is part of the UDS / Claude Code / Git hook security configuration."))
350
+ if _matches_any(relative, CI_GLOBS) and write:
351
+ matches.append(RuleMatch("ci-workflow-modification", Decision.ASK, Risk.MEDIUM,
352
+ "CI workflow changes can alter security checks and secret access."))
353
+ name = PurePosixPath(relative).name.lower()
354
+ if _matches_any(relative, SECRET_FILE_GLOBS) and name not in SECRET_FILE_ALLOW:
355
+ matches.append(RuleMatch("secret-file-access", Decision.ASK, Risk.MEDIUM,
356
+ "File commonly contains credentials."))
357
+ if self.config.protected_paths and _matches_any(relative, self.config.protected_paths):
358
+ matches.append(RuleMatch("project-protected-path", Decision.ASK, Risk.HIGH,
359
+ "Path is protected by project policy.", "project"))
360
+ return [m for m in matches if m.decision is not Decision.ALLOW]
361
+
362
+ def _evaluate_write(self, action: Action) -> list[RuleMatch]:
363
+ matches = self._path_matches(action, write=True)
364
+ secrets = find_secrets(action.content)
365
+ if secrets:
366
+ rules = sorted({s.rule_id for s in secrets})
367
+ lines = sorted({s.line for s in secrets})[:10]
368
+ matches.append(RuleMatch("secret-in-file", Decision.DENY, Risk.CRITICAL,
369
+ f"Content contains likely credential(s) ({', '.join(rules)}) at line(s) "
370
+ f"{', '.join(map(str, lines))}. Store secrets in environment variables or a "
371
+ "secret manager instead."))
372
+ return matches
373
+
374
+ def _evaluate_read(self, action: Action) -> list[RuleMatch]:
375
+ return self._path_matches(action, write=False)
376
+
377
+ def _evaluate_delete(self, action: Action) -> list[RuleMatch]:
378
+ matches = self._path_matches(action, write=True)
379
+ matches.append(RuleMatch("file-delete", Decision.ASK, Risk.MEDIUM, "Deleting files requires approval."))
380
+ return matches
381
+
382
+
383
+ def evaluate(action: Action, config: ProjectConfig | None = None) -> PolicyDecision:
384
+ return PolicyEngine(config).evaluate(action)
@@ -0,0 +1,80 @@
1
+ """Bounded, symlink-safe project file inventory used by detection and scanners."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ # Directories that are never project source and are expensive to walk.
9
+ DEFAULT_EXCLUDED_DIRS = frozenset({
10
+ ".git", ".hg", ".svn", ".uds", ".venv", "venv", "env", ".tox", ".nox", "__pycache__",
11
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", "node_modules", "bower_components",
12
+ ".next", ".nuxt", ".svelte-kit", "dist", "build", "out", "target", "bin", "obj",
13
+ ".gradle", ".idea", ".vs", ".vscode", "coverage", "htmlcov", ".terraform", ".cache",
14
+ "site-packages", ".eggs",
15
+ })
16
+
17
+
18
+ @dataclass
19
+ class ProjectInventory:
20
+ root: Path
21
+ files: list[str] = field(default_factory=list) # posix paths relative to root
22
+ truncated: bool = False
23
+
24
+ @property
25
+ def names(self) -> set[str]:
26
+ return {path.rsplit("/", 1)[-1] for path in self.files}
27
+
28
+ @property
29
+ def suffixes(self) -> set[str]:
30
+ result = set()
31
+ for path in self.files:
32
+ name = path.rsplit("/", 1)[-1]
33
+ if "." in name:
34
+ result.add("." + name.rsplit(".", 1)[-1].lower())
35
+ return result
36
+
37
+ def has_name(self, *names: str) -> bool:
38
+ wanted = {n.lower() for n in names}
39
+ return any(name.lower() in wanted for name in self.names)
40
+
41
+ def has_suffix(self, *suffixes: str) -> bool:
42
+ return bool(self.suffixes & {s.lower() for s in suffixes})
43
+
44
+ def find(self, name: str) -> list[str]:
45
+ lowered = name.lower()
46
+ return [p for p in self.files if p.rsplit("/", 1)[-1].lower() == lowered]
47
+
48
+ def has_path_part(self, part: str) -> bool:
49
+ needle = part.strip("/").lower()
50
+ return any(f"/{needle}/" in f"/{p.lower()}" for p in self.files)
51
+
52
+
53
+ def build_inventory(
54
+ root: Path,
55
+ *,
56
+ max_files: int = 50_000,
57
+ max_depth: int = 12,
58
+ extra_excludes: list[str] | None = None,
59
+ ) -> ProjectInventory:
60
+ """Walk ``root`` without following symlinks, skipping heavy directories."""
61
+ resolved = root.expanduser().resolve()
62
+ inventory = ProjectInventory(root=resolved)
63
+ excluded = set(DEFAULT_EXCLUDED_DIRS) | {e.strip("/\\") for e in (extra_excludes or []) if e}
64
+ base_depth = len(resolved.parts)
65
+ for current, dirs, files in os.walk(resolved, followlinks=False):
66
+ current_path = Path(current)
67
+ depth = len(current_path.parts) - base_depth
68
+ dirs[:] = sorted(
69
+ d for d in dirs
70
+ if d not in excluded and not (current_path / d).is_symlink() and depth < max_depth
71
+ )
72
+ for name in sorted(files):
73
+ relative = (current_path / name).relative_to(resolved).as_posix()
74
+ if relative in excluded:
75
+ continue
76
+ inventory.files.append(relative)
77
+ if len(inventory.files) >= max_files:
78
+ inventory.truncated = True
79
+ return inventory
80
+ return inventory