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