audit-framework-jsonl 0.1.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.
- audit_framework_jsonl/__init__.py +18 -0
- audit_framework_jsonl/plugin.py +20 -0
- audit_framework_jsonl/sink.py +135 -0
- audit_framework_jsonl-0.1.0.dist-info/METADATA +84 -0
- audit_framework_jsonl-0.1.0.dist-info/RECORD +7 -0
- audit_framework_jsonl-0.1.0.dist-info/WHEEL +4 -0
- audit_framework_jsonl-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""audit-framework-jsonl — append-only JSONL file sink for audit-framework.
|
|
2
|
+
|
|
3
|
+
The reference :class:`~audit_framework.core.ports.ExternalSink` implementation:
|
|
4
|
+
copy ``sink.py`` as the template for your own sink (Splunk HEC, Elasticsearch,
|
|
5
|
+
syslog, …).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from audit_framework_jsonl.plugin import register
|
|
9
|
+
from audit_framework_jsonl.sink import JsonlFileSink
|
|
10
|
+
|
|
11
|
+
import importlib.metadata as _md
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = _md.version("audit-framework-jsonl")
|
|
15
|
+
except _md.PackageNotFoundError: # running from source without an install
|
|
16
|
+
__version__ = "0.0.0+unknown"
|
|
17
|
+
|
|
18
|
+
__all__ = ["JsonlFileSink", "register", "__version__"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Plugin registration for the JSONL file sink.
|
|
2
|
+
|
|
3
|
+
The core never imports this module directly; it is reached either via the
|
|
4
|
+
``audit_framework.plugins`` entry point declared in ``pyproject.toml`` (picked
|
|
5
|
+
up by :meth:`PluginRegistry.discover_entrypoints`) or by an explicit module
|
|
6
|
+
path passed to :meth:`PluginRegistry.load_from_config`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from audit_framework_jsonl.sink import JsonlFileSink
|
|
14
|
+
|
|
15
|
+
__all__ = ["register"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(registry: Any) -> None:
|
|
19
|
+
"""Register :class:`JsonlFileSink` as the ``file_jsonl`` external sink."""
|
|
20
|
+
registry.register("external_sink", "file_jsonl", JsonlFileSink)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""JsonlFileSink — an append-only JSON-Lines :class:`ExternalSink`.
|
|
2
|
+
|
|
3
|
+
This is the **reference sink implementation** for the audit-framework plugin
|
|
4
|
+
system: the simplest possible adapter, written so a customer can copy it as the
|
|
5
|
+
template for their own sink (Splunk HEC, Elasticsearch, syslog, …). The recipe
|
|
6
|
+
every sink follows:
|
|
7
|
+
|
|
8
|
+
1. Expose a stable :pyattr:`sink_name` (matched against ``AuditPolicy.sinks``).
|
|
9
|
+
2. Implement ``async emit(event, context)`` — forward one event, best-effort.
|
|
10
|
+
3. Implement ``async health_check()`` — report whether the downstream is usable.
|
|
11
|
+
4. Register the class in a ``register(registry)`` function (see ``plugin.py``).
|
|
12
|
+
|
|
13
|
+
This sink appends one compact JSON object per line to a file. Writes are
|
|
14
|
+
serialised with an :class:`asyncio.Lock` and offloaded with
|
|
15
|
+
:func:`asyncio.to_thread`, so concurrent ``emit`` calls neither interleave nor
|
|
16
|
+
block the event loop. Optional rotation is supported by date (one file per UTC
|
|
17
|
+
day) and/or by size (roll the current file aside once it would exceed a byte
|
|
18
|
+
threshold).
|
|
19
|
+
|
|
20
|
+
Only the standard library is used here; the ``audit_framework`` import is for
|
|
21
|
+
type hints and is part of this plugin's declared dependency.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable, Optional
|
|
32
|
+
|
|
33
|
+
from audit_framework.core.models import AuditEvent, PipelineContext
|
|
34
|
+
|
|
35
|
+
__all__ = ["JsonlFileSink"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _utc_now() -> datetime:
|
|
39
|
+
return datetime.now(timezone.utc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JsonlFileSink:
|
|
43
|
+
"""Appends each audit event as one JSON line to a file.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
path:
|
|
48
|
+
Destination file (its parent directories are created on demand).
|
|
49
|
+
name:
|
|
50
|
+
The :pyattr:`sink_name` used for per-policy sink filtering.
|
|
51
|
+
daily:
|
|
52
|
+
When True, write to a date-stamped file ``<stem>-YYYY-MM-DD<suffix>``
|
|
53
|
+
so each UTC day gets its own file.
|
|
54
|
+
max_bytes:
|
|
55
|
+
When set, roll the current file aside (``<stem>.<timestamp><suffix>``)
|
|
56
|
+
before a write that would push it past this size. Composes with
|
|
57
|
+
``daily``.
|
|
58
|
+
clock:
|
|
59
|
+
Injectable time source (returns an aware :class:`datetime`); used for
|
|
60
|
+
rotation stamps. Defaults to ``datetime.now(timezone.utc)``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
path: str | os.PathLike[str],
|
|
66
|
+
*,
|
|
67
|
+
name: str = "file_jsonl",
|
|
68
|
+
daily: bool = False,
|
|
69
|
+
max_bytes: Optional[int] = None,
|
|
70
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._base = Path(path)
|
|
73
|
+
self._name = name
|
|
74
|
+
self._daily = daily
|
|
75
|
+
self._max_bytes = max_bytes
|
|
76
|
+
self._clock = clock
|
|
77
|
+
self._lock = asyncio.Lock()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def sink_name(self) -> str:
|
|
81
|
+
"""Stable identifier matched against ``AuditPolicy.sinks``."""
|
|
82
|
+
return self._name
|
|
83
|
+
|
|
84
|
+
async def emit(self, event: AuditEvent, context: PipelineContext) -> None:
|
|
85
|
+
"""Append ``event`` (as one JSON line) to the current target file.
|
|
86
|
+
|
|
87
|
+
Best-effort and serialised: a raised OSError propagates so the
|
|
88
|
+
``SinkFanOutMiddleware`` can record the failure, but it never corrupts a
|
|
89
|
+
concurrent write (the lock guarantees one writer at a time).
|
|
90
|
+
"""
|
|
91
|
+
line = json.dumps(
|
|
92
|
+
event.to_dict(), separators=(",", ":"), ensure_ascii=False, default=str
|
|
93
|
+
)
|
|
94
|
+
async with self._lock:
|
|
95
|
+
await asyncio.to_thread(self._write, line)
|
|
96
|
+
|
|
97
|
+
async def health_check(self) -> bool:
|
|
98
|
+
"""Return True if the target directory exists (or can be) and is writable."""
|
|
99
|
+
return await asyncio.to_thread(self._check_writable)
|
|
100
|
+
|
|
101
|
+
# ----------------------------------------------------------------- #
|
|
102
|
+
# Blocking helpers (run inside asyncio.to_thread) #
|
|
103
|
+
# ----------------------------------------------------------------- #
|
|
104
|
+
def _write(self, line: str) -> None:
|
|
105
|
+
path = self._target_path()
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
if self._max_bytes is not None and path.exists():
|
|
108
|
+
projected = path.stat().st_size + len(line.encode("utf-8")) + 1
|
|
109
|
+
if projected > self._max_bytes:
|
|
110
|
+
self._rollover(path)
|
|
111
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
112
|
+
fh.write(line + "\n")
|
|
113
|
+
|
|
114
|
+
def _check_writable(self) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
parent = self._base.parent
|
|
117
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
return os.access(parent, os.W_OK)
|
|
119
|
+
except OSError:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def _target_path(self) -> Path:
|
|
123
|
+
if not self._daily:
|
|
124
|
+
return self._base
|
|
125
|
+
stamp = self._clock().strftime("%Y-%m-%d")
|
|
126
|
+
return self._base.with_name(f"{self._base.stem}-{stamp}{self._base.suffix}")
|
|
127
|
+
|
|
128
|
+
def _rollover(self, path: Path) -> None:
|
|
129
|
+
stamp = self._clock().strftime("%Y%m%dT%H%M%S")
|
|
130
|
+
target = path.with_name(f"{path.stem}.{stamp}{path.suffix}")
|
|
131
|
+
suffix = 0
|
|
132
|
+
while target.exists(): # never clobber an existing rolled file
|
|
133
|
+
suffix += 1
|
|
134
|
+
target = path.with_name(f"{path.stem}.{stamp}.{suffix}{path.suffix}")
|
|
135
|
+
path.rename(target)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audit-framework-jsonl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Append-only JSONL file ExternalSink for audit-framework — the reference sink plugin.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vanmarkic/audit-logger
|
|
6
|
+
Project-URL: Repository, https://github.com/vanmarkic/audit-logger
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: audit,audit-log,jsonl,plugin,siem,sink
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: audit-framework<0.2,>=0.1
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# audit-framework-jsonl
|
|
16
|
+
|
|
17
|
+
An append-only **JSON-Lines file sink** for
|
|
18
|
+
[`audit-framework`](../audit-framework) — and the **reference implementation**
|
|
19
|
+
every other `ExternalSink` (Splunk HEC, Elasticsearch, syslog, …) can be copied
|
|
20
|
+
from.
|
|
21
|
+
|
|
22
|
+
It implements the `ExternalSink` port: one compact JSON object per line,
|
|
23
|
+
appended to a file. Writes are serialised and offloaded off the event loop, with
|
|
24
|
+
optional rotation by date and/or size.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install audit-framework-jsonl
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from audit_framework_jsonl.sink import JsonlFileSink
|
|
36
|
+
from audit_framework.core.middlewares.sink_fanout import SinkFanOutMiddleware
|
|
37
|
+
|
|
38
|
+
sink = JsonlFileSink("/var/log/audit/audit.jsonl", daily=True, max_bytes=50_000_000)
|
|
39
|
+
pipeline.use(SinkFanOutMiddleware([sink])) # fan events out to this sink
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or wire it by configuration through the registry — it advertises itself under
|
|
43
|
+
the `audit_framework.plugins` entry point as the `file_jsonl` external sink:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
registry.discover_entrypoints() # finds file_jsonl
|
|
47
|
+
SinkClass = registry.get("external_sink", "file_jsonl")
|
|
48
|
+
sink = SinkClass("/var/log/audit/audit.jsonl")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each emitted line is `event.to_dict()` serialised compactly, e.g.:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{"actor_id":"alice","action":"DELETE","resource_type":"contract","resource_id":"c-42",...}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Options
|
|
58
|
+
|
|
59
|
+
| Param | Effect |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `name` | The `sink_name` used for per-policy sink filtering (default `"file_jsonl"`). |
|
|
62
|
+
| `daily` | Write to a date-stamped file `audit-YYYY-MM-DD.jsonl` (one per UTC day). |
|
|
63
|
+
| `max_bytes` | Roll the current file aside (`audit.<timestamp>.jsonl`) before it exceeds this size. Composes with `daily`. |
|
|
64
|
+
| `clock` | Injectable time source for rotation stamps (testing). |
|
|
65
|
+
|
|
66
|
+
## Writing your own sink
|
|
67
|
+
|
|
68
|
+
`sink.py` is deliberately tiny — copy it and change four things:
|
|
69
|
+
|
|
70
|
+
1. a stable `sink_name` (matched against `AuditPolicy.sinks`),
|
|
71
|
+
2. `async emit(event, context)` — forward `event.to_dict()` to your platform (best-effort; raise on permanent failure so the pipeline records it),
|
|
72
|
+
3. `async health_check()` — report reachability,
|
|
73
|
+
4. a `register(registry)` that calls `registry.register("external_sink", "<name>", YourSink)` (+ an `audit_framework.plugins` entry point in `pyproject.toml`).
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e ".[dev]"
|
|
79
|
+
pytest # 9 stdlib-only tests (tmp files; no infrastructure)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
audit_framework_jsonl/__init__.py,sha256=WGJYvRCkbIduq_J9EoK-NdCgBbpd1ingyLzTAYps0NY,620
|
|
2
|
+
audit_framework_jsonl/plugin.py,sha256=NictSLREHWUCswa4hjTHFtck3zi6_-_CHoCM3P2y_hY,662
|
|
3
|
+
audit_framework_jsonl/sink.py,sha256=wnuXm4HVKkB_oX_GqHqoXWFf7qVmaKOFtbsILynjMa0,5175
|
|
4
|
+
audit_framework_jsonl-0.1.0.dist-info/METADATA,sha256=otv8QdTsRnxOPhT-BQ7GB8572NbyRXlwvIoPenApV90,2925
|
|
5
|
+
audit_framework_jsonl-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
audit_framework_jsonl-0.1.0.dist-info/entry_points.txt,sha256=NKdDnahYuY9Hcl4awH4ANCxV6IUKfpZxGAuOToKpi9M,77
|
|
7
|
+
audit_framework_jsonl-0.1.0.dist-info/RECORD,,
|