diff --git a/ansible/deploy_dlp_full_stack.yml b/ansible/deploy_dlp_full_stack.yml new file mode 100644 index 0000000..7a7eb7c --- /dev/null +++ b/ansible/deploy_dlp_full_stack.yml @@ -0,0 +1,10 @@ +--- +- import_playbook: deploy_aw_server.yml +- import_playbook: deploy_aw_windows.yml + +- name: Deploy DLP policy engine + hosts: aw_server + become: true + gather_facts: false + roles: + - role: dlp-policy-engine diff --git a/ansible/roles/dlp-policy-engine/tasks/main.yml b/ansible/roles/dlp-policy-engine/tasks/main.yml new file mode 100644 index 0000000..1fea9ab --- /dev/null +++ b/ansible/roles/dlp-policy-engine/tasks/main.yml @@ -0,0 +1,41 @@ +--- +- name: Ensure policy engine directory exists + become: true + ansible.builtin.file: + path: /opt/activitywatch/dlp-policy-engine + state: directory + owner: activitywatch + group: activitywatch + mode: "0755" + +- name: Deploy policy engine files + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + owner: activitywatch + group: activitywatch + mode: "{{ item.mode | default('0644') }}" + loop: + - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_service.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_service.py" } + - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_schema.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_schema.py" } + - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_storage.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_storage.py" } + - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_distributor.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_distributor.py" } + - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/dlp-policy-engine.service", dest: "/etc/systemd/system/dlp-policy-engine.service" } + +- name: Install python deps for policy engine + become: true + ansible.builtin.pip: + name: + - fastapi + - uvicorn + - pydantic + executable: pip3 + +- name: Enable and restart policy engine + become: true + ansible.builtin.systemd: + daemon_reload: true + name: dlp-policy-engine.service + enabled: true + state: restarted diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py new file mode 100644 index 0000000..61a370f --- /dev/null +++ b/aw-server/dlp-case-management/case_service.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any + +from fastapi import FastAPI +from pydantic import BaseModel + +DB = Path("/opt/activitywatch/dlp-case-management/cases.db") +APP = FastAPI(title="AWatch DLP Case Management") + + +class CaseCreate(BaseModel): + incident_id: str + title: str + severity: str = "medium" + assignee: str | None = None + + +def _conn() -> sqlite3.Connection: + DB.parent.mkdir(parents=True, exist_ok=True) + c = sqlite3.connect(DB) + c.execute( + "CREATE TABLE IF NOT EXISTS cases (id INTEGER PRIMARY KEY, incident_id TEXT, title TEXT, severity TEXT, assignee TEXT, status TEXT DEFAULT 'open')" + ) + return c + + +@APP.post("/api/0/dlp/cases") +def create_case(payload: CaseCreate) -> dict[str, Any]: + c = _conn() + cur = c.cursor() + cur.execute( + "INSERT INTO cases (incident_id,title,severity,assignee,status) VALUES (?,?,?,?,?)", + (payload.incident_id, payload.title, payload.severity, payload.assignee, "open"), + ) + c.commit() + case_id = cur.lastrowid + c.close() + return {"id": case_id} + + +@APP.get("/api/0/dlp/cases") +def list_cases() -> list[dict[str, Any]]: + c = _conn() + rows = c.execute("SELECT id,incident_id,title,severity,assignee,status FROM cases ORDER BY id DESC").fetchall() + c.close() + return [ + {"id": r[0], "incident_id": r[1], "title": r[2], "severity": r[3], "assignee": r[4], "status": r[5]} + for r in rows + ] diff --git a/aw-server/dlp-compliance/report_generator.py b/aw-server/dlp-compliance/report_generator.py new file mode 100644 index 0000000..f10758d --- /dev/null +++ b/aw-server/dlp-compliance/report_generator.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + + +def render_html(period: str) -> str: + return f"""
Период: {period}
Сгенерирован: {datetime.now().isoformat()}
""" + + +def main() -> None: + period = datetime.now().strftime("%Y-%m") + out = Path("/opt/activitywatch/dlp-compliance/reports") + out.mkdir(parents=True, exist_ok=True) + html = out / f"152-fz-{period}.html" + html.write_text(render_html(period), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/aw-server/dlp-content-analysis/checksum_validator.py b/aw-server/dlp-content-analysis/checksum_validator.py new file mode 100644 index 0000000..4a70008 --- /dev/null +++ b/aw-server/dlp-content-analysis/checksum_validator.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re + + +def validate_inn(value: str) -> bool: + digits = re.sub(r"\D", "", value) + if len(digits) == 10: + coef = [2, 4, 10, 3, 5, 9, 4, 6, 8] + chk = sum(int(digits[i]) * coef[i] for i in range(9)) % 11 % 10 + return chk == int(digits[9]) + if len(digits) == 12: + c11 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + c12 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + chk11 = sum(int(digits[i]) * c11[i] for i in range(10)) % 11 % 10 + chk12 = sum(int(digits[i]) * c12[i] for i in range(11)) % 11 % 10 + return chk11 == int(digits[10]) and chk12 == int(digits[11]) + return False + + +def validate_snils(value: str) -> bool: + digits = re.sub(r"\D", "", value) + if len(digits) != 11: + return False + number = digits[:9] + checksum = int(digits[9:]) + s = sum(int(number[i]) * (9 - i) for i in range(9)) + if s < 100: + expected = s + elif s in (100, 101): + expected = 0 + else: + expected = s % 101 + if expected == 100: + expected = 0 + return checksum == expected diff --git a/aw-server/dlp-content-analysis/dictionaries/152-fz-pdn.json b/aw-server/dlp-content-analysis/dictionaries/152-fz-pdn.json new file mode 100644 index 0000000..869d668 --- /dev/null +++ b/aw-server/dlp-content-analysis/dictionaries/152-fz-pdn.json @@ -0,0 +1,17 @@ +{ + "inn": { + "regex": "\\b\\d{10}\\b|\\b\\d{12}\\b", + "checksum": "inn", + "description": "ИНН" + }, + "snils": { + "regex": "\\b\\d{3}-\\d{3}-\\d{3}\\s?\\d{2}\\b", + "checksum": "snils", + "description": "СНИЛС" + }, + "passport": { + "regex": "\\b\\d{4}\\s?\\d{6}\\b", + "checksum": "none", + "description": "Паспорт РФ" + } +} diff --git a/aw-server/dlp-content-analysis/dictionary_matcher.py b/aw-server/dlp-content-analysis/dictionary_matcher.py new file mode 100644 index 0000000..acf6e34 --- /dev/null +++ b/aw-server/dlp-content-analysis/dictionary_matcher.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import pathlib +import re +from typing import Any + +from checksum_validator import validate_inn, validate_snils + + +def _validate(kind: str, value: str) -> bool: + if kind == "inn": + return validate_inn(value) + if kind == "snils": + return validate_snils(value) + return True + + +def match_text(text: str, dictionary_path: str) -> list[dict[str, Any]]: + rules = json.loads(pathlib.Path(dictionary_path).read_text(encoding="utf-8")) + results: list[dict[str, Any]] = [] + for name, rule in rules.items(): + regex = re.compile(rule["regex"]) + checksum_kind = rule.get("checksum", "none") + for m in regex.finditer(text): + token = m.group(0) + if _validate(checksum_kind, token): + results.append( + { + "name": name, + "description": rule.get("description", name), + "value": token, + "start": m.start(), + "end": m.end(), + } + ) + return results diff --git a/aw-server/dlp-content-analysis/ocr_processor.py b/aw-server/dlp-content-analysis/ocr_processor.py new file mode 100644 index 0000000..7965287 --- /dev/null +++ b/aw-server/dlp-content-analysis/ocr_processor.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path + +from PIL import Image +import pytesseract + + +def extract_text(image_path: str) -> str: + path = Path(image_path) + if not path.exists(): + return "" + img = Image.open(path) + return pytesseract.image_to_string(img, lang="rus+eng") diff --git a/aw-server/dlp-content-analysis/regex-packs/contacts.json b/aw-server/dlp-content-analysis/regex-packs/contacts.json new file mode 100644 index 0000000..880c4ed --- /dev/null +++ b/aw-server/dlp-content-analysis/regex-packs/contacts.json @@ -0,0 +1,6 @@ +{ + "rules": [ + { "id": "email", "regex": "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}", "severity": "low" }, + { "id": "phone-ru", "regex": "(?:\\+7|8)\\s*\\(?\\d{3}\\)?\\s*\\d{3}[- ]?\\d{2}[- ]?\\d{2}", "severity": "low" } + ] +} diff --git a/aw-server/dlp-content-analysis/regex-packs/financial.json b/aw-server/dlp-content-analysis/regex-packs/financial.json new file mode 100644 index 0000000..f80f80d --- /dev/null +++ b/aw-server/dlp-content-analysis/regex-packs/financial.json @@ -0,0 +1,6 @@ +{ + "rules": [ + { "id": "card-pan", "regex": "\\b(?:\\d[ -]*?){13,19}\\b", "severity": "high" }, + { "id": "iban", "regex": "\\b[A-Z]{2}\\d{2}[A-Z0-9]{11,30}\\b", "severity": "medium" } + ] +} diff --git a/aw-server/dlp-content-analysis/regex-packs/secrets.json b/aw-server/dlp-content-analysis/regex-packs/secrets.json new file mode 100644 index 0000000..607216a --- /dev/null +++ b/aw-server/dlp-content-analysis/regex-packs/secrets.json @@ -0,0 +1,6 @@ +{ + "rules": [ + { "id": "aws-access-key", "regex": "AKIA[0-9A-Z]{16}", "severity": "high" }, + { "id": "generic-password", "regex": "(?i)(password|пароль)\\s*[:=]\\s*\\S{6,}", "severity": "medium" } + ] +} diff --git a/aw-server/dlp-integrations/cef-exporter.service b/aw-server/dlp-integrations/cef-exporter.service new file mode 100644 index 0000000..c5624a4 --- /dev/null +++ b/aw-server/dlp-integrations/cef-exporter.service @@ -0,0 +1,12 @@ +[Unit] +Description=AWatch DLP CEF Exporter +After=network-online.target + +[Service] +Type=oneshot +User=activitywatch +Group=activitywatch +ExecStart=/usr/bin/python3 /opt/activitywatch/dlp-integrations/cef_exporter.py + +[Install] +WantedBy=multi-user.target diff --git a/aw-server/dlp-integrations/cef-exporter.timer b/aw-server/dlp-integrations/cef-exporter.timer new file mode 100644 index 0000000..aaaad7e --- /dev/null +++ b/aw-server/dlp-integrations/cef-exporter.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AWatch DLP CEF Exporter every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Unit=cef-exporter.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/aw-server/dlp-integrations/cef_exporter.py b/aw-server/dlp-integrations/cef_exporter.py new file mode 100644 index 0000000..64ac3ff --- /dev/null +++ b/aw-server/dlp-integrations/cef_exporter.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import logging +import os +import socket +from datetime import datetime, timezone + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + +def build_cef(event: dict) -> str: + sev_map = {"low": 3, "medium": 6, "high": 10} + sev = sev_map.get(event.get("severity", "low"), 3) + ts = datetime.now(timezone.utc).isoformat() + msg = event.get("message", "") + host = event.get("hostname", "unknown") + return f"CEF:0|AWatch-rus|DLP|1.0|{event.get('id','dlp')}|{msg}|{sev}|rt={ts} shost={host}" + + +def send_syslog(line: str, host: str, port: int) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.sendto(line.encode("utf-8", errors="ignore"), (host, port)) + finally: + sock.close() + + +def main() -> None: + sample = os.environ.get("AW_DLP_CEF_SAMPLE", "") + event = json.loads(sample) if sample else {"id": "startup", "message": "cef exporter heartbeat", "severity": "low"} + line = build_cef(event) + host = os.environ.get("AW_DLP_SYSLOG_HOST", "127.0.0.1") + port = int(os.environ.get("AW_DLP_SYSLOG_PORT", "514")) + send_syslog(line, host, port) + logging.info("sent CEF event to %s:%d", host, port) + + +if __name__ == "__main__": + main() diff --git a/aw-server/dlp-integrations/webhook_sender.py b/aw-server/dlp-integrations/webhook_sender.py new file mode 100644 index 0000000..4c2f3cc --- /dev/null +++ b/aw-server/dlp-integrations/webhook_sender.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import time +from urllib import request + + +def post(url: str, payload: dict, retries: int = 3) -> bool: + body = json.dumps(payload).encode("utf-8") + for i in range(retries): + try: + req = request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with request.urlopen(req, timeout=10): + return True + except Exception: + time.sleep(2 ** i) + return False + + +def main() -> None: + hooks = [h.strip() for h in os.environ.get("AW_DLP_CRITICAL_WEBHOOKS", "").split(",") if h.strip()] + payload = {"text": "AWatch DLP critical incident", "severity": "high"} + for hook in hooks: + post(hook, payload) + + +if __name__ == "__main__": + main() diff --git a/aw-server/dlp-monitoring/metrics_exporter.py b/aw-server/dlp-monitoring/metrics_exporter.py new file mode 100644 index 0000000..8a52a8a --- /dev/null +++ b/aw-server/dlp-monitoring/metrics_exporter.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse + +app = FastAPI(title="AWatch DLP Metrics") + + +@app.get("/metrics", response_class=PlainTextResponse) +def metrics() -> str: + # Minimal exporter baseline for Prometheus scraping. + return "\n".join( + [ + "# HELP aw_dlp_exporter_up Exporter availability.", + "# TYPE aw_dlp_exporter_up gauge", + "aw_dlp_exporter_up 1", + ] + ) diff --git a/grafana/dlp-dashboard.json b/grafana/dlp-dashboard.json new file mode 100644 index 0000000..084cd45 --- /dev/null +++ b/grafana/dlp-dashboard.json @@ -0,0 +1,15 @@ +{ + "title": "AWatch DLP Overview", + "schemaVersion": 39, + "version": 1, + "panels": [ + { + "type": "stat", + "title": "DLP Exporter Up", + "gridPos": { "x": 0, "y": 0, "w": 8, "h": 4 }, + "targets": [ + { "expr": "aw_dlp_exporter_up" } + ] + } + ] +} diff --git a/scripts/dlp-admin-cli.py b/scripts/dlp-admin-cli.py new file mode 100644 index 0000000..8f1487c --- /dev/null +++ b/scripts/dlp-admin-cli.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from urllib import request + + +def get_json(url: str): + with request.urlopen(url, timeout=10) as r: + return json.loads(r.read().decode("utf-8")) + + +def main() -> None: + p = argparse.ArgumentParser(description="AWatch DLP admin CLI") + p.add_argument("--server", default="http://127.0.0.1:5601") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("policies-list") + sub.add_parser("health-check") + args = p.parse_args() + + if args.cmd == "policies-list": + data = get_json(f"{args.server}/api/0/dlp/policies") + print(json.dumps(data, ensure_ascii=False, indent=2)) + elif args.cmd == "health-check": + data = get_json(f"{args.server}/health") + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/windows/install-dlp-client.ps1 b/windows/install-dlp-client.ps1 new file mode 100644 index 0000000..d11fd2d --- /dev/null +++ b/windows/install-dlp-client.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory = $true)][string]$ServerHost, + [int]$ServerPort = 5600, + [string]$InstallRoot = "C:\ProgramData\AWatch-rus" +) + +$ErrorActionPreference = "Stop" + +New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $InstallRoot "logs") -Force | Out-Null + +$configPath = Join-Path $InstallRoot "deployment-config.json" +$policyPath = Join-Path $InstallRoot "dlp-policy.json" + +$cfg = @{ + server = @{ + host = $ServerHost + port = $ServerPort + apiBase = "http://$ServerHost`:$ServerPort/api/0" + } + paths = @{ + logsRoot = (Join-Path $InstallRoot "logs") + } + dlp = @{ + policyMode = "server" + } + localAgentLogsEnabled = $true +} +$cfg | ConvertTo-Json -Depth 8 | Set-Content -Path $configPath -Encoding UTF8 + +if (-not (Test-Path $policyPath)) { + @{ + version = 1 + defaults = @{ + enabled = $true + action = "log" + severity = "low" + cooldownSeconds = 300 + } + rules = @() + } | ConvertTo-Json -Depth 8 | Set-Content -Path $policyPath -Encoding UTF8 +} + +Write-Host "DLP client config written: $configPath"