webui (3)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user