518 lines
17 KiB
Python
Executable File
518 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Protocol, TypeAlias
|
|
|
|
|
|
JsonScalar: TypeAlias = str | int | float | bool | None
|
|
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
|
|
|
|
|
DEFAULT_BUCKET_PREFIXES = ("aw-file-operations_", "aw-dlp-incidents_")
|
|
DEFAULT_SQLITE_PATH = "data/dlp-events.sqlite3"
|
|
EVENT_COLUMNS = (
|
|
"bucket_id",
|
|
"event_id",
|
|
"stream_type",
|
|
"hostname",
|
|
"username",
|
|
"event_ts",
|
|
"duration",
|
|
"operation",
|
|
"file_path",
|
|
"old_file_path",
|
|
"extension",
|
|
"archive_hint",
|
|
"rule_id",
|
|
"action",
|
|
"severity",
|
|
"signal_type",
|
|
"message",
|
|
"source",
|
|
"screenshot_path",
|
|
"raw_json",
|
|
"ingested_at",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Bucket:
|
|
id: str
|
|
type: str
|
|
client: str
|
|
hostname: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AwEvent:
|
|
bucket_id: str
|
|
hostname: str
|
|
stream_type: str
|
|
event_id: str
|
|
timestamp: str
|
|
duration: float
|
|
data: dict[str, JsonValue]
|
|
|
|
|
|
class PsycopgConnection(Protocol):
|
|
def cursor(self):
|
|
...
|
|
|
|
def commit(self) -> None:
|
|
...
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(tz=UTC)
|
|
|
|
|
|
def parse_timestamp(value: str) -> datetime:
|
|
normalized = value.replace("Z", "+00:00")
|
|
parsed = datetime.fromisoformat(normalized)
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=UTC)
|
|
return parsed.astimezone(UTC)
|
|
|
|
|
|
def format_aw_timestamp(value: datetime) -> str:
|
|
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def load_state(path: Path) -> dict[str, str]:
|
|
if not path.exists():
|
|
return {}
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def save_state(path: Path, state: dict[str, str]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def normalize_base_url(base_url: str) -> str:
|
|
return base_url.rstrip("/")
|
|
|
|
|
|
def aw_get_json(base_url: str, path: str, timeout: int) -> JsonValue:
|
|
url = normalize_base_url(base_url) + path
|
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def list_buckets(base_url: str, timeout: int) -> list[Bucket]:
|
|
payload = aw_get_json(base_url, "/buckets", timeout)
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("ActivityWatch /buckets response must be a JSON object")
|
|
buckets: list[Bucket] = []
|
|
for bucket_id, bucket_data in payload.items():
|
|
if not isinstance(bucket_data, dict):
|
|
continue
|
|
buckets.append(
|
|
Bucket(
|
|
id=str(bucket_id),
|
|
type=str(bucket_data.get("type", "")),
|
|
client=str(bucket_data.get("client", "")),
|
|
hostname=str(bucket_data.get("hostname", "")),
|
|
)
|
|
)
|
|
return buckets
|
|
|
|
|
|
def bucket_stream_type(bucket: Bucket) -> str | None:
|
|
if bucket.id.startswith("aw-file-operations_") or bucket.type == "aw.file.operation":
|
|
return "file_operation"
|
|
if bucket.id.startswith("aw-dlp-incidents_") or bucket.type == "aw.dlp.incident":
|
|
return "dlp_incident"
|
|
return None
|
|
|
|
|
|
def select_buckets(buckets: Iterable[Bucket], prefixes: tuple[str, ...]) -> list[tuple[Bucket, str]]:
|
|
selected: list[tuple[Bucket, str]] = []
|
|
for bucket in buckets:
|
|
stream_type = bucket_stream_type(bucket)
|
|
if stream_type and any(bucket.id.startswith(prefix) for prefix in prefixes):
|
|
selected.append((bucket, stream_type))
|
|
return selected
|
|
|
|
|
|
def build_events_path(bucket_id: str, start: datetime, end: datetime, limit: int) -> str:
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"start": format_aw_timestamp(start),
|
|
"end": format_aw_timestamp(end),
|
|
"limit": str(limit),
|
|
}
|
|
)
|
|
return f"/buckets/{urllib.parse.quote(bucket_id, safe='')}/events?{query}"
|
|
|
|
|
|
def event_key(bucket_id: str, timestamp: str, duration: float, data: dict[str, JsonValue]) -> str:
|
|
payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return f"{bucket_id}|{timestamp}|{duration}|{payload}"
|
|
|
|
|
|
def fetch_bucket_events(
|
|
base_url: str,
|
|
bucket: Bucket,
|
|
stream_type: str,
|
|
start: datetime,
|
|
end: datetime,
|
|
limit: int,
|
|
timeout: int,
|
|
) -> list[AwEvent]:
|
|
payload = aw_get_json(base_url, build_events_path(bucket.id, start, end, limit), timeout)
|
|
if not isinstance(payload, list):
|
|
raise ValueError(f"ActivityWatch events response for {bucket.id} must be a JSON array")
|
|
events: list[AwEvent] = []
|
|
for item in payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
timestamp = str(item["timestamp"])
|
|
duration = float(item.get("duration", 0) or 0)
|
|
data = item.get("data") or {}
|
|
if not isinstance(data, dict):
|
|
data = {"raw": data}
|
|
item_id = str(item.get("id") or event_key(bucket.id, timestamp, duration, data))
|
|
events.append(
|
|
AwEvent(
|
|
bucket_id=bucket.id,
|
|
hostname=bucket.hostname or str(data.get("hostname") or ""),
|
|
stream_type=stream_type,
|
|
event_id=item_id,
|
|
timestamp=timestamp,
|
|
duration=duration,
|
|
data=data,
|
|
)
|
|
)
|
|
return events
|
|
|
|
|
|
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(str(path))
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute("PRAGMA synchronous=NORMAL")
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
return connection
|
|
|
|
|
|
def ensure_schema(connection: sqlite3.Connection) -> None:
|
|
connection.executescript(
|
|
"""
|
|
create table if not exists dlp_events (
|
|
id integer primary key autoincrement,
|
|
bucket_id text not null,
|
|
event_id text not null,
|
|
stream_type text not null,
|
|
hostname text not null,
|
|
username text,
|
|
event_ts text not null,
|
|
duration real not null default 0,
|
|
operation text,
|
|
file_path text,
|
|
old_file_path text,
|
|
extension text,
|
|
archive_hint integer not null default 0,
|
|
rule_id text,
|
|
action text,
|
|
severity text,
|
|
signal_type text,
|
|
message text,
|
|
source text,
|
|
screenshot_path text,
|
|
raw_json text not null,
|
|
ingested_at text not null,
|
|
unique (bucket_id, event_id)
|
|
);
|
|
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
|
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
|
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
|
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
|
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
|
|
|
create view if not exists dlp_file_operations as
|
|
select *
|
|
from dlp_events
|
|
where stream_type = 'file_operation';
|
|
|
|
create view if not exists dlp_incidents as
|
|
select *
|
|
from dlp_events
|
|
where stream_type = 'dlp_incident';
|
|
"""
|
|
)
|
|
connection.commit()
|
|
|
|
|
|
def ensure_postgres_schema(connection: PsycopgConnection) -> None:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
create table if not exists dlp_events (
|
|
id bigserial primary key,
|
|
bucket_id text not null,
|
|
event_id text not null,
|
|
stream_type text not null,
|
|
hostname text not null,
|
|
username text,
|
|
event_ts timestamptz not null,
|
|
duration double precision not null default 0,
|
|
operation text,
|
|
file_path text,
|
|
old_file_path text,
|
|
extension text,
|
|
archive_hint boolean not null default false,
|
|
rule_id text,
|
|
action text,
|
|
severity text,
|
|
signal_type text,
|
|
message text,
|
|
source text,
|
|
screenshot_path text,
|
|
raw_json jsonb not null,
|
|
ingested_at timestamptz not null,
|
|
unique (bucket_id, event_id)
|
|
);
|
|
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
|
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
|
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
|
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
|
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
|
|
|
create or replace view dlp_file_operations as
|
|
select *
|
|
from dlp_events
|
|
where stream_type = 'file_operation';
|
|
|
|
create or replace view dlp_incidents as
|
|
select *
|
|
from dlp_events
|
|
where stream_type = 'dlp_incident';
|
|
"""
|
|
)
|
|
connection.commit()
|
|
|
|
|
|
def first_string(data: dict[str, JsonValue], keys: tuple[str, ...]) -> str | None:
|
|
for key in keys:
|
|
value = data.get(key)
|
|
if value is not None and str(value) != "":
|
|
return str(value)
|
|
return None
|
|
|
|
|
|
def bool_as_int(value: JsonValue) -> int:
|
|
if isinstance(value, bool):
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
return int(value.lower() in {"1", "true", "yes", "y"})
|
|
return int(bool(value))
|
|
|
|
|
|
def event_row(event: AwEvent, ingested_at: str) -> tuple[JsonValue, ...]:
|
|
data = event.data
|
|
event_id = event.event_id or event_key(event.bucket_id, event.timestamp, event.duration, data)
|
|
return (
|
|
event.bucket_id,
|
|
event_id,
|
|
event.stream_type,
|
|
event.hostname,
|
|
first_string(data, ("username", "user")),
|
|
event.timestamp,
|
|
event.duration,
|
|
first_string(data, ("operation",)),
|
|
first_string(data, ("path", "filePath")),
|
|
first_string(data, ("oldPath", "oldFilePath")),
|
|
first_string(data, ("extension",)),
|
|
bool_as_int(data.get("archiveHint")),
|
|
first_string(data, ("ruleId", "rule")),
|
|
first_string(data, ("action",)),
|
|
first_string(data, ("severity",)),
|
|
first_string(data, ("signalType",)),
|
|
first_string(data, ("message",)),
|
|
first_string(data, ("source",)),
|
|
first_string(data, ("screenshotPath", "capturePath", "artifactPath")),
|
|
json.dumps(data, ensure_ascii=False, sort_keys=True),
|
|
ingested_at,
|
|
)
|
|
|
|
|
|
def insert_events(connection: sqlite3.Connection, events: Iterable[AwEvent]) -> int:
|
|
inserted = 0
|
|
now = format_aw_timestamp(utc_now())
|
|
for event in events:
|
|
cursor = connection.execute(
|
|
"""
|
|
insert or ignore into dlp_events (
|
|
bucket_id,
|
|
event_id,
|
|
stream_type,
|
|
hostname,
|
|
username,
|
|
event_ts,
|
|
duration,
|
|
operation,
|
|
file_path,
|
|
old_file_path,
|
|
extension,
|
|
archive_hint,
|
|
rule_id,
|
|
action,
|
|
severity,
|
|
signal_type,
|
|
message,
|
|
source,
|
|
screenshot_path,
|
|
raw_json,
|
|
ingested_at
|
|
)
|
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
event_row(event, now),
|
|
)
|
|
inserted += int(cursor.rowcount > 0)
|
|
connection.commit()
|
|
return inserted
|
|
|
|
|
|
def insert_postgres_events(dsn: str, events: Iterable[AwEvent]) -> int:
|
|
try:
|
|
import psycopg
|
|
except ImportError as exc:
|
|
raise SystemExit("PostgreSQL mode requires psycopg: python3 -m pip install 'psycopg[binary]'") from exc
|
|
|
|
inserted = 0
|
|
now = format_aw_timestamp(utc_now())
|
|
columns = ", ".join(EVENT_COLUMNS)
|
|
placeholders = ", ".join(["%s"] * len(EVENT_COLUMNS))
|
|
sql = f"""
|
|
insert into dlp_events ({columns})
|
|
values ({placeholders})
|
|
on conflict (bucket_id, event_id) do nothing
|
|
"""
|
|
with psycopg.connect(dsn) as connection:
|
|
ensure_postgres_schema(connection)
|
|
with connection.cursor() as cursor:
|
|
for event in events:
|
|
row = list(event_row(event, now))
|
|
row[EVENT_COLUMNS.index("archive_hint")] = bool(row[EVENT_COLUMNS.index("archive_hint")])
|
|
cursor.execute(sql, row)
|
|
inserted += int(cursor.rowcount > 0)
|
|
connection.commit()
|
|
return inserted
|
|
|
|
|
|
def get_start_time(args: argparse.Namespace, state: dict[str, str]) -> datetime:
|
|
if args.since:
|
|
return parse_timestamp(args.since)
|
|
if state.get("last_end"):
|
|
return parse_timestamp(state["last_end"]) - timedelta(seconds=args.overlap_seconds)
|
|
return utc_now() - timedelta(hours=args.lookback_hours)
|
|
|
|
|
|
def parse_prefixes(value: str) -> tuple[str, ...]:
|
|
prefixes = tuple(item.strip() for item in value.split(",") if item.strip())
|
|
if not prefixes:
|
|
raise argparse.ArgumentTypeError("at least one bucket prefix is required")
|
|
return prefixes
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Aggregate AWatch-rus DLP buckets into a local warehouse database.")
|
|
parser.add_argument("--aw-url", default=os.environ.get("AW_URL", "http://127.0.0.1:5600/api/0"))
|
|
parser.add_argument("--postgres-dsn", default=os.environ.get("DLP_AGGREGATOR_POSTGRES_DSN"))
|
|
parser.add_argument("--sqlite-path", default=os.environ.get("DLP_AGGREGATOR_SQLITE_PATH", DEFAULT_SQLITE_PATH))
|
|
parser.add_argument("--state-path", default=os.environ.get("DLP_AGGREGATOR_STATE_PATH", "data/dlp-aggregator-state.json"))
|
|
parser.add_argument("--bucket-prefixes", type=parse_prefixes, default=DEFAULT_BUCKET_PREFIXES)
|
|
parser.add_argument("--since", help="UTC ISO timestamp. Overrides saved state, for example 2026-05-02T00:00:00Z.")
|
|
parser.add_argument("--lookback-hours", type=int, default=24)
|
|
parser.add_argument("--overlap-seconds", type=int, default=60)
|
|
parser.add_argument("--limit", type=int, default=10000)
|
|
parser.add_argument("--timeout", type=int, default=15)
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
state_path = Path(args.state_path)
|
|
state = load_state(state_path)
|
|
start = get_start_time(args, state)
|
|
end = utc_now()
|
|
|
|
buckets = select_buckets(list_buckets(args.aw_url, args.timeout), args.bucket_prefixes)
|
|
all_events: list[AwEvent] = []
|
|
for bucket, stream_type in buckets:
|
|
all_events.extend(fetch_bucket_events(args.aw_url, bucket, stream_type, start, end, args.limit, args.timeout))
|
|
|
|
if args.dry_run:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"aw_url": args.aw_url,
|
|
"start": format_aw_timestamp(start),
|
|
"end": format_aw_timestamp(end),
|
|
"selected_buckets": [bucket.id for bucket, _stream_type in buckets],
|
|
"fetched_events": len(all_events),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
if args.postgres_dsn:
|
|
target = "postgres"
|
|
target_path = args.postgres_dsn.split("@")[-1]
|
|
inserted = insert_postgres_events(args.postgres_dsn, all_events)
|
|
else:
|
|
target = "sqlite"
|
|
sqlite_path = Path(args.sqlite_path)
|
|
target_path = str(sqlite_path)
|
|
connection = connect_sqlite(sqlite_path)
|
|
try:
|
|
ensure_schema(connection)
|
|
inserted = insert_events(connection, all_events)
|
|
finally:
|
|
connection.close()
|
|
|
|
state["last_end"] = format_aw_timestamp(end)
|
|
save_state(state_path, state)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"aw_url": args.aw_url,
|
|
"target": target,
|
|
"target_path": target_path,
|
|
"state_path": str(state_path),
|
|
"start": format_aw_timestamp(start),
|
|
"end": format_aw_timestamp(end),
|
|
"selected_buckets": len(buckets),
|
|
"fetched_events": len(all_events),
|
|
"inserted_events": inserted,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except urllib.error.URLError as exc:
|
|
print(f"ActivityWatch API request failed: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|