vmware-policy 1.4.0__tar.gz
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.
- vmware_policy-1.4.0/.coverage +0 -0
- vmware_policy-1.4.0/PKG-INFO +42 -0
- vmware_policy-1.4.0/README.md +27 -0
- vmware_policy-1.4.0/docs/version-strategy.md +54 -0
- vmware_policy-1.4.0/pyproject.toml +42 -0
- vmware_policy-1.4.0/tests/__init__.py +0 -0
- vmware_policy-1.4.0/tests/test_audit.py +107 -0
- vmware_policy-1.4.0/tests/test_decorators.py +147 -0
- vmware_policy-1.4.0/tests/test_policy.py +119 -0
- vmware_policy-1.4.0/tests/test_sanitize.py +33 -0
- vmware_policy-1.4.0/vmware_policy/__init__.py +8 -0
- vmware_policy-1.4.0/vmware_policy/audit.py +262 -0
- vmware_policy-1.4.0/vmware_policy/cli.py +119 -0
- vmware_policy-1.4.0/vmware_policy/decorators.py +165 -0
- vmware_policy-1.4.0/vmware_policy/policy.py +226 -0
- vmware_policy-1.4.0/vmware_policy/rules_default.yaml +30 -0
- vmware_policy-1.4.0/vmware_policy/sanitize.py +24 -0
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vmware-policy
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: Unified audit logging, policy enforcement, and sanitization for VMware MCP skills
|
|
5
|
+
Author: Wei Zhou / 周崴
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: rich<15.0,>=13.0
|
|
9
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-cov<8.0,>=5.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest<10.0,>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff<1.0,>=0.5; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# VMware Policy
|
|
17
|
+
|
|
18
|
+
Unified audit logging, policy enforcement, and sanitization for the VMware MCP skill family.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install vmware-policy
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from vmware_policy import vmware_tool
|
|
30
|
+
|
|
31
|
+
@vmware_tool(risk_level="high", sensitive_params=["password"])
|
|
32
|
+
def delete_segment(name: str, env: str = "") -> dict:
|
|
33
|
+
...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
vmware-audit log --last 20
|
|
40
|
+
vmware-audit log --status denied --since 2026-03-28
|
|
41
|
+
vmware-audit stats --days 7
|
|
42
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# VMware Policy
|
|
2
|
+
|
|
3
|
+
Unified audit logging, policy enforcement, and sanitization for the VMware MCP skill family.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vmware-policy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from vmware_policy import vmware_tool
|
|
15
|
+
|
|
16
|
+
@vmware_tool(risk_level="high", sensitive_params=["password"])
|
|
17
|
+
def delete_segment(name: str, env: str = "") -> dict:
|
|
18
|
+
...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## CLI
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
vmware-audit log --last 20
|
|
25
|
+
vmware-audit log --status denied --since 2026-03-28
|
|
26
|
+
vmware-audit stats --days 7
|
|
27
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# VMware Policy — Version Strategy
|
|
2
|
+
|
|
3
|
+
## Independent Versioning
|
|
4
|
+
|
|
5
|
+
vmware-policy uses its own version number, independent of the VMware skill family (currently v1.3.5).
|
|
6
|
+
|
|
7
|
+
**Rationale:** Policy is infrastructure-layer software with a different iteration cadence than business skills. Binding versions would force all 7 skills to release whenever policy gets a patch.
|
|
8
|
+
|
|
9
|
+
## Semantic Versioning (semver)
|
|
10
|
+
|
|
11
|
+
| Version bump | When | Example |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Patch (1.0.x) | Bug fixes, logging improvements, rule additions | 1.0.0 -> 1.0.1 |
|
|
14
|
+
| Minor (1.x.0) | New features (backward compatible): new decorator params, new CLI commands | 1.0.0 -> 1.1.0 |
|
|
15
|
+
| Major (x.0.0) | Breaking changes to decorator signature or audit.db schema | 1.0.0 -> 2.0.0 |
|
|
16
|
+
|
|
17
|
+
## Stability Guarantees (v1.x)
|
|
18
|
+
|
|
19
|
+
The following are **public API** and will NOT change in v1.x:
|
|
20
|
+
|
|
21
|
+
1. `@vmware_tool` decorator signature (all params remain optional, keyword-only)
|
|
22
|
+
2. `_is_vmware_tool` attribute on decorated functions
|
|
23
|
+
3. `_risk_level`, `_idempotent`, `_timeout_seconds`, `_sensitive_params` metadata attributes
|
|
24
|
+
4. `sanitize(text, max_len)` function signature
|
|
25
|
+
5. `audit.db` table schema (columns may be added, never removed or renamed)
|
|
26
|
+
6. `PolicyDenied` exception class
|
|
27
|
+
7. `vmware-audit` CLI command names (log, export, stats)
|
|
28
|
+
|
|
29
|
+
## Dependency Declaration
|
|
30
|
+
|
|
31
|
+
All skills should declare:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
"vmware-policy>=1.0.0,<2.0"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This allows any 1.x patch/minor update without requiring skill changes.
|
|
38
|
+
|
|
39
|
+
## Release Checklist
|
|
40
|
+
|
|
41
|
+
1. Update `vmware_policy/__init__.py` version
|
|
42
|
+
2. Update `pyproject.toml` version
|
|
43
|
+
3. Run `pytest tests/ -v` (all must pass)
|
|
44
|
+
4. Build: `python -m build`
|
|
45
|
+
5. Tag: `git tag v1.x.x`
|
|
46
|
+
6. Publish to PyPI: `uv publish`
|
|
47
|
+
|
|
48
|
+
## vmware-harness Versioning
|
|
49
|
+
|
|
50
|
+
vmware-harness also has its own version (starting at v0.1.0, pre-stable).
|
|
51
|
+
|
|
52
|
+
- v0.x: API may change without major version bump
|
|
53
|
+
- v1.0: First stable release (after production validation)
|
|
54
|
+
- Same semver rules as vmware-policy after v1.0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vmware-policy"
|
|
7
|
+
version = "1.4.0"
|
|
8
|
+
description = "Unified audit logging, policy enforcement, and sanitization for VMware MCP skills"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Wei Zhou / 周崴" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"typer>=0.12,<1.0",
|
|
15
|
+
"rich>=13.0,<15.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0,<10.0",
|
|
21
|
+
"pytest-cov>=5.0,<8.0",
|
|
22
|
+
"ruff>=0.5,<1.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
vmware-audit = "vmware_policy.cli:app"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["vmware_policy"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 100
|
|
33
|
+
target-version = "py310"
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = [
|
|
41
|
+
"unit: Unit tests",
|
|
42
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for vmware_policy.audit."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from vmware_policy.audit import AuditEngine, detect_agent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.unit
|
|
13
|
+
class TestAuditEngine:
|
|
14
|
+
def setup_method(self):
|
|
15
|
+
self._tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
16
|
+
self._tmp.close()
|
|
17
|
+
self.db_path = Path(self._tmp.name)
|
|
18
|
+
self.engine = AuditEngine(self.db_path)
|
|
19
|
+
|
|
20
|
+
def teardown_method(self):
|
|
21
|
+
self.db_path.unlink(missing_ok=True)
|
|
22
|
+
|
|
23
|
+
def test_creates_db_and_table(self):
|
|
24
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
25
|
+
tables = conn.execute(
|
|
26
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
27
|
+
).fetchall()
|
|
28
|
+
conn.close()
|
|
29
|
+
assert ("audit_log",) in tables
|
|
30
|
+
|
|
31
|
+
def test_wal_mode_enabled(self):
|
|
32
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
33
|
+
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
|
34
|
+
conn.close()
|
|
35
|
+
assert mode == "wal"
|
|
36
|
+
|
|
37
|
+
def test_log_writes_record(self):
|
|
38
|
+
self.engine.log(skill="aiops", tool="vm_power_on", status="ok")
|
|
39
|
+
rows = self.engine.query(limit=10)
|
|
40
|
+
assert len(rows) == 1
|
|
41
|
+
assert rows[0]["skill"] == "aiops"
|
|
42
|
+
assert rows[0]["tool"] == "vm_power_on"
|
|
43
|
+
assert rows[0]["status"] == "ok"
|
|
44
|
+
|
|
45
|
+
def test_log_denied_writes_record(self):
|
|
46
|
+
self.engine.log(skill="nsx", tool="delete_segment", status="denied")
|
|
47
|
+
rows = self.engine.query(status="denied")
|
|
48
|
+
assert len(rows) == 1
|
|
49
|
+
assert rows[0]["status"] == "denied"
|
|
50
|
+
|
|
51
|
+
def test_query_filters(self):
|
|
52
|
+
self.engine.log(skill="aiops", tool="vm_power_on", status="ok")
|
|
53
|
+
self.engine.log(skill="nsx", tool="delete_segment", status="denied")
|
|
54
|
+
self.engine.log(skill="aiops", tool="vm_power_off", status="error")
|
|
55
|
+
|
|
56
|
+
assert len(self.engine.query(skill="aiops")) == 2
|
|
57
|
+
assert len(self.engine.query(status="denied")) == 1
|
|
58
|
+
assert len(self.engine.query(tool="vm_power_on")) == 1
|
|
59
|
+
|
|
60
|
+
def test_stats(self):
|
|
61
|
+
self.engine.log(skill="aiops", tool="a", status="ok")
|
|
62
|
+
self.engine.log(skill="aiops", tool="b", status="ok")
|
|
63
|
+
self.engine.log(skill="nsx", tool="c", status="denied")
|
|
64
|
+
data = self.engine.stats(days=1)
|
|
65
|
+
assert data["total"] == 3
|
|
66
|
+
assert data["by_skill"]["aiops"] == 2
|
|
67
|
+
assert data["by_status"]["denied"] == 1
|
|
68
|
+
|
|
69
|
+
def test_log_never_raises(self):
|
|
70
|
+
"""Audit logging should swallow errors, not crash the tool."""
|
|
71
|
+
# Use an invalid path that can't be written
|
|
72
|
+
bad_engine = AuditEngine("/nonexistent/path/audit.db")
|
|
73
|
+
bad_engine.log(skill="test", tool="test") # should not raise
|
|
74
|
+
|
|
75
|
+
def test_sensitive_params_stored(self):
|
|
76
|
+
self.engine.log(
|
|
77
|
+
skill="aiops",
|
|
78
|
+
tool="guest_exec",
|
|
79
|
+
params={"command": "ls", "password": "***"},
|
|
80
|
+
status="ok",
|
|
81
|
+
)
|
|
82
|
+
rows = self.engine.query(limit=1)
|
|
83
|
+
assert "***" in rows[0]["params"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.unit
|
|
87
|
+
class TestDetectAgent:
|
|
88
|
+
def test_default_unknown(self, monkeypatch):
|
|
89
|
+
monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
|
|
90
|
+
monkeypatch.delenv("CLAUDE_CODE", raising=False)
|
|
91
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
92
|
+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
|
93
|
+
monkeypatch.delenv("DEERFLOW_SESSION", raising=False)
|
|
94
|
+
monkeypatch.delenv("CODEX_SESSION", raising=False)
|
|
95
|
+
assert detect_agent() == "unknown"
|
|
96
|
+
|
|
97
|
+
def test_detect_claude(self, monkeypatch):
|
|
98
|
+
monkeypatch.setenv("CLAUDE_CODE", "1")
|
|
99
|
+
assert detect_agent() == "claude"
|
|
100
|
+
|
|
101
|
+
def test_detect_local(self, monkeypatch):
|
|
102
|
+
monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False)
|
|
103
|
+
monkeypatch.delenv("CLAUDE_CODE", raising=False)
|
|
104
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
105
|
+
monkeypatch.delenv("CODEX_SESSION", raising=False)
|
|
106
|
+
monkeypatch.setenv("OLLAMA_HOST", "http://localhost:11434")
|
|
107
|
+
assert detect_agent() == "local"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Tests for vmware_policy.decorators — the @vmware_tool decorator."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from vmware_policy.audit import AuditEngine, _engine
|
|
9
|
+
from vmware_policy.decorators import PolicyDenied, vmware_tool
|
|
10
|
+
import vmware_policy.audit as audit_mod
|
|
11
|
+
import vmware_policy.policy as policy_mod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def _fresh_singletons(tmp_path):
|
|
16
|
+
"""Reset audit and policy singletons for each test."""
|
|
17
|
+
db_path = tmp_path / "test_audit.db"
|
|
18
|
+
engine = AuditEngine(db_path)
|
|
19
|
+
audit_mod._engine = engine
|
|
20
|
+
policy_mod._engine = None
|
|
21
|
+
yield engine
|
|
22
|
+
audit_mod._engine = None
|
|
23
|
+
policy_mod._engine = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.unit
|
|
27
|
+
class TestVmwareTool:
|
|
28
|
+
def test_bare_decorator(self, _fresh_singletons):
|
|
29
|
+
"""@vmware_tool without arguments."""
|
|
30
|
+
@vmware_tool
|
|
31
|
+
def list_segments(target: str = "") -> list:
|
|
32
|
+
return ["seg1", "seg2"]
|
|
33
|
+
|
|
34
|
+
result = list_segments()
|
|
35
|
+
assert result == ["seg1", "seg2"]
|
|
36
|
+
|
|
37
|
+
def test_decorator_with_args(self, _fresh_singletons):
|
|
38
|
+
"""@vmware_tool(risk_level='high')."""
|
|
39
|
+
@vmware_tool(risk_level="high", sensitive_params=["password"])
|
|
40
|
+
def delete_vm(name: str, password: str = "") -> str:
|
|
41
|
+
return f"deleted {name}"
|
|
42
|
+
|
|
43
|
+
result = delete_vm(name="test-vm", password="secret123")
|
|
44
|
+
assert result == "deleted test-vm"
|
|
45
|
+
|
|
46
|
+
def test_metadata_attached(self):
|
|
47
|
+
@vmware_tool(risk_level="critical", idempotent=True, timeout_seconds=60)
|
|
48
|
+
def my_tool() -> None:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
assert my_tool._is_vmware_tool is True
|
|
52
|
+
assert my_tool._risk_level == "critical"
|
|
53
|
+
assert my_tool._idempotent is True
|
|
54
|
+
assert my_tool._timeout_seconds == 60
|
|
55
|
+
|
|
56
|
+
def test_audit_log_written(self, _fresh_singletons):
|
|
57
|
+
engine = _fresh_singletons
|
|
58
|
+
|
|
59
|
+
@vmware_tool
|
|
60
|
+
def my_op(target: str = "") -> str:
|
|
61
|
+
return "done"
|
|
62
|
+
|
|
63
|
+
my_op(target="vcenter1")
|
|
64
|
+
rows = engine.query(limit=10)
|
|
65
|
+
assert len(rows) == 1
|
|
66
|
+
assert rows[0]["tool"] == "my_op"
|
|
67
|
+
assert rows[0]["status"] == "ok"
|
|
68
|
+
|
|
69
|
+
def test_error_logged(self, _fresh_singletons):
|
|
70
|
+
engine = _fresh_singletons
|
|
71
|
+
|
|
72
|
+
@vmware_tool
|
|
73
|
+
def failing_op() -> None:
|
|
74
|
+
raise RuntimeError("boom")
|
|
75
|
+
|
|
76
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
77
|
+
failing_op()
|
|
78
|
+
|
|
79
|
+
rows = engine.query(limit=10)
|
|
80
|
+
assert len(rows) == 1
|
|
81
|
+
assert rows[0]["status"] == "error"
|
|
82
|
+
|
|
83
|
+
def test_sensitive_params_redacted(self, _fresh_singletons):
|
|
84
|
+
engine = _fresh_singletons
|
|
85
|
+
|
|
86
|
+
@vmware_tool(sensitive_params=["password", "token"])
|
|
87
|
+
def login(user: str, password: str, token: str = "") -> str:
|
|
88
|
+
return "ok"
|
|
89
|
+
|
|
90
|
+
login(user="admin", password="secret", token="abc123")
|
|
91
|
+
rows = engine.query(limit=1)
|
|
92
|
+
params = rows[0]["params"]
|
|
93
|
+
assert "secret" not in params
|
|
94
|
+
assert "abc123" not in params
|
|
95
|
+
assert "***" in params
|
|
96
|
+
assert "admin" in params
|
|
97
|
+
|
|
98
|
+
def test_preserves_function_name(self):
|
|
99
|
+
@vmware_tool
|
|
100
|
+
def original_name() -> None:
|
|
101
|
+
"""Original docstring."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
assert original_name.__name__ == "original_name"
|
|
105
|
+
assert original_name.__doc__ == "Original docstring."
|
|
106
|
+
|
|
107
|
+
def test_duration_recorded(self, _fresh_singletons):
|
|
108
|
+
import time
|
|
109
|
+
|
|
110
|
+
engine = _fresh_singletons
|
|
111
|
+
|
|
112
|
+
@vmware_tool
|
|
113
|
+
def slow_op() -> str:
|
|
114
|
+
time.sleep(0.05)
|
|
115
|
+
return "done"
|
|
116
|
+
|
|
117
|
+
slow_op()
|
|
118
|
+
rows = engine.query(limit=1)
|
|
119
|
+
assert rows[0]["duration_ms"] >= 40
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.unit
|
|
123
|
+
class TestPolicyDenied:
|
|
124
|
+
def test_denied_logged_with_status(self, _fresh_singletons, tmp_path):
|
|
125
|
+
engine = _fresh_singletons
|
|
126
|
+
|
|
127
|
+
# Create a policy with deny rule
|
|
128
|
+
rules_path = tmp_path / "rules.yaml"
|
|
129
|
+
rules_path.write_text(
|
|
130
|
+
"deny:\n"
|
|
131
|
+
" - name: no-delete\n"
|
|
132
|
+
' operations: ["delete_*"]\n'
|
|
133
|
+
" reason: Deletion not allowed\n"
|
|
134
|
+
)
|
|
135
|
+
policy_mod._engine = None
|
|
136
|
+
import vmware_policy.policy as pm
|
|
137
|
+
pm._engine = pm.PolicyEngine(rules_path)
|
|
138
|
+
|
|
139
|
+
@vmware_tool(risk_level="high")
|
|
140
|
+
def delete_segment(name: str) -> str:
|
|
141
|
+
return "deleted"
|
|
142
|
+
|
|
143
|
+
with pytest.raises(PolicyDenied, match="Deletion not allowed"):
|
|
144
|
+
delete_segment(name="seg1")
|
|
145
|
+
|
|
146
|
+
rows = engine.query(limit=1)
|
|
147
|
+
assert rows[0]["status"] == "denied"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tests for vmware_policy.policy — the policy engine."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from vmware_policy.policy import PolicyEngine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.unit
|
|
9
|
+
class TestPolicyEngine:
|
|
10
|
+
def test_no_rules_allows_all(self, tmp_path):
|
|
11
|
+
rules_path = tmp_path / "nonexistent.yaml"
|
|
12
|
+
engine = PolicyEngine(rules_path)
|
|
13
|
+
result = engine.check_allowed("delete_vm")
|
|
14
|
+
assert result.allowed is True
|
|
15
|
+
assert result.rule == "no_rules"
|
|
16
|
+
|
|
17
|
+
def test_deny_rule_blocks(self, tmp_path):
|
|
18
|
+
rules = tmp_path / "rules.yaml"
|
|
19
|
+
rules.write_text(
|
|
20
|
+
"deny:\n"
|
|
21
|
+
" - name: no-delete\n"
|
|
22
|
+
' operations: ["delete_*"]\n'
|
|
23
|
+
" reason: No deletions allowed\n"
|
|
24
|
+
)
|
|
25
|
+
engine = PolicyEngine(rules)
|
|
26
|
+
result = engine.check_allowed("delete_segment")
|
|
27
|
+
assert result.allowed is False
|
|
28
|
+
assert result.rule == "no-delete"
|
|
29
|
+
assert "No deletions" in result.reason
|
|
30
|
+
|
|
31
|
+
def test_deny_rule_env_filter(self, tmp_path):
|
|
32
|
+
rules = tmp_path / "rules.yaml"
|
|
33
|
+
rules.write_text(
|
|
34
|
+
"deny:\n"
|
|
35
|
+
" - name: no-delete-prod\n"
|
|
36
|
+
' operations: ["delete_*"]\n'
|
|
37
|
+
' environments: ["production"]\n'
|
|
38
|
+
" reason: No deletions in prod\n"
|
|
39
|
+
)
|
|
40
|
+
engine = PolicyEngine(rules)
|
|
41
|
+
|
|
42
|
+
prod = engine.check_allowed("delete_vm", env="production")
|
|
43
|
+
assert prod.allowed is False
|
|
44
|
+
|
|
45
|
+
dev = engine.check_allowed("delete_vm", env="development")
|
|
46
|
+
assert dev.allowed is True
|
|
47
|
+
|
|
48
|
+
def test_deny_rule_risk_filter(self, tmp_path):
|
|
49
|
+
rules = tmp_path / "rules.yaml"
|
|
50
|
+
rules.write_text(
|
|
51
|
+
"deny:\n"
|
|
52
|
+
" - name: no-critical\n"
|
|
53
|
+
" min_risk_level: critical\n"
|
|
54
|
+
" reason: Critical ops blocked\n"
|
|
55
|
+
)
|
|
56
|
+
engine = PolicyEngine(rules)
|
|
57
|
+
|
|
58
|
+
crit = engine.check_allowed("any_op", risk_level="critical")
|
|
59
|
+
assert crit.allowed is False
|
|
60
|
+
|
|
61
|
+
low = engine.check_allowed("any_op", risk_level="low")
|
|
62
|
+
assert low.allowed is True
|
|
63
|
+
|
|
64
|
+
def test_wildcard_pattern(self, tmp_path):
|
|
65
|
+
rules = tmp_path / "rules.yaml"
|
|
66
|
+
rules.write_text(
|
|
67
|
+
"deny:\n"
|
|
68
|
+
" - name: block-all\n"
|
|
69
|
+
' operations: ["*"]\n'
|
|
70
|
+
" reason: Everything blocked\n"
|
|
71
|
+
)
|
|
72
|
+
engine = PolicyEngine(rules)
|
|
73
|
+
assert engine.check_allowed("anything").allowed is False
|
|
74
|
+
|
|
75
|
+
def test_exact_match(self, tmp_path):
|
|
76
|
+
rules = tmp_path / "rules.yaml"
|
|
77
|
+
rules.write_text(
|
|
78
|
+
"deny:\n"
|
|
79
|
+
" - name: specific\n"
|
|
80
|
+
' operations: ["vm_power_off"]\n'
|
|
81
|
+
" reason: Blocked\n"
|
|
82
|
+
)
|
|
83
|
+
engine = PolicyEngine(rules)
|
|
84
|
+
assert engine.check_allowed("vm_power_off").allowed is False
|
|
85
|
+
assert engine.check_allowed("vm_power_on").allowed is True
|
|
86
|
+
|
|
87
|
+
def test_hot_reload(self, tmp_path):
|
|
88
|
+
rules = tmp_path / "rules.yaml"
|
|
89
|
+
rules.write_text("")
|
|
90
|
+
engine = PolicyEngine(rules)
|
|
91
|
+
assert engine.check_allowed("delete_vm").allowed is True
|
|
92
|
+
|
|
93
|
+
# Update rules file
|
|
94
|
+
rules.write_text(
|
|
95
|
+
"deny:\n"
|
|
96
|
+
" - name: new-rule\n"
|
|
97
|
+
' operations: ["delete_*"]\n'
|
|
98
|
+
" reason: Now blocked\n"
|
|
99
|
+
)
|
|
100
|
+
# Force mtime change detection
|
|
101
|
+
import os
|
|
102
|
+
os.utime(rules, (rules.stat().st_mtime + 1, rules.stat().st_mtime + 1))
|
|
103
|
+
|
|
104
|
+
assert engine.check_allowed("delete_vm").allowed is False
|
|
105
|
+
|
|
106
|
+
def test_bypass_mode(self, tmp_path, monkeypatch):
|
|
107
|
+
rules = tmp_path / "rules.yaml"
|
|
108
|
+
rules.write_text(
|
|
109
|
+
"deny:\n"
|
|
110
|
+
" - name: block-all\n"
|
|
111
|
+
' operations: ["*"]\n'
|
|
112
|
+
" reason: Blocked\n"
|
|
113
|
+
)
|
|
114
|
+
engine = PolicyEngine(rules)
|
|
115
|
+
|
|
116
|
+
monkeypatch.setenv("VMWARE_POLICY_DISABLED", "1")
|
|
117
|
+
result = engine.check_allowed("delete_vm")
|
|
118
|
+
assert result.allowed is True
|
|
119
|
+
assert result.rule == "policy_disabled"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for vmware_policy.sanitize."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from vmware_policy.sanitize import sanitize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.unit
|
|
9
|
+
class TestSanitize:
|
|
10
|
+
def test_strips_control_chars(self):
|
|
11
|
+
assert sanitize("hello\x00world") == "helloworld"
|
|
12
|
+
assert sanitize("a\x0b\x0cb") == "ab"
|
|
13
|
+
assert sanitize("\x7f\x80\x9f") == ""
|
|
14
|
+
|
|
15
|
+
def test_preserves_newline_and_tab(self):
|
|
16
|
+
assert sanitize("line1\nline2") == "line1\nline2"
|
|
17
|
+
assert sanitize("col1\tcol2") == "col1\tcol2"
|
|
18
|
+
|
|
19
|
+
def test_truncates_to_max_len(self):
|
|
20
|
+
long_text = "a" * 1000
|
|
21
|
+
assert len(sanitize(long_text)) == 500
|
|
22
|
+
assert len(sanitize(long_text, max_len=100)) == 100
|
|
23
|
+
|
|
24
|
+
def test_handles_non_string_input(self):
|
|
25
|
+
assert sanitize(12345) == "12345"
|
|
26
|
+
assert sanitize(None) == "None"
|
|
27
|
+
|
|
28
|
+
def test_empty_string(self):
|
|
29
|
+
assert sanitize("") == ""
|
|
30
|
+
|
|
31
|
+
def test_unicode_preserved(self):
|
|
32
|
+
assert sanitize("中文测试") == "中文测试"
|
|
33
|
+
assert sanitize("émojis 🎉") == "émojis 🎉"
|