actaeon 0.2.0b0__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.
- actaeon/__init__.py +3 -0
- actaeon/__main__.py +3 -0
- actaeon/access.py +137 -0
- actaeon/assistant.py +209 -0
- actaeon/auth.py +443 -0
- actaeon/bench.py +186 -0
- actaeon/bridges/__init__.py +16 -0
- actaeon/bridges/common.py +148 -0
- actaeon/bridges/demo.py +232 -0
- actaeon/bridges/local_intel.py +77 -0
- actaeon/bridges/local_tools.py +31 -0
- actaeon/bridges/openapi.py +169 -0
- actaeon/bridges/search_index.py +303 -0
- actaeon/bridges/server.py +109 -0
- actaeon/bridges/sql.py +227 -0
- actaeon/bridges/taxii.py +185 -0
- actaeon/bridges/web.py +109 -0
- actaeon/cli.py +910 -0
- actaeon/company.py +102 -0
- actaeon/config.example.yaml +236 -0
- actaeon/config.py +336 -0
- actaeon/connectors/__init__.py +6 -0
- actaeon/connectors/audit.py +131 -0
- actaeon/connectors/client.py +294 -0
- actaeon/connectors/contract.py +103 -0
- actaeon/connectors/hub.py +279 -0
- actaeon/connectors/oauth.py +237 -0
- actaeon/connectors/policy.py +79 -0
- actaeon/connectors/ratelimit.py +26 -0
- actaeon/connectors/spec.py +154 -0
- actaeon/connectors/untrusted.py +29 -0
- actaeon/db.py +341 -0
- actaeon/db_migrate.py +92 -0
- actaeon/decode.py +150 -0
- actaeon/demo/__init__.py +1 -0
- actaeon/demo/data.py +87 -0
- actaeon/demo/setup.py +62 -0
- actaeon/doctor.py +157 -0
- actaeon/hunt/__init__.py +6 -0
- actaeon/hunt/attack_data.json +1 -0
- actaeon/hunt/attack_names.py +79 -0
- actaeon/hunt/autopilot.py +153 -0
- actaeon/hunt/chat.py +132 -0
- actaeon/hunt/coverage.py +73 -0
- actaeon/hunt/detection.py +180 -0
- actaeon/hunt/drift.py +262 -0
- actaeon/hunt/engine.py +696 -0
- actaeon/hunt/entities.py +169 -0
- actaeon/hunt/graph.py +86 -0
- actaeon/hunt/injection.py +112 -0
- actaeon/hunt/jsonparse.py +70 -0
- actaeon/hunt/library.py +225 -0
- actaeon/hunt/memory.py +192 -0
- actaeon/hunt/packs.py +114 -0
- actaeon/hunt/prompts.py +101 -0
- actaeon/hunt/report.py +138 -0
- actaeon/hunt/scheduler.py +156 -0
- actaeon/hunt/service.py +492 -0
- actaeon/hunt/sigma_convert.py +159 -0
- actaeon/hunt/signals.py +56 -0
- actaeon/hunt/store.py +158 -0
- actaeon/hunt/sweep.py +231 -0
- actaeon/hunt/timeline.py +87 -0
- actaeon/hunt/toolloop.py +142 -0
- actaeon/hunt/triage.py +123 -0
- actaeon/hunt/validator.py +109 -0
- actaeon/hunt/verify.py +88 -0
- actaeon/hunts/collection/archive-created-before-transfer.yaml +6 -0
- actaeon/hunts/collection/data-staged-on-a-network-share.yaml +6 -0
- actaeon/hunts/collection/email-forwarding-rule-to-an-outside-address.yaml +6 -0
- actaeon/hunts/collection/storage-made-public.yaml +6 -0
- actaeon/hunts/command-and-control/beaconing.yaml +6 -0
- actaeon/hunts/command-and-control/first-seen-external-destination.yaml +6 -0
- actaeon/hunts/command-and-control/rare-user-agent.yaml +6 -0
- actaeon/hunts/command-and-control/tool-download-with-built-in-utilities.yaml +6 -0
- actaeon/hunts/command-and-control/unusual-dns.yaml +6 -0
- actaeon/hunts/credential-access/as-rep-roasting.yaml +6 -0
- actaeon/hunts/credential-access/browser-credential-store-access.yaml +6 -0
- actaeon/hunts/credential-access/brute-force-against-one-account.yaml +6 -0
- actaeon/hunts/credential-access/consent-to-an-unfamiliar-application.yaml +6 -0
- actaeon/hunts/credential-access/directory-replication-from-a-non-controller.yaml +6 -0
- actaeon/hunts/credential-access/kerberoasting.yaml +6 -0
- actaeon/hunts/credential-access/lsass-memory-access.yaml +6 -0
- actaeon/hunts/credential-access/mfa-fatigue.yaml +6 -0
- actaeon/hunts/credential-access/password-spraying.yaml +6 -0
- actaeon/hunts/credential-access/registry-hive-or-ntds-copy.yaml +6 -0
- actaeon/hunts/defense-impairment/cloud-logging-disabled.yaml +6 -0
- actaeon/hunts/defense-impairment/event-log-clearing.yaml +6 -0
- actaeon/hunts/defense-impairment/firewall-rule-added-or-disabled.yaml +6 -0
- actaeon/hunts/defense-impairment/linux-log-or-history-tampering.yaml +6 -0
- actaeon/hunts/defense-impairment/security-tool-tampering.yaml +6 -0
- actaeon/hunts/discovery/burst-of-discovery-commands.yaml +6 -0
- actaeon/hunts/discovery/internal-port-scan.yaml +6 -0
- actaeon/hunts/execution/encoded-powershell-from-office.yaml +6 -0
- actaeon/hunts/execution/first-seen-process-on-servers.yaml +6 -0
- actaeon/hunts/execution/new-cron-job-or-systemd-unit.yaml +6 -0
- actaeon/hunts/execution/new-scheduled-task-by-an-unusual-parent.yaml +6 -0
- actaeon/hunts/execution/office-macro-writing-executables.yaml +6 -0
- actaeon/hunts/execution/python-or-perl-one-liner-with-a-network-connection.yaml +6 -0
- actaeon/hunts/execution/rare-parent-child-process-pair.yaml +6 -0
- actaeon/hunts/execution/script-host-from-a-download-or-temp-folder.yaml +6 -0
- actaeon/hunts/execution/unix-shell-from-a-web-server.yaml +6 -0
- actaeon/hunts/execution/wmi-process-creation.yaml +6 -0
- actaeon/hunts/exfiltration/large-outbound-transfer-at-night.yaml +6 -0
- actaeon/hunts/exfiltration/upload-to-cloud-storage.yaml +6 -0
- actaeon/hunts/impact/mass-file-changes.yaml +6 -0
- actaeon/hunts/impact/service-stop-spree.yaml +6 -0
- actaeon/hunts/impact/shadow-copy-deletion.yaml +6 -0
- actaeon/hunts/initial-access/account-logging-in-from-a-first-seen-source.yaml +6 -0
- actaeon/hunts/initial-access/cloud-access-key-used-from-a-new-location.yaml +6 -0
- actaeon/hunts/initial-access/impossible-travel.yaml +6 -0
- actaeon/hunts/initial-access/phishing-attachment-to-process.yaml +6 -0
- actaeon/hunts/lateral-movement/pass-the-hash-pattern.yaml +6 -0
- actaeon/hunts/lateral-movement/rdp-from-an-unusual-source.yaml +6 -0
- actaeon/hunts/lateral-movement/remote-powershell-or-winrm-use.yaml +6 -0
- actaeon/hunts/lateral-movement/remote-service-execution.yaml +6 -0
- actaeon/hunts/lateral-movement/ssh-lateral-movement.yaml +6 -0
- actaeon/hunts/persistence/image-file-execution-options-hijack.yaml +6 -0
- actaeon/hunts/persistence/new-cloud-admin-or-role-assignment.yaml +6 -0
- actaeon/hunts/persistence/new-privileged-account.yaml +6 -0
- actaeon/hunts/persistence/new-service-installed.yaml +6 -0
- actaeon/hunts/persistence/run-key-persistence.yaml +6 -0
- actaeon/hunts/persistence/ssh-authorized-keys-change.yaml +6 -0
- actaeon/hunts/persistence/startup-folder-drop.yaml +6 -0
- actaeon/hunts/persistence/web-shell-on-a-web-server.yaml +6 -0
- actaeon/hunts/persistence/wmi-event-subscription.yaml +6 -0
- actaeon/hunts/privilege-escalation/process-injection-indicators.yaml +6 -0
- actaeon/hunts/privilege-escalation/sudo-or-su-abuse.yaml +6 -0
- actaeon/hunts/privilege-escalation/uac-bypass-pattern.yaml +6 -0
- actaeon/hunts/stealth/masquerading-binary-name.yaml +6 -0
- actaeon/hunts/stealth/signed-binary-proxy-execution.yaml +6 -0
- actaeon/hunts/stealth/timestomping-or-hidden-files.yaml +6 -0
- actaeon/intel/__init__.py +6 -0
- actaeon/intel/extract.py +124 -0
- actaeon/intel/feeds.py +131 -0
- actaeon/intel/fetch.py +175 -0
- actaeon/intel/relevance.py +103 -0
- actaeon/intel/stix.py +145 -0
- actaeon/intel/store.py +319 -0
- actaeon/intel/web.py +138 -0
- actaeon/jobs.py +358 -0
- actaeon/leads.py +225 -0
- actaeon/mcp_server.py +167 -0
- actaeon/outputs.py +247 -0
- actaeon/paths.py +26 -0
- actaeon/privacy.py +155 -0
- actaeon/providers/__init__.py +193 -0
- actaeon/providers/anthropic.py +65 -0
- actaeon/providers/base.py +156 -0
- actaeon/providers/cli.py +86 -0
- actaeon/providers/demo.py +121 -0
- actaeon/providers/gemini.py +230 -0
- actaeon/providers/ollama.py +69 -0
- actaeon/providers/openai_compat.py +87 -0
- actaeon/reporting.py +262 -0
- actaeon/retention.py +80 -0
- actaeon/server.py +81 -0
- actaeon/static/app.css +98 -0
- actaeon/static/app.js +858 -0
- actaeon/static/mdi/LICENSE +20 -0
- actaeon/static/mdi/README.md +5 -0
- actaeon/static/mdi/css/icons.css +103 -0
- actaeon/static/mdi/fonts/icons.woff2 +0 -0
- actaeon/tasks.py +79 -0
- actaeon/templates/index.html +31 -0
- actaeon/templates/login.html +18 -0
- actaeon/tickets.py +116 -0
- actaeon/web.py +812 -0
- actaeon/worker.py +117 -0
- actaeon-0.2.0b0.dist-info/METADATA +339 -0
- actaeon-0.2.0b0.dist-info/RECORD +176 -0
- actaeon-0.2.0b0.dist-info/WHEEL +5 -0
- actaeon-0.2.0b0.dist-info/entry_points.txt +2 -0
- actaeon-0.2.0b0.dist-info/licenses/LICENSE +202 -0
- actaeon-0.2.0b0.dist-info/licenses/NOTICE +16 -0
- actaeon-0.2.0b0.dist-info/top_level.txt +1 -0
actaeon/__init__.py
ADDED
actaeon/__main__.py
ADDED
actaeon/access.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Who is asking, and which data sources they may use and see.
|
|
2
|
+
|
|
3
|
+
A connector can list who may use it::
|
|
4
|
+
|
|
5
|
+
connectors:
|
|
6
|
+
- name: mail
|
|
7
|
+
roles: [email]
|
|
8
|
+
access: [insider-threat, "role:admin", "user:jdoe"] # group names, role:<role>, user:<name>
|
|
9
|
+
|
|
10
|
+
No ``access`` (the default) means everyone. Admins can use every connector.
|
|
11
|
+
|
|
12
|
+
The caller is held in a context variable: the web guard sets it per request,
|
|
13
|
+
and jobs started from a request inherit it (the job runner copies the context),
|
|
14
|
+
so a hunt runs with the permissions of whoever started it. Scheduled and
|
|
15
|
+
command-line work has no caller and sees everything.
|
|
16
|
+
|
|
17
|
+
A hunt that called a connector with ``access`` is *restricted* to the people
|
|
18
|
+
who may use those connectors: other people cannot list or open it, its lead,
|
|
19
|
+
reports, chat or exports, and it is left out of other people's hunt memory.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextvars
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
ROLE_RANK = {"viewer": 0, "operator": 1, "admin": 2}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Access:
|
|
32
|
+
user: str = ""
|
|
33
|
+
role: str = "viewer"
|
|
34
|
+
groups: tuple[str, ...] = ()
|
|
35
|
+
connectors: frozenset[str] | None = None # None: every connector
|
|
36
|
+
restricted_names: frozenset[str] = field(default_factory=frozenset)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {"user": self.user, "role": self.role, "groups": list(self.groups),
|
|
40
|
+
"connectors": None if self.connectors is None else sorted(self.connectors),
|
|
41
|
+
"restricted_names": sorted(self.restricted_names)}
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, d: dict) -> Access:
|
|
45
|
+
c = d.get("connectors")
|
|
46
|
+
return cls(user=str(d.get("user", "")), role=str(d.get("role", "viewer")), groups=tuple(d.get("groups") or ()),
|
|
47
|
+
connectors=None if c is None else frozenset(c),
|
|
48
|
+
restricted_names=frozenset(d.get("restricted_names") or ()))
|
|
49
|
+
|
|
50
|
+
def public(self) -> dict:
|
|
51
|
+
return {"user": self.user, "role": self.role, "groups": list(self.groups),
|
|
52
|
+
"connectors": None if self.connectors is None else sorted(self.connectors)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_current: contextvars.ContextVar[Access | None] = contextvars.ContextVar("actaeon_access", default=None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current() -> Access | None:
|
|
59
|
+
return _current.get()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_current(a: Access | None):
|
|
63
|
+
return _current.set(a)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reset(token) -> None:
|
|
67
|
+
_current.reset(token)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def matches(entry: str, user: str, role: str, groups) -> bool:
|
|
71
|
+
e = str(entry).strip()
|
|
72
|
+
if e.startswith("role:"):
|
|
73
|
+
want = e[5:].strip().lower()
|
|
74
|
+
return want in ROLE_RANK and ROLE_RANK.get(role, -1) >= ROLE_RANK[want]
|
|
75
|
+
if e.startswith("user:"):
|
|
76
|
+
return bool(user) and e[5:].strip().lower() == str(user).lower()
|
|
77
|
+
return e in set(groups or ())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build(specs, *, user: str, role: str, groups=()) -> Access:
|
|
81
|
+
"""The Access for this caller over these connector specs (an iterable of ConnectorSpec)."""
|
|
82
|
+
specs = list(specs)
|
|
83
|
+
restricted = frozenset(s.name for s in specs if getattr(s, "access", None))
|
|
84
|
+
groups = tuple(sorted({str(g) for g in groups or () if str(g).strip()}))
|
|
85
|
+
if role == "admin" or not restricted:
|
|
86
|
+
return Access(user=user, role=role, groups=groups, connectors=None, restricted_names=restricted)
|
|
87
|
+
allowed = frozenset(s.name for s in specs
|
|
88
|
+
if not getattr(s, "access", None) or any(matches(e, user, role, groups) for e in s.access))
|
|
89
|
+
return Access(user=user, role=role, groups=groups, connectors=allowed, restricted_names=restricted)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def can_use(connector: str) -> bool:
|
|
93
|
+
a = current()
|
|
94
|
+
return a is None or a.connectors is None or connector in a.connectors
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def can_see(restricted) -> bool:
|
|
98
|
+
"""May the current caller see a record that used these restricted connectors?"""
|
|
99
|
+
a = current()
|
|
100
|
+
if a is None or a.connectors is None or not restricted:
|
|
101
|
+
return True
|
|
102
|
+
return set(restricted) <= set(a.connectors)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def scheduler_access(cfg) -> Access | None:
|
|
106
|
+
"""The identity the scheduler's own work (sweeps, autopilot, web intel) runs as.
|
|
107
|
+
|
|
108
|
+
scheduler:
|
|
109
|
+
groups: [soc-automation] # connector access lists are matched against these
|
|
110
|
+
role: operator
|
|
111
|
+
|
|
112
|
+
Without a ``scheduler`` section it runs with no person behind it and may use every connector
|
|
113
|
+
(what it reads from restricted sources is then restricted like any hunt). Saved-hunt replays always
|
|
114
|
+
run as the person who saved the hunt."""
|
|
115
|
+
s = ((getattr(cfg, "raw", None) or {}).get("scheduler") or {})
|
|
116
|
+
if not s:
|
|
117
|
+
return None
|
|
118
|
+
from .connectors.spec import parse_connectors
|
|
119
|
+
try:
|
|
120
|
+
specs = parse_connectors(list(getattr(cfg, "connectors", None) or []))
|
|
121
|
+
except Exception:
|
|
122
|
+
specs = []
|
|
123
|
+
role = str(s.get("role", "operator")).lower()
|
|
124
|
+
if role not in ROLE_RANK:
|
|
125
|
+
raise ValueError("scheduler.role must be viewer, operator or admin")
|
|
126
|
+
groups = s.get("groups") or []
|
|
127
|
+
if not isinstance(groups, list):
|
|
128
|
+
raise ValueError("scheduler.groups must be a list")
|
|
129
|
+
return build(specs, user=str(s.get("user", "scheduler")), role=role, groups=groups)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_as(a: Access | None, fn, *args, **kw):
|
|
133
|
+
tok = set_current(a)
|
|
134
|
+
try:
|
|
135
|
+
return fn(*args, **kw)
|
|
136
|
+
finally:
|
|
137
|
+
reset(tok)
|
actaeon/assistant.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The Ask assistant: questions and hunt requests in plain language.
|
|
2
|
+
|
|
3
|
+
Actions the model can take (one JSON object per turn):
|
|
4
|
+
search_records {query} past hunts, leads and analyst notes that match
|
|
5
|
+
get_hunt {id} one hunt's verdict and findings
|
|
6
|
+
list_leads {status} open or recent leads
|
|
7
|
+
lookup_intel {value} is a value in the intel store
|
|
8
|
+
start_hunt {hypothesis, context} run a full hunt now (stored like any hunt, origin ask:<user>)
|
|
9
|
+
ask_hunt {hunt_id, question} follow-up on a finished hunt (its chat; new searches become evidence)
|
|
10
|
+
answer {text, hunts, leads} final reply
|
|
11
|
+
Data is only searched inside hunts, so everything the assistant finds is stored
|
|
12
|
+
with its evidence. Conversations are stored with links to the hunts and leads used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
|
|
22
|
+
from . import db
|
|
23
|
+
from .connectors.untrusted import SYSTEM_RULE
|
|
24
|
+
from .hunt.jsonparse import first_object
|
|
25
|
+
|
|
26
|
+
MAX_ACTIONS = 6
|
|
27
|
+
MAX_HUNTS = 2
|
|
28
|
+
_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
SYSTEM = f"""You are Actaeon's assistant for a security team. You answer questions and run threat hunts.
|
|
31
|
+
Reply with exactly ONE JSON object per turn, one of:
|
|
32
|
+
{{"action": "search_records", "query": "words"}}
|
|
33
|
+
{{"action": "get_hunt", "id": "hunt id"}}
|
|
34
|
+
{{"action": "list_leads", "status": "open|new|closed"}}
|
|
35
|
+
{{"action": "lookup_intel", "value": "IP, domain, hash ..."}}
|
|
36
|
+
{{"action": "start_hunt", "hypothesis": "a specific, testable hypothesis with a time window", "context": "why"}}
|
|
37
|
+
{{"action": "ask_hunt", "hunt_id": "id", "question": "follow-up question"}}
|
|
38
|
+
{{"action": "answer", "text": "plain answer", "hunts": ["ids you used"], "leads": [lead ids]}}
|
|
39
|
+
Rules: to find out whether something happened in the environment, start a hunt (you cannot search data
|
|
40
|
+
directly). Check records first so you do not repeat a recent hunt. Every factual claim must come from a tool
|
|
41
|
+
result; say plainly when you do not know. Name hunt ids and lead ids in the answer. At most {MAX_HUNTS} hunts.
|
|
42
|
+
{SYSTEM_RULE}"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now() -> str:
|
|
46
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Conversations:
|
|
50
|
+
def __init__(self, path):
|
|
51
|
+
self.path = path
|
|
52
|
+
with self._conn() as c:
|
|
53
|
+
c.executescript("""
|
|
54
|
+
CREATE TABLE IF NOT EXISTS conversations (id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT NOT NULL,
|
|
55
|
+
created TEXT NOT NULL, updated TEXT NOT NULL, title TEXT NOT NULL);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS conv_msgs (id INTEGER PRIMARY KEY AUTOINCREMENT, conv_id INTEGER NOT NULL,
|
|
57
|
+
ts TEXT NOT NULL, role TEXT NOT NULL, user TEXT, content TEXT NOT NULL, links TEXT);
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
def _conn(self):
|
|
61
|
+
c = db.connect(self.path)
|
|
62
|
+
return c
|
|
63
|
+
|
|
64
|
+
def create(self, user: str, title: str) -> int:
|
|
65
|
+
with _lock, self._conn() as c:
|
|
66
|
+
return c.execute("INSERT INTO conversations (user, created, updated, title) VALUES (?,?,?,?)",
|
|
67
|
+
(user, _now(), _now(), title[:120])).lastrowid
|
|
68
|
+
|
|
69
|
+
def add(self, cid: int, role: str, content: str, user: str = "", links: dict | None = None) -> None:
|
|
70
|
+
with _lock, self._conn() as c:
|
|
71
|
+
c.execute("INSERT INTO conv_msgs (conv_id, ts, role, user, content, links) VALUES (?,?,?,?,?,?)",
|
|
72
|
+
(cid, _now(), role, user, content[:20000], json.dumps(links or {})))
|
|
73
|
+
c.execute("UPDATE conversations SET updated=? WHERE id=?", (_now(), cid))
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _mine(owner: str) -> bool:
|
|
77
|
+
"""With connector access limits in force, people only see their own conversations (answers can
|
|
78
|
+
quote restricted hunts). Admins and unrestricted callers see all."""
|
|
79
|
+
from .access import current
|
|
80
|
+
a = current()
|
|
81
|
+
return a is None or a.connectors is None or owner == a.user
|
|
82
|
+
|
|
83
|
+
def get(self, cid: int) -> dict | None:
|
|
84
|
+
with self._conn() as c:
|
|
85
|
+
r = c.execute("SELECT * FROM conversations WHERE id=?", (int(cid),)).fetchone()
|
|
86
|
+
if not r or not self._mine(r["user"]):
|
|
87
|
+
return None
|
|
88
|
+
msgs = [{**dict(m), "links": json.loads(m["links"] or "{}")}
|
|
89
|
+
for m in c.execute("SELECT * FROM conv_msgs WHERE conv_id=? ORDER BY id", (int(cid),))]
|
|
90
|
+
return {**dict(r), "messages": msgs}
|
|
91
|
+
|
|
92
|
+
def list(self, limit: int = 100) -> list[dict]:
|
|
93
|
+
with self._conn() as c:
|
|
94
|
+
return [dict(r) for r in c.execute("SELECT * FROM conversations ORDER BY updated DESC LIMIT ?",
|
|
95
|
+
(int(limit),)) if self._mine(r["user"])]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _records(cfg, query: str) -> dict:
|
|
99
|
+
from .hunt import service as hs
|
|
100
|
+
mem = hs.memory_for(cfg).related(query, k=5)
|
|
101
|
+
words = [w for w in re.findall(r"[A-Za-z0-9._@:-]{3,}", query.lower())][:8]
|
|
102
|
+
hunts = []
|
|
103
|
+
for h in hs.store_for(cfg).list(limit=300):
|
|
104
|
+
text = (h["hypothesis"] + " " + json.dumps(h.get("verdict") or {})).lower()
|
|
105
|
+
if words and sum(w in text for w in words) >= max(1, len(words) // 2):
|
|
106
|
+
hunts.append({"id": h["id"], "created": h["created"], "outcome": h["outcome"],
|
|
107
|
+
"hypothesis": h["hypothesis"][:200], "summary": ((h.get("verdict") or {}).get("summary")
|
|
108
|
+
or "")[:300]})
|
|
109
|
+
if len(hunts) >= 8:
|
|
110
|
+
break
|
|
111
|
+
leads = [{"id": c["id"], "status": c["status"], "severity": c["severity"], "title": c["title"][:160]}
|
|
112
|
+
for c in hs.leads_for(cfg).list(limit=200)
|
|
113
|
+
if words and any(w in c["title"].lower() for w in words)][:8]
|
|
114
|
+
return {"hunts": hunts, "leads": leads, "notes": [n["text"][:300] for n in mem["notes"]],
|
|
115
|
+
"related_past_hunts": [{"id": h["hunt_id"], "outcome": h["outcome"], "hypothesis": h["hypothesis"][:160]}
|
|
116
|
+
for h in mem["hunts"]]}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _hunt_brief(h: dict) -> dict:
|
|
120
|
+
v = h.get("verdict") or {}
|
|
121
|
+
return {"id": h["id"], "status": h["status"], "outcome": h.get("outcome"), "confidence": v.get("confidence"),
|
|
122
|
+
"summary": v.get("summary"), "findings": [f"{f['claim']} ({', '.join(f['evidence'])})"
|
|
123
|
+
for f in v.get("findings") or []],
|
|
124
|
+
"verification": (v.get("verification") or {}).get("result"), "needs_review": v.get("needs_review")}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ask(cfg, message: str, *, user: str, conv_id: int | None = None, llm=None, hub=None) -> dict:
|
|
128
|
+
from .hunt import service as hs
|
|
129
|
+
message = (message or "").strip()[:4000]
|
|
130
|
+
if not message:
|
|
131
|
+
raise ValueError("message is required")
|
|
132
|
+
conv = Conversations(hs.store_for(cfg).path)
|
|
133
|
+
if conv_id is None or not conv.get(conv_id):
|
|
134
|
+
conv_id = conv.create(user, message)
|
|
135
|
+
conv.add(conv_id, "user", message, user)
|
|
136
|
+
if llm is None:
|
|
137
|
+
from .providers import get_llm
|
|
138
|
+
llm = get_llm()
|
|
139
|
+
plm = hs.privacy_llm(cfg, llm)
|
|
140
|
+
hist = (conv.get(conv_id) or {}).get("messages", [])[-10:-1]
|
|
141
|
+
history = "\n".join(f"{m['role'].upper()}: {m['content'][:1200]}" for m in hist)
|
|
142
|
+
msgs = [{"role": "system", "content": SYSTEM},
|
|
143
|
+
{"role": "user", "content": f"{hs.environment_context(cfg)}\n\nEarlier in this conversation:\n"
|
|
144
|
+
f"{history or '(none)'}\n\nNew message from {user}: {message}"}]
|
|
145
|
+
used_hunts: list[str] = []
|
|
146
|
+
used_leads: list[int] = []
|
|
147
|
+
started = 0
|
|
148
|
+
final = None
|
|
149
|
+
for step in range(MAX_ACTIONS + 1):
|
|
150
|
+
if step == MAX_ACTIONS:
|
|
151
|
+
msgs.append({"role": "user", "content": "No more actions. Give the answer now."})
|
|
152
|
+
res = plm.for_role("analyst").chat(msgs, max_tokens=1500)
|
|
153
|
+
if res.error:
|
|
154
|
+
final = {"text": f"(the model could not answer: {res.error[:200]})"}
|
|
155
|
+
break
|
|
156
|
+
act = first_object(res.text) or {}
|
|
157
|
+
kind = act.get("action")
|
|
158
|
+
if kind == "answer" or step == MAX_ACTIONS or not kind:
|
|
159
|
+
final = act if kind == "answer" else {"text": res.text[:4000]}
|
|
160
|
+
break
|
|
161
|
+
msgs.append({"role": "assistant", "content": json.dumps(act)[:3000]})
|
|
162
|
+
try:
|
|
163
|
+
if kind == "search_records":
|
|
164
|
+
out = _records(cfg, str(act.get("query", "")))
|
|
165
|
+
elif kind == "get_hunt":
|
|
166
|
+
h = hs.store_for(cfg).get(str(act.get("id", "")))
|
|
167
|
+
out = _hunt_brief(h) if h else {"error": "no such hunt"}
|
|
168
|
+
if h:
|
|
169
|
+
used_hunts.append(h["id"])
|
|
170
|
+
elif kind == "list_leads":
|
|
171
|
+
stt = str(act.get("status", "open"))
|
|
172
|
+
rows = hs.leads_for(cfg).list(open_only=stt == "open", status=stt if stt in ("new", "closed") else None,
|
|
173
|
+
limit=20)
|
|
174
|
+
out = [{"id": c["id"], "status": c["status"], "severity": c["severity"], "assignee": c["assignee"],
|
|
175
|
+
"title": c["title"][:160]} for c in rows]
|
|
176
|
+
elif kind == "lookup_intel":
|
|
177
|
+
out = hs.intel_for(cfg).lookup(str(act.get("value", "")))
|
|
178
|
+
elif kind == "start_hunt":
|
|
179
|
+
if started >= MAX_HUNTS:
|
|
180
|
+
out = {"error": f"at most {MAX_HUNTS} hunts per message"}
|
|
181
|
+
else:
|
|
182
|
+
started += 1
|
|
183
|
+
hyp = str(act.get("hypothesis", "")).strip()[:1000]
|
|
184
|
+
h = hs.run_hunt(cfg, hyp, origin=f"ask:{user}", actor=user,
|
|
185
|
+
context=f"Requested in the Ask assistant by {user}: {message[:500]}\n"
|
|
186
|
+
f"{str(act.get('context', ''))[:500]}", llm=llm, hub=hub)
|
|
187
|
+
used_hunts.append(h["id"])
|
|
188
|
+
lead = hs.leads_for(cfg).by_hunt(h["id"])
|
|
189
|
+
if lead:
|
|
190
|
+
used_leads.append(lead["id"])
|
|
191
|
+
out = {**_hunt_brief(h), "lead": lead["id"] if lead else None}
|
|
192
|
+
elif kind == "ask_hunt":
|
|
193
|
+
from .hunt.chat import ask as hunt_ask
|
|
194
|
+
hid = str(act.get("hunt_id", ""))
|
|
195
|
+
out = hunt_ask(cfg, hid, str(act.get("question", "")), author=user, llm=llm, hub=hub)
|
|
196
|
+
used_hunts.append(hid)
|
|
197
|
+
else:
|
|
198
|
+
out = {"error": f"unknown action {kind!r}"}
|
|
199
|
+
except (ValueError, LookupError) as exc:
|
|
200
|
+
out = {"error": str(exc)[:300]}
|
|
201
|
+
msgs.append({"role": "user", "content": f"Result of {kind}:\n<<UNTRUSTED-DATA ask>>\n"
|
|
202
|
+
f"{json.dumps(out, default=str)[:12000]}\n<<END-UNTRUSTED-DATA ask>>"})
|
|
203
|
+
text = str((final or {}).get("text") or "(no answer)")[:8000]
|
|
204
|
+
hunts = list(dict.fromkeys(used_hunts + [h for h in (final or {}).get("hunts") or [] if isinstance(h, str)
|
|
205
|
+
and hs.store_for(cfg).get(h, with_steps=False)]))
|
|
206
|
+
leads = list(dict.fromkeys(used_leads + [c for c in (final or {}).get("leads") or [] if isinstance(c, int)
|
|
207
|
+
and hs.leads_for(cfg).get(c)]))
|
|
208
|
+
conv.add(conv_id, "assistant", text, "actaeon", {"hunts": hunts, "leads": leads})
|
|
209
|
+
return {"conversation_id": conv_id, "answer": text, "hunts": hunts, "leads": leads}
|