controlzero 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.
- controlzero/__init__.py +41 -0
- controlzero/_internal/__init__.py +1 -0
- controlzero/_internal/dlp_scanner.py +777 -0
- controlzero/_internal/enforcer.py +210 -0
- controlzero/_internal/types.py +19 -0
- controlzero/audit_local.py +128 -0
- controlzero/audit_remote.py +221 -0
- controlzero/cli/__init__.py +1 -0
- controlzero/cli/main.py +1177 -0
- controlzero/cli/templates/autogen.yaml +79 -0
- controlzero/cli/templates/claude-code.yaml +85 -0
- controlzero/cli/templates/codex-cli.yaml +80 -0
- controlzero/cli/templates/cost-cap.yaml +64 -0
- controlzero/cli/templates/crewai.yaml +83 -0
- controlzero/cli/templates/cursor.yaml +86 -0
- controlzero/cli/templates/gemini-cli.yaml +85 -0
- controlzero/cli/templates/generic.yaml +57 -0
- controlzero/cli/templates/langchain.yaml +89 -0
- controlzero/cli/templates/mcp.yaml +79 -0
- controlzero/cli/templates/rag.yaml +63 -0
- controlzero/client.py +398 -0
- controlzero/enrollment.py +493 -0
- controlzero/errors.py +60 -0
- controlzero/policy_loader.py +245 -0
- controlzero/tamper.py +337 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/autogen.yaml +79 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/claude-code.yaml +85 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/codex-cli.yaml +80 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/cost-cap.yaml +64 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/crewai.yaml +83 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/cursor.yaml +86 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/gemini-cli.yaml +85 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/generic.yaml +57 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/langchain.yaml +89 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/mcp.yaml +79 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/rag.yaml +63 -0
- controlzero-1.0.0.dist-info/METADATA +232 -0
- controlzero-1.0.0.dist-info/RECORD +41 -0
- controlzero-1.0.0.dist-info/WHEEL +4 -0
- controlzero-1.0.0.dist-info/entry_points.txt +2 -0
- controlzero-1.0.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Local policy evaluator. Pure Python, in-process, fail-closed by default.
|
|
2
|
+
|
|
3
|
+
Copied from the proven control_zero.policy.enforcer module and adapted for the
|
|
4
|
+
local-first surface. The eval logic is identical so behavior is consistent
|
|
5
|
+
across hosted and local modes.
|
|
6
|
+
|
|
7
|
+
Pattern matching uses Python's stdlib fnmatch (shell-style globs). All friendly
|
|
8
|
+
shorthand (e.g. "delete_*" -> "delete_*:*") is normalized in policy_loader.py
|
|
9
|
+
BEFORE rules reach this evaluator. By the time rules arrive here, every action
|
|
10
|
+
is canonical "tool:method" form. This keeps the evaluator simple and matches
|
|
11
|
+
the legacy hosted engine's behavior.
|
|
12
|
+
|
|
13
|
+
DLP scanning (added 2026-04-09): after the tool-name policy check, the
|
|
14
|
+
evaluator optionally scans tool arguments for PII, secrets, and sensitive data
|
|
15
|
+
patterns. A "block" DLP match overrides an "allow" decision. "detect" matches
|
|
16
|
+
are recorded in the audit entry without blocking.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from controlzero._internal.dlp_scanner import (
|
|
26
|
+
DLPMatch,
|
|
27
|
+
DLPScanner,
|
|
28
|
+
extract_text_from_args,
|
|
29
|
+
)
|
|
30
|
+
from controlzero._internal.types import PolicyRule
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PolicyDecision:
|
|
35
|
+
"""Result of a policy evaluation.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
effect: One of "allow", "deny", "warn", "audit".
|
|
39
|
+
decision: Alias for effect, for ergonomic Hello World code.
|
|
40
|
+
policy_id: ID of the rule that matched, or None if no match.
|
|
41
|
+
reason: Human-readable reason for the decision.
|
|
42
|
+
evaluated_rules: How many rules were checked before a match.
|
|
43
|
+
dlp_findings: List of DLP match dicts for the audit trail.
|
|
44
|
+
"""
|
|
45
|
+
effect: str
|
|
46
|
+
policy_id: Optional[str] = None
|
|
47
|
+
reason: Optional[str] = None
|
|
48
|
+
evaluated_rules: int = 0
|
|
49
|
+
dlp_findings: list[dict] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def decision(self) -> str:
|
|
53
|
+
"""Alias for `effect`. Reads more naturally in user code."""
|
|
54
|
+
return self.effect
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def allowed(self) -> bool:
|
|
58
|
+
"""True if the call was allowed."""
|
|
59
|
+
return self.effect == "allow"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def denied(self) -> bool:
|
|
63
|
+
"""True if the call was denied."""
|
|
64
|
+
return self.effect == "deny"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PolicyDeniedError(Exception):
|
|
68
|
+
"""Raised by `Client.guard(..., raise_on_deny=True)` when a policy denies an action."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, decision: PolicyDecision):
|
|
71
|
+
self.decision = decision
|
|
72
|
+
super().__init__(
|
|
73
|
+
f"Policy denied: {decision.reason or 'no reason provided'}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PolicyEvaluator:
|
|
78
|
+
"""In-process policy evaluator. Fail-closed by default.
|
|
79
|
+
|
|
80
|
+
Loads a list of internal `PolicyRule` objects and evaluates tool calls
|
|
81
|
+
against them in order. The first matching rule wins. If no rule matches,
|
|
82
|
+
the call is denied (fail-closed).
|
|
83
|
+
|
|
84
|
+
Optionally accepts a DLPScanner to inspect tool arguments for sensitive
|
|
85
|
+
content after the tool-name policy check passes.
|
|
86
|
+
|
|
87
|
+
All pattern matching uses `fnmatch.fnmatchcase` (shell-style globs):
|
|
88
|
+
* matches everything
|
|
89
|
+
delete_* matches anything starting with "delete_"
|
|
90
|
+
github:* matches any method on the github tool
|
|
91
|
+
*:read matches the read method on any tool
|
|
92
|
+
model:*-haiku-* matches any model with -haiku- in its name
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
rules: Optional[list[PolicyRule]] = None,
|
|
98
|
+
dlp_scanner: Optional[DLPScanner] = None,
|
|
99
|
+
):
|
|
100
|
+
self._rules: list[PolicyRule] = rules or []
|
|
101
|
+
self._dlp_scanner: Optional[DLPScanner] = dlp_scanner
|
|
102
|
+
|
|
103
|
+
def load(self, rules: list[PolicyRule]) -> None:
|
|
104
|
+
"""Replace the rule set."""
|
|
105
|
+
self._rules = list(rules)
|
|
106
|
+
|
|
107
|
+
def set_dlp_scanner(self, scanner: DLPScanner) -> None:
|
|
108
|
+
"""Set or replace the DLP scanner."""
|
|
109
|
+
self._dlp_scanner = scanner
|
|
110
|
+
|
|
111
|
+
def evaluate(
|
|
112
|
+
self,
|
|
113
|
+
tool: str,
|
|
114
|
+
method: str = "*",
|
|
115
|
+
context: Optional[dict] = None,
|
|
116
|
+
args: Optional[dict] = None,
|
|
117
|
+
) -> PolicyDecision:
|
|
118
|
+
"""Evaluate a tool call against the loaded rules.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tool: Tool name (e.g. "delete_file", "github").
|
|
122
|
+
method: Optional method name. Defaults to "*" which matches any.
|
|
123
|
+
context: Optional context dict with `resource` and `tags` keys.
|
|
124
|
+
args: Optional tool arguments dict. Scanned for DLP patterns if
|
|
125
|
+
a DLPScanner is configured.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
PolicyDecision. Always returns; never raises.
|
|
129
|
+
"""
|
|
130
|
+
action = f"{tool}:{method}"
|
|
131
|
+
resource = (context or {}).get("resource")
|
|
132
|
+
evaluated = 0
|
|
133
|
+
|
|
134
|
+
for rule in self._rules:
|
|
135
|
+
evaluated += 1
|
|
136
|
+
if not _glob_any(rule.actions, action):
|
|
137
|
+
continue
|
|
138
|
+
if rule.resources:
|
|
139
|
+
if not resource or not _glob_any(rule.resources, resource):
|
|
140
|
+
continue
|
|
141
|
+
decision = PolicyDecision(
|
|
142
|
+
effect=rule.effect,
|
|
143
|
+
policy_id=rule.id or rule.name or None,
|
|
144
|
+
# User-provided reason wins. Falls back to canned text only if
|
|
145
|
+
# the rule had no `reason:` field in the source policy.
|
|
146
|
+
reason=rule.reason or f"Matched rule {rule.id or rule.name}".strip(),
|
|
147
|
+
evaluated_rules=evaluated,
|
|
148
|
+
)
|
|
149
|
+
# DLP scan: only when the policy decision is "allow" and args exist
|
|
150
|
+
if decision.allowed and args and self._dlp_scanner is not None:
|
|
151
|
+
decision = self._apply_dlp_scan(decision, args)
|
|
152
|
+
return decision
|
|
153
|
+
|
|
154
|
+
# Fail-closed default. No matching rule means deny.
|
|
155
|
+
return PolicyDecision(
|
|
156
|
+
effect="deny",
|
|
157
|
+
reason="No matching policy rule (fail-closed default)",
|
|
158
|
+
evaluated_rules=evaluated,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _apply_dlp_scan(
|
|
162
|
+
self, decision: PolicyDecision, args: dict
|
|
163
|
+
) -> PolicyDecision:
|
|
164
|
+
"""Run DLP scanner on tool arguments and update decision if needed.
|
|
165
|
+
|
|
166
|
+
If any DLP rule has action="block", the decision is overridden to DENY.
|
|
167
|
+
If rules have action="detect" or "mask", findings are attached to the
|
|
168
|
+
decision for audit but the call is still allowed.
|
|
169
|
+
"""
|
|
170
|
+
if self._dlp_scanner is None:
|
|
171
|
+
return decision
|
|
172
|
+
|
|
173
|
+
text = extract_text_from_args(args)
|
|
174
|
+
if not text:
|
|
175
|
+
return decision
|
|
176
|
+
|
|
177
|
+
matches = self._dlp_scanner.scan(text)
|
|
178
|
+
if not matches:
|
|
179
|
+
return decision
|
|
180
|
+
|
|
181
|
+
findings = self._dlp_scanner.get_findings_for_audit(matches)
|
|
182
|
+
decision.dlp_findings = findings
|
|
183
|
+
|
|
184
|
+
if self._dlp_scanner.has_blocking_match(matches):
|
|
185
|
+
# Find the first blocking rule for the reason message
|
|
186
|
+
blocking = next(m for m in matches if m.action == "block")
|
|
187
|
+
return PolicyDecision(
|
|
188
|
+
effect="deny",
|
|
189
|
+
policy_id=decision.policy_id,
|
|
190
|
+
reason=(
|
|
191
|
+
f"DLP rule '{blocking.rule_name}' matched "
|
|
192
|
+
f"{blocking.category} content in tool arguments"
|
|
193
|
+
),
|
|
194
|
+
evaluated_rules=decision.evaluated_rules,
|
|
195
|
+
dlp_findings=findings,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return decision
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _glob_any(patterns: list[str], value: str) -> bool:
|
|
202
|
+
"""True if any pattern matches the value via fnmatch.fnmatchcase.
|
|
203
|
+
|
|
204
|
+
fnmatchcase is case-sensitive (matches policy semantics) and supports the
|
|
205
|
+
same syntax users learn from .gitignore: *, ?, [seq], [!seq].
|
|
206
|
+
"""
|
|
207
|
+
for p in patterns:
|
|
208
|
+
if fnmatch.fnmatchcase(value, p):
|
|
209
|
+
return True
|
|
210
|
+
return False
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Internal type definitions. Public users should import from controlzero, not here."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PolicyRule(BaseModel):
|
|
8
|
+
"""Internal canonical representation of a single policy rule.
|
|
9
|
+
|
|
10
|
+
The user-facing schema (see policy_loader) is friendlier; this is what the
|
|
11
|
+
evaluator consumes after translation.
|
|
12
|
+
"""
|
|
13
|
+
id: str = ""
|
|
14
|
+
name: str = ""
|
|
15
|
+
effect: str # "allow" or "deny"
|
|
16
|
+
actions: list[str] = Field(default_factory=list)
|
|
17
|
+
resources: list[str] = Field(default_factory=list)
|
|
18
|
+
conditions: dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
reason: str = "" # Human-readable explanation, surfaced in audit + denies
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Local audit log sink built on loguru.
|
|
2
|
+
|
|
3
|
+
When the SDK runs without an API key, audit entries are written to a local file
|
|
4
|
+
with rotation. This module isolates the loguru config so the rest of the SDK
|
|
5
|
+
does not depend on loguru directly.
|
|
6
|
+
|
|
7
|
+
When an API key is set, this module is bypassed entirely and audit goes to the
|
|
8
|
+
remote forwarder. The user-supplied log_* options are ignored with a warning
|
|
9
|
+
(see Client.__init__).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalAuditLogger:
|
|
22
|
+
"""Writes audit entries to a local rotated log file via loguru.
|
|
23
|
+
|
|
24
|
+
Falls back to stderr if the configured log path is not writable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
log_path: str = "./controlzero.log",
|
|
30
|
+
rotation: str = "daily",
|
|
31
|
+
retention: str = "30 days",
|
|
32
|
+
compression: Optional[str] = None,
|
|
33
|
+
log_format: str = "json",
|
|
34
|
+
):
|
|
35
|
+
self._log_path = log_path
|
|
36
|
+
self._rotation = rotation
|
|
37
|
+
self._retention = retention
|
|
38
|
+
self._compression = compression
|
|
39
|
+
self._format = log_format
|
|
40
|
+
self._logger = None
|
|
41
|
+
self._sink_id: Optional[int] = None
|
|
42
|
+
self._fallback = False
|
|
43
|
+
# Per-instance component tag prevents cross-instance log duplication
|
|
44
|
+
# when multiple Client objects exist in the same process. Each loguru
|
|
45
|
+
# sink filters on its own component string.
|
|
46
|
+
self._component = f"controlzero.audit.{id(self)}"
|
|
47
|
+
self._setup()
|
|
48
|
+
|
|
49
|
+
def _setup(self) -> None:
|
|
50
|
+
try:
|
|
51
|
+
from loguru import logger as _logger
|
|
52
|
+
except ImportError:
|
|
53
|
+
print(
|
|
54
|
+
"controlzero: loguru not installed, audit logs will go to stderr.",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
self._fallback = True
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
path = Path(self._log_path)
|
|
61
|
+
try:
|
|
62
|
+
# Make sure the parent directory exists and is writable
|
|
63
|
+
parent = path.parent
|
|
64
|
+
if str(parent) and not parent.exists():
|
|
65
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# Translate "daily" sugar to a loguru-friendly value
|
|
68
|
+
rotation_value: Any = self._rotation
|
|
69
|
+
if self._rotation == "daily":
|
|
70
|
+
rotation_value = "00:00"
|
|
71
|
+
|
|
72
|
+
self._logger = _logger.bind(component=self._component)
|
|
73
|
+
# Add a dedicated sink filtered on this instance's component tag,
|
|
74
|
+
# so each Client gets its own sink and lines never duplicate
|
|
75
|
+
# across instances. Capture self._component in the lambda's
|
|
76
|
+
# default arg to avoid late-binding issues.
|
|
77
|
+
comp = self._component
|
|
78
|
+
self._sink_id = _logger.add(
|
|
79
|
+
str(path),
|
|
80
|
+
rotation=rotation_value,
|
|
81
|
+
retention=self._retention,
|
|
82
|
+
compression=self._compression,
|
|
83
|
+
serialize=False, # we format ourselves
|
|
84
|
+
format="{message}",
|
|
85
|
+
filter=lambda record, c=comp: record["extra"].get("component") == c,
|
|
86
|
+
enqueue=True, # async-safe
|
|
87
|
+
)
|
|
88
|
+
except (OSError, PermissionError) as e:
|
|
89
|
+
print(
|
|
90
|
+
f"controlzero: cannot write to {self._log_path} ({e}), "
|
|
91
|
+
"falling back to stderr.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
self._fallback = True
|
|
95
|
+
|
|
96
|
+
def log(self, entry: dict) -> None:
|
|
97
|
+
"""Write a single audit entry."""
|
|
98
|
+
line = self._format_entry(entry)
|
|
99
|
+
if self._fallback or self._logger is None:
|
|
100
|
+
print(line, file=sys.stderr)
|
|
101
|
+
return
|
|
102
|
+
self._logger.info(line)
|
|
103
|
+
|
|
104
|
+
def _format_entry(self, entry: dict) -> str:
|
|
105
|
+
# Always include a timestamp in UTC ISO 8601
|
|
106
|
+
record = {
|
|
107
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
108
|
+
**entry,
|
|
109
|
+
}
|
|
110
|
+
if self._format == "pretty":
|
|
111
|
+
# Compact human-readable form: ts | decision | tool | reason
|
|
112
|
+
parts = [
|
|
113
|
+
record.get("ts", ""),
|
|
114
|
+
record.get("decision", "?"),
|
|
115
|
+
record.get("tool", "?"),
|
|
116
|
+
record.get("reason", ""),
|
|
117
|
+
]
|
|
118
|
+
return " | ".join(str(p) for p in parts)
|
|
119
|
+
return json.dumps(record, default=str)
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
if self._logger is not None and self._sink_id is not None:
|
|
123
|
+
try:
|
|
124
|
+
from loguru import logger as _logger
|
|
125
|
+
_logger.remove(self._sink_id)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
self._sink_id = None
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Remote audit sink -- batches local audit entries and POSTs them to the backend.
|
|
2
|
+
|
|
3
|
+
When an enrolled SDK evaluates a tool call, the decision is written to the local
|
|
4
|
+
audit log first (fire-and-forget). This module buffers those entries and
|
|
5
|
+
periodically flushes them to ``POST /api/audit`` on the backend so the
|
|
6
|
+
governance admin dashboard shows real data from CLI hooks (Claude Code,
|
|
7
|
+
Gemini CLI, Codex CLI, etc.).
|
|
8
|
+
|
|
9
|
+
Design constraints:
|
|
10
|
+
- NEVER block or crash the hook-check critical path. Local audit is
|
|
11
|
+
always written first; remote is best-effort.
|
|
12
|
+
- Thread-safe: multiple guard() calls from different threads share
|
|
13
|
+
one buffer protected by a lock.
|
|
14
|
+
- Flush runs in a daemon thread so shutdown of the main process is
|
|
15
|
+
not blocked by a slow network.
|
|
16
|
+
- On 401 the sink disables itself (enrollment expired).
|
|
17
|
+
- On transient errors the buffer is retained for the next flush cycle.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import getpass
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import platform
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("controlzero.audit_remote")
|
|
33
|
+
|
|
34
|
+
# Buffer limits
|
|
35
|
+
MAX_BUFFER_SIZE = 50
|
|
36
|
+
FLUSH_INTERVAL_S = 30.0
|
|
37
|
+
FLUSH_TIMEOUT_S = 2.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RemoteAuditSink:
|
|
41
|
+
"""Buffers audit entries and flushes them to the backend in batches."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_url: str,
|
|
46
|
+
machine_token: str, # not used for auth header, kept for future
|
|
47
|
+
org_id: str,
|
|
48
|
+
machine_id: str,
|
|
49
|
+
state_dir: Optional["Path"] = None,
|
|
50
|
+
):
|
|
51
|
+
self._api_url = api_url.rstrip("/")
|
|
52
|
+
self._machine_token = machine_token
|
|
53
|
+
self._org_id = org_id
|
|
54
|
+
self._machine_id = machine_id
|
|
55
|
+
self._state_dir = state_dir
|
|
56
|
+
|
|
57
|
+
self._buffer: list[dict] = []
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._disabled = False
|
|
60
|
+
self._closed = False
|
|
61
|
+
|
|
62
|
+
# Start the periodic flush timer
|
|
63
|
+
self._start_flush_timer()
|
|
64
|
+
|
|
65
|
+
# ---- public API ----
|
|
66
|
+
|
|
67
|
+
def log(self, entry: dict) -> None:
|
|
68
|
+
"""Append an audit entry to the buffer. Triggers flush if buffer is full."""
|
|
69
|
+
if self._disabled or self._closed:
|
|
70
|
+
return
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._buffer.append(self._to_wire_format(entry))
|
|
73
|
+
should_flush = len(self._buffer) >= MAX_BUFFER_SIZE
|
|
74
|
+
if should_flush:
|
|
75
|
+
self._flush_async()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Final flush on shutdown. Blocks briefly to drain the buffer."""
|
|
79
|
+
if self._closed:
|
|
80
|
+
return
|
|
81
|
+
self._closed = True
|
|
82
|
+
self._cancel_flush_timer()
|
|
83
|
+
# Synchronous flush -- we are shutting down
|
|
84
|
+
self._flush_sync()
|
|
85
|
+
|
|
86
|
+
# ---- wire format mapping ----
|
|
87
|
+
|
|
88
|
+
def _to_wire_format(self, entry: dict) -> dict:
|
|
89
|
+
"""Map from the local audit entry shape to the backend wire format.
|
|
90
|
+
|
|
91
|
+
Backend expects (from audit_ingest_handler.go AuditIngestEntry):
|
|
92
|
+
id, user, tool_name, decision, policy_id, rule_id, reason,
|
|
93
|
+
hostname, mode, ts, verbosity_level
|
|
94
|
+
"""
|
|
95
|
+
hostname = platform.node() or "unknown"
|
|
96
|
+
user = ""
|
|
97
|
+
try:
|
|
98
|
+
user = getpass.getuser()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"id": str(uuid.uuid4()),
|
|
104
|
+
"tool_name": entry.get("tool", ""),
|
|
105
|
+
"decision": entry.get("decision", "allow"),
|
|
106
|
+
"policy_id": entry.get("policy_id", ""),
|
|
107
|
+
"rule_id": entry.get("policy_id", ""), # local entries use policy_id as rule_id
|
|
108
|
+
"reason": entry.get("reason", ""),
|
|
109
|
+
"hostname": hostname,
|
|
110
|
+
"user": user,
|
|
111
|
+
"mode": entry.get("mode", "local"),
|
|
112
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# ---- flush mechanics ----
|
|
116
|
+
|
|
117
|
+
def _flush_async(self) -> None:
|
|
118
|
+
"""Run flush in a daemon thread so it never blocks the caller."""
|
|
119
|
+
t = threading.Thread(target=self._flush_sync, daemon=True)
|
|
120
|
+
t.start()
|
|
121
|
+
|
|
122
|
+
def _flush_sync(self) -> None:
|
|
123
|
+
"""POST buffered entries to the backend. On success, clear buffer."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
if not self._buffer or self._disabled:
|
|
126
|
+
return
|
|
127
|
+
batch = list(self._buffer)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
self._post_batch(batch)
|
|
131
|
+
# Success: remove the flushed entries from the buffer
|
|
132
|
+
with self._lock:
|
|
133
|
+
# Only remove the entries we successfully sent; new entries
|
|
134
|
+
# may have been appended while we were posting.
|
|
135
|
+
self._buffer = self._buffer[len(batch):]
|
|
136
|
+
except _AuthExpiredError:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"controlzero: remote audit sink disabled -- enrollment expired (401)"
|
|
139
|
+
)
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._disabled = True
|
|
142
|
+
self._buffer.clear()
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
# Transient error: keep entries in buffer for next flush
|
|
145
|
+
logger.warning(
|
|
146
|
+
"controlzero: remote audit flush failed (%s); "
|
|
147
|
+
"entries retained for retry",
|
|
148
|
+
exc,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _post_batch(self, batch: list[dict]) -> None:
|
|
152
|
+
"""Send a batch of entries to POST /api/audit with signed headers."""
|
|
153
|
+
try:
|
|
154
|
+
from controlzero.enrollment import load_state, sign_request
|
|
155
|
+
except ImportError:
|
|
156
|
+
# enrollment module not available -- cannot send
|
|
157
|
+
raise _AuthExpiredError("enrollment module not available")
|
|
158
|
+
|
|
159
|
+
state = load_state(self._state_dir) if self._state_dir else load_state()
|
|
160
|
+
if state is None:
|
|
161
|
+
raise _AuthExpiredError("no enrollment state")
|
|
162
|
+
|
|
163
|
+
body = json.dumps({"entries": batch}).encode("utf-8")
|
|
164
|
+
state_dir_kwarg = {"state_dir": self._state_dir} if self._state_dir else {}
|
|
165
|
+
headers = sign_request(state, "POST", "/api/audit", body, **state_dir_kwarg)
|
|
166
|
+
headers["Content-Type"] = "application/json"
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
import httpx
|
|
170
|
+
except ImportError:
|
|
171
|
+
raise _AuthExpiredError("httpx not available")
|
|
172
|
+
|
|
173
|
+
resp = httpx.post(
|
|
174
|
+
f"{self._api_url}/api/audit",
|
|
175
|
+
content=body,
|
|
176
|
+
headers=headers,
|
|
177
|
+
timeout=FLUSH_TIMEOUT_S,
|
|
178
|
+
)
|
|
179
|
+
if resp.status_code == 401:
|
|
180
|
+
raise _AuthExpiredError("server returned 401")
|
|
181
|
+
if resp.status_code >= 400:
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
f"audit ingest returned HTTP {resp.status_code}: {resp.text}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ---- periodic timer ----
|
|
187
|
+
|
|
188
|
+
def _start_flush_timer(self) -> None:
|
|
189
|
+
"""Schedule the next periodic flush."""
|
|
190
|
+
if self._closed or self._disabled:
|
|
191
|
+
return
|
|
192
|
+
self._timer = threading.Timer(FLUSH_INTERVAL_S, self._on_timer)
|
|
193
|
+
self._timer.daemon = True
|
|
194
|
+
self._timer.start()
|
|
195
|
+
|
|
196
|
+
def _cancel_flush_timer(self) -> None:
|
|
197
|
+
timer = getattr(self, "_timer", None)
|
|
198
|
+
if timer is not None:
|
|
199
|
+
timer.cancel()
|
|
200
|
+
|
|
201
|
+
def _on_timer(self) -> None:
|
|
202
|
+
"""Called by the periodic timer. Flush and reschedule."""
|
|
203
|
+
if self._closed or self._disabled:
|
|
204
|
+
return
|
|
205
|
+
self._flush_sync()
|
|
206
|
+
self._start_flush_timer()
|
|
207
|
+
|
|
208
|
+
# ---- properties for testing ----
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def disabled(self) -> bool:
|
|
212
|
+
return self._disabled
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def buffer(self) -> list[dict]:
|
|
216
|
+
with self._lock:
|
|
217
|
+
return list(self._buffer)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _AuthExpiredError(Exception):
|
|
221
|
+
"""Internal: signals the remote sink should be permanently disabled."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface for the controlzero SDK."""
|