webui (3)

This commit is contained in:
Ludwig Lehnert
2026-07-31 20:23:52 +00:00
parent 055f318e52
commit 17f5ef8560
13 changed files with 222 additions and 56 deletions
+1
View File
@@ -50,3 +50,4 @@ ACME_HTTP_PORT=80
# BACKUP_STATUS_FILE=/state/backup-status.json
# AUDIT_COMPRESS_AFTER_HOURS=24
# AUDIT_QUERY_MAX_DAYS=31
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
+1
View File
@@ -28,6 +28,7 @@ COPY --from=step-cli /usr/local/bin/step /usr/local/bin/step
COPY app/reconcile_shares.py /app/reconcile_shares.py
COPY app/backup_to_destination.py /app/backup_to_destination.py
COPY app/audit_policy.py /app/audit_policy.py
COPY app/audit_collector.py /app/audit_collector.py
COPY app/web_ui.py /app/web_ui.py
COPY app/web /app/web
+9 -7
View File
@@ -22,8 +22,8 @@ This repository provides a production-oriented Samba file server container that
- Setup prompts for well-known authorization groups by SID (`DOMAIN_USERS_SID`, `DOMAIN_ADMINS_SID`) to avoid localized group names.
- `FSLOGIX_GROUP_SID` controls who can access the default FSLogix share (defaults to `DOMAIN_USERS_SID`).
- Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules.
- Share operations are audited with Samba `full_audit` (connect, list, read, write, create, delete, rename).
- A collector persists every `full_audit` record in daily NDJSON files under `/state/audit`; closed files are gzip-compressed and are never automatically deleted.
- Samba `full_audit` is restricted to successful and failed reads, writes, renames/moves, and deletions.
- A collector normalizes those four actions and persists them in daily NDJSON files under `/state/audit`; closed files are gzip-compressed and are never automatically deleted.
- A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health.
- Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation.
- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported.
@@ -121,7 +121,7 @@ The launcher builds the current application and starts an isolated, run-scoped n
- a real Smallstep CA whose ACME provisioner issues the web UI certificate for `files.localhost`;
- an authenticated rsync daemon used by the normal backup implementation;
- the actual file-server image, joined to the dummy domain;
- a continuous SMB client that reads, writes, and lists files as several domain users.
- a continuous SMB client that exercises reads, writes, renames/moves, and deletions, including activity from an excluded dummy service account.
The preview starts with group, Private, FSLogix, and historical audit data. The client keeps current-day audit activity moving, while a real backup runs immediately and repeats in the background. Open the URL and use the credentials printed by the launcher. Defaults are:
@@ -174,7 +174,7 @@ The E2E suite verifies:
- domain trust, group-to-folder mapping, nested and transitive group trees;
- SMB allow/deny behavior and real file operations;
- Data, Private, and FSLogix usage aggregation;
- live `full_audit` ingestion, filters, facets, and pagination;
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
- compression and querying of a closed daily audit log;
- real rsync transfer progress, completed backup status, log output, and remote snapshot marker;
- overview and system-health aggregation.
@@ -374,20 +374,22 @@ Useful optional settings:
| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit |
| `AUDIT_COMPRESS_AFTER_HOURS` | `24` | Minimum idle age before compressing a closed day |
| `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window |
| `AUDIT_SKIP_USER_SUFFIXES` | `_svc,_ServiceAcc` | Case-insensitive account suffixes excluded from collection and queries; set empty to disable |
If `WEB_ENABLED` is absent on an upgraded installation and no TLS settings/certificate exist, the web service stays disabled while Samba and audit collection continue. Set `WEB_ENABLED=true` after adding TLS configuration.
## Audit Archive
Samba already produced `full_audit` messages before this web UI was added. The collector now makes them durable:
Samba emits only the selected high-level `full_audit` operations, and the collector makes them durable:
- it tails every `/var/log/samba/log.*` source, remembers inode and byte offsets in `/state/audit/collector-state.json`, and follows Samba rotation without duplicating a rotated file;
- each event records timestamp, user, client address/name, share, VFS operation, read/write/list/session category, result, and path;
- each event records timestamp, user, client address/name, share, result, path, and one of the actions `read`, `write`, `move`, or `delete`;
- directory listings, sessions, metadata access, file-open/create noise, and all other VFS operations are discarded; users ending in a configured `AUDIT_SKIP_USER_SUFFIXES` value are also discarded;
- records are appended to `/state/audit/YYYY-MM-DD.jsonl`;
- a closed, idle daily file becomes `.jsonl.gz`;
- no audit retention deletion is performed, so capacity planning for the `state_data` volume is the operator's responsibility.
Collection starts even when the web UI is disabled. Existing Samba log content is imported when the collector first starts, but audit data that Samba rotated away before this version was deployed cannot be recovered.
Collection starts even when the web UI is disabled. Existing Samba log content is imported when the collector first starts, but the same operation and user-suffix policy is applied during import. Audit data that Samba rotated away before this version was deployed cannot be recovered.
## Backups
+11 -24
View File
@@ -12,6 +12,11 @@ import sys
import time
from typing import Dict, Iterable, Optional
try:
from .audit_policy import action_for, skip_user
except ImportError:
from audit_policy import action_for, skip_user
SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*")
ARCHIVE_DIR = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
@@ -69,28 +74,6 @@ def parse_samba_timestamp(raw_line: str, fallback: dt.datetime) -> str:
return fallback.isoformat(timespec="milliseconds")
def action_for(operation: str) -> str:
operation = operation.lower()
if operation in {
"read", "pread", "pread_recv", "pread_send", "recvfile", "sendfile",
"offload_read_recv", "offload_read_send",
}:
return "read"
if operation in {
"write", "pwrite", "pwrite_recv", "pwrite_send", "ftruncate",
"fallocate", "create_file", "mkdirat", "mknodat", "renameat",
"unlinkat", "symlinkat", "linkat", "offload_write_recv",
"offload_write_send", "fsetxattr", "removexattr", "fremovexattr",
"mkdir", "rmdir", "rename", "unlink",
}:
return "write"
if operation in {"opendir", "fdopendir", "readdir", "freaddir_attr", "closedir"}:
return "list"
if operation in {"connect", "disconnect"}:
return "session"
return "metadata"
def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
match = AUDIT_MARKER_RE.search(raw_line)
if match:
@@ -104,16 +87,20 @@ def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
return None
observed_at = utc_now()
operation = fields[5].strip()
action = action_for(operation)
user = fields[1].strip()
if action is None or skip_user(user):
return None
result = fields[6].strip()
return {
"timestamp": parse_samba_timestamp(raw_line, observed_at),
"ingestedAt": observed_at.isoformat(timespec="milliseconds"),
"user": fields[1].strip(),
"user": user,
"clientIp": fields[2].strip(),
"client": fields[3].strip(),
"share": fields[4].strip(),
"operation": operation,
"action": action_for(operation),
"action": action,
"result": result,
"success": result.upper() == "OK",
"path": "|".join(fields[7:]).strip(),
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Shared policy for the small set of user-facing audit events."""
import os
from typing import Optional, Tuple
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
OPERATION_ACTIONS = {
"read": "read",
"pread": "read",
"pread_recv": "read",
"pread_send": "read",
"sendfile": "read",
"offload_read_recv": "read",
"offload_read_send": "read",
"write": "write",
"pwrite": "write",
"pwrite_recv": "write",
"pwrite_send": "write",
"recvfile": "write",
"offload_write_recv": "write",
"offload_write_send": "write",
"renameat": "move",
"rename": "move",
"unlinkat": "delete",
"unlink": "delete",
"rmdir": "delete",
}
def action_for(operation: str) -> Optional[str]:
return OPERATION_ACTIONS.get(operation.strip().casefold())
def skipped_user_suffixes() -> Tuple[str, ...]:
raw = os.getenv("AUDIT_SKIP_USER_SUFFIXES", "_svc,_ServiceAcc")
return tuple(
suffix.strip().casefold()
for suffix in raw.split(",")
if suffix.strip()
)
def account_name(user: str) -> str:
account = user.strip().rsplit("\\", 1)[-1]
return account.split("@", 1)[0]
def skip_user(user: str) -> bool:
account = account_name(user).casefold()
return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
+6 -7
View File
@@ -24,7 +24,7 @@ const utcTime = value => {
const pad = number => String(number).padStart(2, "0");
return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
};
const actionLabel = value => ({read: "Lesen", write: "Schreiben", list: "Auflisten", metadata: "Metadaten", session: "Sitzung"}[value] || value || "—");
const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen/Verschieben", delete: "Löschen"}[value] || value || "—");
const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value);
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
function backupMessage(data) {
@@ -127,7 +127,7 @@ function eventRows(events) {
return events.map(event => `<tr>
<td class="timestamp">${esc(utcTime(event.timestamp))}</td>
<td>${esc(event.user)}</td><td>${esc(event.clientIp)}</td><td>${esc(event.share)}</td>
<td>${badge(actionLabel(event.action || event.operation))}<br><span class="muted">${esc(event.operation)}</span></td>
<td>${badge(actionLabel(event.action || event.operation))}</td>
<td class="path">${esc(event.path || "—")}</td>
<td>${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")}</td>
</tr>`).join("");
@@ -230,20 +230,19 @@ async function renderStorage(type) {
async function renderActivity() {
const today = new Date();
const yesterday = new Date(Date.now() - 86400000);
content.innerHTML = pageHead("Aktivitätsprotokoll", "Aufgezeichnete Samba-Vorgänge nach Datum, Identität, Freigabe, Aktion, Ergebnis oder Pfad durchsuchen.") + `
content.innerHTML = pageHead("Aktivitätsprotokoll", "Lese-, Schreib-, Umbenennungs-/Verschiebe- und Löschvorgänge nach Datum, Identität, Freigabe, Ergebnis oder Pfad durchsuchen.") + `
<section class="panel">
<form id="activity-filter" class="filters">
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label>
<label>Bis (UTC)<input name="to" type="date" value="${isoDay(today)}" required></label>
<label>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="list">Auflisten</option><option value="metadata">Metadaten</option><option value="session">Sitzung</option></select></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="move">Umbenennen/Verschieben</option><option value="delete">Löschen</option></select></label>
<label>Ergebnis<select name="result"><option value="">Alle Ergebnisse</option><option value="OK">Erfolgreich</option><option value="fail">Fehlgeschlagen</option></select></label>
<label>Operation<input name="operation" list="operations-list" placeholder="z. B. pread"></label>
<label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
<button type="submit">Filter anwenden</button>
</form>
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist><datalist id="operations-list"></datalist>
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist>
<p id="activity-summary" class="muted"></p>
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody id="activity-rows"></tbody></table></div>
<p><button id="load-more" hidden>Weitere laden</button></p>
@@ -262,7 +261,7 @@ async function renderActivity() {
const more = document.querySelector("#load-more");
cursor = result.nextCursor || 0;
more.hidden = !result.nextCursor;
for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares], ["operations-list", result.facets.operations]]) {
for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares]]) {
document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `<option value="${esc(value)}">`).join("");
}
};
+22 -4
View File
@@ -30,6 +30,11 @@ try:
except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory
try:
from app.audit_policy import AUDIT_ACTIONS, action_for, skip_user
except ImportError: # Container execution uses /app as the import root.
from audit_policy import AUDIT_ACTIONS, action_for, skip_user
STATE_ROOT = os.getenv("WEB_STATE_DIR", "/state/web")
AUDIT_ROOT = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
@@ -87,7 +92,10 @@ def base64url(value: bytes) -> str:
def base64url_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
if not hmac.compare_digest(base64url(decoded), value):
raise ValueError("non-canonical base64url")
return decoded
class TokenManager:
@@ -589,10 +597,20 @@ def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
if not os.path.isfile(path):
continue
for event in iter_audit_file_reverse(path):
facets["users"].add(str(event.get("user", "")))
user = str(event.get("user", ""))
operation = str(event.get("operation", ""))
action = action_for(operation)
if action is None and not operation:
stored_action = str(event.get("action", "")).casefold()
action = stored_action if stored_action in AUDIT_ACTIONS else None
if action is None or skip_user(user):
continue
if event.get("action") != action:
event = {**event, "action": action}
facets["users"].add(user)
facets["shares"].add(str(event.get("share", "")))
facets["operations"].add(str(event.get("operation", "")))
facets["actions"].add(str(event.get("action", "")))
facets["operations"].add(operation)
facets["actions"].add(action)
failed_filter = filters["result"] == "fail"
if failed_filter and bool(event.get("success", False)):
continue
+2 -1
View File
@@ -98,6 +98,7 @@ ensure_user carol "$AD_USER_PASSWORD" Carol Clark
ensure_user dave "$AD_USER_PASSWORD" Dave Davis
ensure_user eve "$AD_USER_PASSWORD" Eve Evans
ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator
ensure_user report_svc "$AD_USER_PASSWORD" Report Service
for group in Finance_Analysts Engineering_Leads FS_Finance FS_Engineering FS_Projects; do
ensure_group "$group"
@@ -110,7 +111,7 @@ set_display_name FS_Engineering Engineering
set_display_name FS_Projects Projects
add_members Finance_Analysts alice
add_members FS_Finance 'Finance_Analysts,bob'
add_members FS_Finance 'Finance_Analysts,bob,report_svc'
add_members Engineering_Leads carol
add_members FS_Engineering 'Engineering_Leads,dave'
add_members FS_Projects 'FS_Finance,FS_Engineering,eve'
+30 -4
View File
@@ -262,16 +262,42 @@ def main() -> int:
check(int(users.get("alice", {}).get("fslogixBytes", 0)) > 0, "Alice FSLogix usage missing")
check(int(storage_payload.get("totals", {}).get("dataBytes", 0)) > 0, "Data total is empty")
announce("live Samba audit ingestion, filters, facets, and pagination")
announce("high-level Samba audit ingestion, suffix exclusions, filters, facets, and pagination")
required_actions = {"read", "write", "move", "delete"}
activity = eventually(
"live alice audit records",
lambda: http(query_path("/api/activity", {"user": "alice", "limit": "5"}), token=token),
lambda response: response.status == 200 and response.json().get("matched", 0) >= 2,
"all four live alice audit actions",
lambda: http(query_path("/api/activity", {"user": "alice", "limit": "100"}), token=token),
lambda response: (
response.status == 200
and required_actions.issubset(
{event.get("action") for event in response.json().get("events", [])}
)
),
timeout=60,
)
activity_payload = activity.json()
alice_actions = {event.get("action") for event in activity_payload["events"]}
check(all("alice" in str(event.get("user", "")).casefold() for event in activity_payload["events"]), "activity user filter leaked unrelated events")
check(alice_actions == required_actions, f"unexpected alice audit actions: {sorted(alice_actions)}")
check(set(activity_payload.get("facets", {}).get("actions", [])) <= required_actions, "low-level activity category leaked into facets")
check("Data" in activity_payload.get("facets", {}).get("shares", []), "activity share facets missing Data")
moved = http(query_path("/api/activity", {"user": "alice", "action": "move", "limit": "100"}), token=token).json()
check(moved.get("matched", 0) >= 1, "move action filter returned no event")
check(all(event.get("action") == "move" for event in moved.get("events", [])), "move action filter leaked another action")
eventually(
"raw service-account SMB audit source",
lambda: engine_run(
"exec", FILES_CONTAINER, "grep", "-R", "report_svc", "/var/log/samba",
check_result=False,
).returncode,
lambda returncode: returncode == 0,
timeout=30,
)
service_activity = http(query_path("/api/activity", {"user": "report_svc", "limit": "100"}), token=token).json()
check(service_activity.get("matched") == 0, "_svc account was persisted in the activity archive")
one_event = http(query_path("/api/activity", {"limit": "1"}), token=token).json()
check(len(one_event.get("events", [])) == 1, "activity limit was ignored")
check(one_event.get("nextCursor") is not None, "activity pagination cursor missing")
+3 -1
View File
@@ -35,10 +35,12 @@ fi
printf 'Generated by the preview SMB client.\n' > /tmp/live-note.txt
smb alice Data 'cd Finance; mkdir Reports; cd Reports; put /tmp/live-note.txt live-note.txt; ls' >/dev/null
smb alice Data 'cd Finance; cd Reports; put /tmp/live-note.txt audit-source.txt; get audit-source.txt /tmp/audit-readback.txt; rename audit-source.txt audit-moved.txt; del audit-moved.txt' >/dev/null
smb carol Data 'cd Engineering; mkdir Designs; cd Designs; put /tmp/live-note.txt architecture.txt; ls' >/dev/null
smb alice Private 'cd alice; put /tmp/live-note.txt notes.txt; ls' >/dev/null
smb report_svc Data 'cd Finance; cd Reports; put /tmp/live-note.txt ignored-service-event.txt; get ignored-service-event.txt /tmp/ignored-service-readback.txt; del ignored-service-event.txt' >/dev/null
touch /run/preview-client-ready
log 'Initial SMB reads and writes complete; generating live activity'
log 'Initial SMB file operations complete; generating live activity'
counter=0
while true; do
+6 -6
View File
@@ -44,8 +44,8 @@
guest ok = no
vfs objects = acl_xattr full_audit
full_audit:prefix = %T|%u|%I|%m|%S
full_audit:success = all
full_audit:failure = all
full_audit:success = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:failure = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:syslog = false
valid users = @"${DOMAIN_USERS_GROUP}"
hide unreadable = yes
@@ -60,8 +60,8 @@
guest ok = no
vfs objects = acl_xattr full_audit
full_audit:prefix = %T|%u|%I|%m|%S
full_audit:success = all
full_audit:failure = all
full_audit:success = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:failure = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:syslog = false
valid users = @"${DOMAIN_USERS_GROUP}"
create mask = 0660
@@ -87,8 +87,8 @@
guest ok = no
vfs objects = acl_xattr full_audit
full_audit:prefix = %T|%u|%I|%m|%S
full_audit:success = all
full_audit:failure = all
full_audit:success = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:failure = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
full_audit:syslog = false
valid users = @"${FSLOGIX_GROUP}"
force group = "${FSLOGIX_GROUP}"
+1
View File
@@ -413,6 +413,7 @@ ACME_HTTP_PORT=${acme_http_port}
# WEB_DIRECTORY_CACHE_SECONDS=300
# AUDIT_COMPRESS_AFTER_HOURS=24
# AUDIT_QUERY_MAX_DAYS=31
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
# ACME_RENEW_CHECK_SECONDS=900
# WEB_TLS_CERT_FILE=/state/tls/web.crt
# WEB_TLS_KEY_FILE=/state/tls/web.key
+77 -2
View File
@@ -92,6 +92,44 @@ class AuditParsingTests(unittest.TestCase):
self.assertEqual(event["path"], "/data/private/alice/notes.txt")
self.assertTrue(event["success"])
def test_normalizes_only_high_level_file_actions(self):
expected = {
"recvfile": "write",
"renameat": "move",
"unlinkat": "delete",
}
for operation, action in expected.items():
with self.subTest(operation=operation):
line = f"smbd_audit: x|alice|192.0.2.5|PC01|Data|{operation}|OK|file.txt\n"
event = audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
self.assertEqual(event["action"], action)
for operation in ("connect", "readdir", "fstat", "create_file", "fsetxattr"):
with self.subTest(operation=operation):
line = f"smbd_audit: x|alice|192.0.2.5|PC01|Data|{operation}|OK|file.txt\n"
self.assertIsNone(
audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
)
def test_skips_configured_user_suffixes_case_insensitively(self):
with mock.patch.dict(
os.environ,
{"AUDIT_SKIP_USER_SUFFIXES": "_svc,_ServiceAcc"},
):
for user in ("DEV\\backup_SVC", "report_serviceacc@dev.test"):
with self.subTest(user=user):
line = f"smbd_audit: x|{user}|192.0.2.5|PC01|Data|pread|OK|file.txt\n"
self.assertIsNone(
audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
)
with mock.patch.dict(os.environ, {"AUDIT_SKIP_USER_SUFFIXES": ""}):
line = "smbd_audit: x|DEV\\backup_svc|192.0.2.5|PC01|Data|pread|OK|file.txt\n"
self.assertIsNotNone(
audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
)
def test_tracks_rotated_file_by_inode_without_reingesting_it(self):
with tempfile.TemporaryDirectory() as tmpdir:
archive = os.path.join(tmpdir, "audit")
@@ -121,8 +159,8 @@ class AuditQueryTests(unittest.TestCase):
"user": user,
"clientIp": "192.0.2.5",
"share": "Data",
"operation": "pread" if success else "openat",
"action": "read" if success else "metadata",
"operation": "pread" if success else "unlinkat",
"action": "read" if success else "delete",
"path": "folder/file.txt",
"result": "OK" if success else "NT_STATUS_ACCESS_DENIED",
"success": success,
@@ -138,6 +176,10 @@ class AuditQueryTests(unittest.TestCase):
for index in range(3):
handle.write(json.dumps(self.make_event(f"{today}T12:00:0{index}+00:00", "alice")) + "\n")
handle.write(json.dumps(self.make_event(f"{today}T12:00:04+00:00", "bob", False)) + "\n")
ignored = self.make_event(f"{today}T12:00:05+00:00", "metadata-user")
ignored.update({"operation": "create_file", "action": "write"})
handle.write(json.dumps(ignored) + "\n")
handle.write(json.dumps(self.make_event(f"{today}T12:00:06+00:00", "robot_svc")) + "\n")
with gzip.open(old, "wt", encoding="utf-8") as handle:
handle.write(json.dumps(self.make_event(f"{yesterday}T12:00:00+00:00", "alice")) + "\n")
@@ -145,11 +187,15 @@ class AuditQueryTests(unittest.TestCase):
first = web_ui.query_audit({"from": [yesterday.isoformat()], "to": [today.isoformat()], "user": ["alice"], "limit": ["2"]})
failed = web_ui.query_audit({"from": [today.isoformat()], "to": [today.isoformat()], "result": ["fail"]})
second = web_ui.query_audit({"from": [yesterday.isoformat()], "to": [today.isoformat()], "user": ["alice"], "limit": ["2"], "cursor": [str(first["nextCursor"])]})
visible = web_ui.query_audit({"from": [today.isoformat()], "to": [today.isoformat()], "limit": ["100"]})
self.assertEqual(first["matched"], 4)
self.assertEqual(len(first["events"]), 2)
self.assertEqual(len(second["events"]), 2)
self.assertEqual(failed["events"][0]["user"], "bob")
self.assertNotIn("metadata-user", visible["facets"]["users"])
self.assertNotIn("robot_svc", visible["facets"]["users"])
self.assertEqual(set(visible["facets"]["actions"]), {"read", "delete"})
class WebPresentationTests(unittest.TestCase):
@@ -183,6 +229,35 @@ class WebPresentationTests(unittest.TestCase):
self.assertNotIn("localTime", script)
self.assertIn('class="timestamp"', script)
self.assertIn(".timestamp { text-align: left;", css)
self.assertIn("Umbenennen/Verschieben", script)
self.assertIn("Löschen", script)
self.assertNotIn(">Auflisten<", script)
self.assertNotIn(">Metadaten<", script)
self.assertNotIn(">Sitzung<", script)
self.assertNotIn('name="operation"', script)
def test_samba_audits_only_supported_file_operations(self):
path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")
with open(path, encoding="utf-8") as handle:
config = handle.read()
expected = {
"pread", "pread_recv", "read", "sendfile", "offload_read_recv",
"pwrite", "pwrite_recv", "write", "recvfile",
"offload_write_recv", "renameat", "unlinkat",
}
success_lines = [
line for line in config.splitlines()
if line.strip().startswith("full_audit:success =")
]
failure_lines = [
line for line in config.splitlines()
if line.strip().startswith("full_audit:failure =")
]
self.assertEqual(len(success_lines), 3)
self.assertEqual(len(failure_lines), 3)
for line in success_lines + failure_lines:
self.assertEqual(set(line.split("=", 1)[1].split()), expected)
class TlsSummaryTests(unittest.TestCase):