ruleforge-python 0.1.1__py3-none-win_amd64.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.
- ruleforge_python-0.1.1.data/purelib/ruleforge/__init__.py +50 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/_version.py +9 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/alerts.py +86 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/async_service.py +106 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/bin/README.md +10 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/bin/policy.exe +0 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/bin/policy.sha256 +7 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/binary.py +137 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/cli.py +480 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/py.typed +1 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/retry_queue.py +116 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/service.py +195 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/sinks.py +77 -0
- ruleforge_python-0.1.1.data/purelib/ruleforge/sources.py +281 -0
- ruleforge_python-0.1.1.dist-info/LICENSE +42 -0
- ruleforge_python-0.1.1.dist-info/METADATA +200 -0
- ruleforge_python-0.1.1.dist-info/RECORD +20 -0
- ruleforge_python-0.1.1.dist-info/THIRD_PARTY_NOTICES.md +34 -0
- ruleforge_python-0.1.1.dist-info/WHEEL +5 -0
- ruleforge_python-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Ruleforge Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .alerts import Alert, parse_alert_line
|
|
5
|
+
from .binary import BinaryResolutionError, resolve_policy_exe
|
|
6
|
+
from .cli import (
|
|
7
|
+
RunMetrics,
|
|
8
|
+
RunResult,
|
|
9
|
+
ServiceHandle,
|
|
10
|
+
bundle_build,
|
|
11
|
+
bundle_inspect,
|
|
12
|
+
fmt,
|
|
13
|
+
run,
|
|
14
|
+
serve,
|
|
15
|
+
test,
|
|
16
|
+
)
|
|
17
|
+
from .async_service import AsyncRuleforgeService
|
|
18
|
+
from .retry_queue import RetryQueueSink
|
|
19
|
+
from .service import RuleforgeService, ServeOptions
|
|
20
|
+
from .sinks import AsyncAlertSink, AlertSink, FileJsonlSink, StdoutSink, WebhookSink
|
|
21
|
+
from .sources import SourceDefaults, SourceSpec, SourcesConfig
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"Alert",
|
|
26
|
+
"AlertSink",
|
|
27
|
+
"AsyncAlertSink",
|
|
28
|
+
"AsyncRuleforgeService",
|
|
29
|
+
"BinaryResolutionError",
|
|
30
|
+
"FileJsonlSink",
|
|
31
|
+
"RetryQueueSink",
|
|
32
|
+
"RunMetrics",
|
|
33
|
+
"RunResult",
|
|
34
|
+
"RuleforgeService",
|
|
35
|
+
"ServeOptions",
|
|
36
|
+
"ServiceHandle",
|
|
37
|
+
"SourceDefaults",
|
|
38
|
+
"SourceSpec",
|
|
39
|
+
"SourcesConfig",
|
|
40
|
+
"StdoutSink",
|
|
41
|
+
"WebhookSink",
|
|
42
|
+
"bundle_build",
|
|
43
|
+
"bundle_inspect",
|
|
44
|
+
"fmt",
|
|
45
|
+
"parse_alert_line",
|
|
46
|
+
"resolve_policy_exe",
|
|
47
|
+
"run",
|
|
48
|
+
"serve",
|
|
49
|
+
"test",
|
|
50
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Alert models and parsing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Alert:
|
|
12
|
+
ts: str
|
|
13
|
+
source_id: str
|
|
14
|
+
source: str
|
|
15
|
+
rule: str
|
|
16
|
+
severity: str
|
|
17
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
18
|
+
emit: Mapping[str, Any] = field(default_factory=dict)
|
|
19
|
+
because: Mapping[str, Any] | None = None
|
|
20
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "Alert":
|
|
24
|
+
tags: tuple[str, ...] = tuple(
|
|
25
|
+
str(tag) for tag in payload.get("tags", []) if isinstance(tag, (str, int, float))
|
|
26
|
+
)
|
|
27
|
+
emit = payload.get("emit")
|
|
28
|
+
if not isinstance(emit, Mapping):
|
|
29
|
+
emit = {}
|
|
30
|
+
because = payload.get("because")
|
|
31
|
+
if because is not None and not isinstance(because, Mapping):
|
|
32
|
+
because = None
|
|
33
|
+
|
|
34
|
+
return cls(
|
|
35
|
+
ts=str(payload.get("ts", "")),
|
|
36
|
+
source_id=str(payload.get("source_id", "")),
|
|
37
|
+
source=str(payload.get("source", "")),
|
|
38
|
+
rule=str(payload.get("rule", "")),
|
|
39
|
+
severity=str(payload.get("severity", "")),
|
|
40
|
+
tags=tags,
|
|
41
|
+
emit=emit,
|
|
42
|
+
because=because,
|
|
43
|
+
raw=dict(payload),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
data = dict(self.raw)
|
|
48
|
+
if data:
|
|
49
|
+
return data
|
|
50
|
+
return {
|
|
51
|
+
"ts": self.ts,
|
|
52
|
+
"source_id": self.source_id,
|
|
53
|
+
"source": self.source,
|
|
54
|
+
"rule": self.rule,
|
|
55
|
+
"severity": self.severity,
|
|
56
|
+
"tags": list(self.tags),
|
|
57
|
+
"emit": dict(self.emit),
|
|
58
|
+
"because": None if self.because is None else dict(self.because),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def to_json(self) -> str:
|
|
62
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_alert_line(line: str) -> Alert | None:
|
|
66
|
+
"""Parse a single JSONL line from policy output.
|
|
67
|
+
|
|
68
|
+
Returns Alert for match records, otherwise None.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
text = line.strip()
|
|
72
|
+
if not text or not text.startswith("{"):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(text)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
if not isinstance(payload, dict):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if "rule" not in payload or "severity" not in payload:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return Alert.from_dict(payload)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Async wrapper for RuleforgeService."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from .alerts import Alert
|
|
11
|
+
from .service import RuleforgeService, ServeOptions
|
|
12
|
+
from .sinks import AlertSink
|
|
13
|
+
|
|
14
|
+
_SENTINEL = object()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncRuleforgeService:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
sources_config: str | Path,
|
|
22
|
+
sources_status: str | Path | None = None,
|
|
23
|
+
policy_path: str | Path | None = None,
|
|
24
|
+
sinks: Sequence[AlertSink] | None = None,
|
|
25
|
+
options: ServeOptions | None = None,
|
|
26
|
+
extra_args: Sequence[str] | None = None,
|
|
27
|
+
serve_kwargs: Mapping[str, Any] | None = None,
|
|
28
|
+
cwd: str | Path | None = None,
|
|
29
|
+
env: Mapping[str, str] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._service = RuleforgeService.from_sources_config(
|
|
32
|
+
sources_config=sources_config,
|
|
33
|
+
sources_status=sources_status,
|
|
34
|
+
policy_path=policy_path,
|
|
35
|
+
sinks=sinks,
|
|
36
|
+
options=options,
|
|
37
|
+
extra_args=extra_args,
|
|
38
|
+
serve_kwargs=serve_kwargs,
|
|
39
|
+
cwd=cwd,
|
|
40
|
+
env=env,
|
|
41
|
+
)
|
|
42
|
+
self._bridge_stop = threading.Event()
|
|
43
|
+
self._bridge_thread: threading.Thread | None = None
|
|
44
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
45
|
+
self._queue: asyncio.Queue[Alert | object] = asyncio.Queue()
|
|
46
|
+
|
|
47
|
+
async def start(self) -> None:
|
|
48
|
+
if self._bridge_thread and self._bridge_thread.is_alive():
|
|
49
|
+
return
|
|
50
|
+
self._drain_queue()
|
|
51
|
+
self._loop = asyncio.get_running_loop()
|
|
52
|
+
self._bridge_stop.clear()
|
|
53
|
+
self._service.start()
|
|
54
|
+
self._bridge_thread = threading.Thread(
|
|
55
|
+
target=self._bridge_alerts,
|
|
56
|
+
name="ruleforge-async-bridge",
|
|
57
|
+
daemon=True,
|
|
58
|
+
)
|
|
59
|
+
self._bridge_thread.start()
|
|
60
|
+
|
|
61
|
+
async def stop(self) -> None:
|
|
62
|
+
self._bridge_stop.set()
|
|
63
|
+
self._service.stop()
|
|
64
|
+
await asyncio.to_thread(self._service.wait)
|
|
65
|
+
if self._bridge_thread is not None:
|
|
66
|
+
self._bridge_thread.join(timeout=2.0)
|
|
67
|
+
|
|
68
|
+
async def wait(self, timeout: float | None = None) -> bool:
|
|
69
|
+
return await asyncio.to_thread(self._service.wait, timeout)
|
|
70
|
+
|
|
71
|
+
async def next_alert(self, timeout: float | None = None) -> Alert | None:
|
|
72
|
+
if timeout is None:
|
|
73
|
+
item = await self._queue.get()
|
|
74
|
+
else:
|
|
75
|
+
item = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
|
76
|
+
if item is _SENTINEL:
|
|
77
|
+
return None
|
|
78
|
+
return item
|
|
79
|
+
|
|
80
|
+
async def alerts(self):
|
|
81
|
+
while True:
|
|
82
|
+
item = await self._queue.get()
|
|
83
|
+
if item is _SENTINEL:
|
|
84
|
+
break
|
|
85
|
+
yield item
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def last_exit_code(self) -> int | None:
|
|
89
|
+
return self._service.last_exit_code
|
|
90
|
+
|
|
91
|
+
def _bridge_alerts(self) -> None:
|
|
92
|
+
for alert in self._service.iter_alerts():
|
|
93
|
+
if self._bridge_stop.is_set():
|
|
94
|
+
break
|
|
95
|
+
if self._loop is None:
|
|
96
|
+
break
|
|
97
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, alert)
|
|
98
|
+
if self._loop is not None:
|
|
99
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, _SENTINEL)
|
|
100
|
+
|
|
101
|
+
def _drain_queue(self) -> None:
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
self._queue.get_nowait()
|
|
105
|
+
except asyncio.QueueEmpty:
|
|
106
|
+
return
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
The host-platform `policy` executable is staged here during wheel packaging.
|
|
2
|
+
|
|
3
|
+
Development helper:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
python python/scripts/build_release_artifacts.py \
|
|
7
|
+
--policy-binary-path <path-to-policy-binary> \
|
|
8
|
+
--output-root <build-output-root> \
|
|
9
|
+
--skip-tests
|
|
10
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Policy executable resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from importlib import resources
|
|
16
|
+
except ImportError: # pragma: no cover
|
|
17
|
+
import importlib_resources as resources # type: ignore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BinaryResolutionError(RuntimeError):
|
|
21
|
+
"""Raised when no usable policy executable can be resolved."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class BinaryResolution:
|
|
26
|
+
path: Path
|
|
27
|
+
source: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_EMBEDDED_CACHE_PATH: Optional[Path] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_policy_exe(explicit_path: str | os.PathLike[str] | None = None) -> Path:
|
|
34
|
+
"""Resolve policy executable path.
|
|
35
|
+
|
|
36
|
+
Resolution order:
|
|
37
|
+
1) explicit path
|
|
38
|
+
2) RULEFORGE_POLICY_PATH
|
|
39
|
+
3) embedded package binary
|
|
40
|
+
4) PATH lookup (platform-specific names)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
resolved = resolve_policy(explicit_path)
|
|
44
|
+
return resolved.path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_policy(explicit_path: str | os.PathLike[str] | None = None) -> BinaryResolution:
|
|
48
|
+
if explicit_path:
|
|
49
|
+
explicit = Path(explicit_path).expanduser().resolve()
|
|
50
|
+
_validate_executable(explicit, "explicit path")
|
|
51
|
+
return BinaryResolution(path=explicit, source="explicit")
|
|
52
|
+
|
|
53
|
+
env_path = os.environ.get("RULEFORGE_POLICY_PATH")
|
|
54
|
+
if env_path:
|
|
55
|
+
env_candidate = Path(env_path).expanduser().resolve()
|
|
56
|
+
_validate_executable(env_candidate, "RULEFORGE_POLICY_PATH")
|
|
57
|
+
return BinaryResolution(path=env_candidate, source="env")
|
|
58
|
+
|
|
59
|
+
embedded = _resolve_embedded_policy()
|
|
60
|
+
if embedded is not None:
|
|
61
|
+
return BinaryResolution(path=embedded, source="embedded")
|
|
62
|
+
|
|
63
|
+
for name in _path_lookup_names():
|
|
64
|
+
found = shutil.which(name)
|
|
65
|
+
if found:
|
|
66
|
+
candidate = Path(found).expanduser().resolve()
|
|
67
|
+
_validate_executable(candidate, "PATH")
|
|
68
|
+
return BinaryResolution(path=candidate, source="path")
|
|
69
|
+
|
|
70
|
+
raise BinaryResolutionError(
|
|
71
|
+
"Unable to locate policy executable. Provide policy_path, set "
|
|
72
|
+
"RULEFORGE_POLICY_PATH, install a wheel that bundles a platform-native "
|
|
73
|
+
"policy binary, or add policy to PATH."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_executable(path: Path, source: str) -> None:
|
|
78
|
+
if not path.exists():
|
|
79
|
+
raise BinaryResolutionError(f"Policy executable from {source} does not exist: {path}")
|
|
80
|
+
if path.is_dir():
|
|
81
|
+
raise BinaryResolutionError(f"Policy executable from {source} is a directory: {path}")
|
|
82
|
+
if os.name != "nt" and not os.access(path, os.X_OK):
|
|
83
|
+
raise BinaryResolutionError(
|
|
84
|
+
f"Policy executable from {source} is not executable on this platform: {path}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _embedded_binary_name() -> str:
|
|
89
|
+
return "policy.exe" if os.name == "nt" else "policy"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _alternate_embedded_binary_name() -> str:
|
|
93
|
+
return "policy" if os.name == "nt" else "policy.exe"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _path_lookup_names() -> tuple[str, ...]:
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
return ("policy.exe", "policy")
|
|
99
|
+
return ("policy", "policy.exe")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_embedded_policy() -> Optional[Path]:
|
|
103
|
+
global _EMBEDDED_CACHE_PATH
|
|
104
|
+
|
|
105
|
+
if _EMBEDDED_CACHE_PATH is not None and _EMBEDDED_CACHE_PATH.exists():
|
|
106
|
+
return _EMBEDDED_CACHE_PATH
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
package_root = resources.files("ruleforge")
|
|
110
|
+
except ModuleNotFoundError:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
binary_name = _embedded_binary_name()
|
|
114
|
+
traversable = package_root.joinpath(f"bin/{binary_name}")
|
|
115
|
+
if not traversable.is_file():
|
|
116
|
+
alt_name = _alternate_embedded_binary_name()
|
|
117
|
+
alt_traversable = package_root.joinpath(f"bin/{alt_name}")
|
|
118
|
+
if alt_traversable.is_file():
|
|
119
|
+
raise BinaryResolutionError(
|
|
120
|
+
"Embedded policy binary does not match this platform "
|
|
121
|
+
f"(found {alt_name}, expected {binary_name}). Install a "
|
|
122
|
+
"wheel built for your OS/architecture."
|
|
123
|
+
)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
data = traversable.read_bytes()
|
|
127
|
+
digest = hashlib.sha256(data).hexdigest()[:16]
|
|
128
|
+
cache_dir = Path(tempfile.gettempdir()) / "ruleforge-python" / digest
|
|
129
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
materialized = cache_dir / binary_name
|
|
131
|
+
if not materialized.exists():
|
|
132
|
+
materialized.write_bytes(data)
|
|
133
|
+
if os.name != "nt":
|
|
134
|
+
materialized.chmod(materialized.stat().st_mode | stat.S_IEXEC)
|
|
135
|
+
|
|
136
|
+
_EMBEDDED_CACHE_PATH = materialized
|
|
137
|
+
return materialized
|