trident-windows 0.9.0__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.
- trident/__init__.py +2 -0
- trident/__main__.py +289 -0
- trident/_daemon.py +89 -0
- trident/action.py +213 -0
- trident/audit.py +48 -0
- trident/cascade.py +165 -0
- trident/cli.py +436 -0
- trident/config.py +114 -0
- trident/contracts.py +55 -0
- trident/data/icons/trident-danger-preview.png +0 -0
- trident/data/icons/trident-danger.ico +0 -0
- trident/data/icons/trident-monitor-preview.png +0 -0
- trident/data/icons/trident-monitor.ico +0 -0
- trident/data/icons/trident-safe-preview.png +0 -0
- trident/data/icons/trident-safe.ico +0 -0
- trident/data/icons/trident-trust-preview.png +0 -0
- trident/data/icons/trident-trust.ico +0 -0
- trident/data/strings.json +112 -0
- trident/data/trident.toml +56 -0
- trident/decision.py +299 -0
- trident/mode.py +64 -0
- trident/models.py +131 -0
- trident/paths.py +37 -0
- trident/report.py +212 -0
- trident/runtime.py +99 -0
- trident/setup/__init__.py +1 -0
- trident/setup/download_model.py +61 -0
- trident/system_defaults.py +79 -0
- trident/telemetry.py +192 -0
- trident/toast.py +125 -0
- trident/trust_store.py +105 -0
- trident/ui.py +145 -0
- trident/uri_handler.py +107 -0
- trident_windows-0.9.0.dist-info/METADATA +314 -0
- trident_windows-0.9.0.dist-info/RECORD +40 -0
- trident_windows-0.9.0.dist-info/WHEEL +5 -0
- trident_windows-0.9.0.dist-info/entry_points.txt +2 -0
- trident_windows-0.9.0.dist-info/licenses/LICENSE +21 -0
- trident_windows-0.9.0.dist-info/licenses/LICENSE-MODEL +21 -0
- trident_windows-0.9.0.dist-info/top_level.txt +1 -0
trident/__init__.py
ADDED
trident/__main__.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""TRIDENT entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import toast
|
|
11
|
+
from .action import ActionLayer
|
|
12
|
+
from .audit import AuditLog
|
|
13
|
+
from .cascade import Cascade
|
|
14
|
+
from .config import load
|
|
15
|
+
from .contracts import Action, Evidence
|
|
16
|
+
from .decision import DecisionKernel
|
|
17
|
+
from .mode import ModeStore
|
|
18
|
+
from .runtime import (
|
|
19
|
+
CLEANUP_DIR,
|
|
20
|
+
LOGS_DIR,
|
|
21
|
+
TRUSTED_PATHS_FILE,
|
|
22
|
+
_DATA_ROOT,
|
|
23
|
+
ensure_user_dirs,
|
|
24
|
+
)
|
|
25
|
+
from .system_defaults import is_system_safe
|
|
26
|
+
from .telemetry import TelemetrySession
|
|
27
|
+
from .trust_store import TrustStore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_paths(cfg) -> dict:
|
|
31
|
+
"""Resolve model, tokenizer, and config paths.
|
|
32
|
+
|
|
33
|
+
Model files live in %LOCALAPPDATA%\\TRIDENT\\models, populated by
|
|
34
|
+
`trident models fetch`.
|
|
35
|
+
"""
|
|
36
|
+
model_dir = _DATA_ROOT / "models"
|
|
37
|
+
return {
|
|
38
|
+
"model_path": model_dir / cfg.model.filename,
|
|
39
|
+
"tokenizer_dir": model_dir / "tokenizer",
|
|
40
|
+
"config_path": model_dir / "rl_agent_config.json",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ts() -> str:
|
|
45
|
+
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_self_venv(image_path: str | None) -> bool:
|
|
49
|
+
if not image_path:
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
venv_prefix = os.path.normcase(os.path.abspath(sys.prefix))
|
|
53
|
+
path = os.path.normcase(os.path.abspath(image_path))
|
|
54
|
+
return path.startswith(venv_prefix + os.sep)
|
|
55
|
+
except Exception:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
"""Load config, wire all layers, run the watch loop."""
|
|
61
|
+
ensure_user_dirs()
|
|
62
|
+
|
|
63
|
+
# Auto-resolves to %LOCALAPPDATA%\TRIDENT\config\trident.toml,
|
|
64
|
+
# seeding from the shipped default on first run.
|
|
65
|
+
cfg = load()
|
|
66
|
+
paths = _resolve_paths(cfg)
|
|
67
|
+
|
|
68
|
+
if not paths["model_path"].exists():
|
|
69
|
+
print(f"[TRIDENT] Model not found at {paths['model_path']}.", file=sys.stderr)
|
|
70
|
+
print("[TRIDENT] Run `trident models fetch` to download it.", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
print("[TRIDENT] Loading decision kernel...")
|
|
74
|
+
kernel = DecisionKernel(
|
|
75
|
+
model_path=paths["model_path"],
|
|
76
|
+
tokenizer_dir=paths["tokenizer_dir"],
|
|
77
|
+
config_path=paths["config_path"],
|
|
78
|
+
temperature=1.0,
|
|
79
|
+
max_len=cfg.model.max_len,
|
|
80
|
+
head_max_len=cfg.model.head_max_len,
|
|
81
|
+
)
|
|
82
|
+
kernel.load()
|
|
83
|
+
print("[TRIDENT] Model ready.")
|
|
84
|
+
|
|
85
|
+
mode_store = ModeStore()
|
|
86
|
+
mode = mode_store.mode
|
|
87
|
+
print(f"[TRIDENT] Mode: {mode}")
|
|
88
|
+
|
|
89
|
+
cascade = Cascade(
|
|
90
|
+
tier1_threshold=cfg.cascade.tier1_threshold,
|
|
91
|
+
tier2_threshold=cfg.cascade.tier2_threshold,
|
|
92
|
+
tier2_max_cycles=cfg.cascade.tier2_max_cycles,
|
|
93
|
+
suppression_after=cfg.cascade.escalation_suppression_after,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
action_layer = ActionLayer(
|
|
97
|
+
ps1_output_dir=CLEANUP_DIR,
|
|
98
|
+
safety_whitelist=cfg.action.safety_whitelist,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
audit = AuditLog(LOGS_DIR)
|
|
102
|
+
trust_store = TrustStore(TRUSTED_PATHS_FILE)
|
|
103
|
+
trusted_count = len(trust_store.all_trusted())
|
|
104
|
+
if trusted_count:
|
|
105
|
+
print(f"[TRIDENT] Trust store loaded: {trusted_count} path(s) trusted.")
|
|
106
|
+
|
|
107
|
+
telemetry = TelemetrySession(ring_buffer_size=cfg.telemetry.ring_buffer_size)
|
|
108
|
+
telemetry.start()
|
|
109
|
+
|
|
110
|
+
self_pid = os.getpid()
|
|
111
|
+
print(f"[TRIDENT] Watching for process events (self pid={self_pid}). Ctrl+C to stop.")
|
|
112
|
+
|
|
113
|
+
toast.show_startup(trusted_count, mode=mode)
|
|
114
|
+
|
|
115
|
+
suppression_announced: set[int] = set()
|
|
116
|
+
runtime_trusted_pids: set[int] = set()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
while True:
|
|
120
|
+
new_mode = mode_store.poll()
|
|
121
|
+
if new_mode is not None and new_mode != mode:
|
|
122
|
+
mode = new_mode
|
|
123
|
+
print(f"[TRIDENT] {_ts()} mode changed to {mode}")
|
|
124
|
+
|
|
125
|
+
newly_trusted = trust_store.poll()
|
|
126
|
+
if newly_trusted:
|
|
127
|
+
import psutil
|
|
128
|
+
for p in newly_trusted:
|
|
129
|
+
print(f"[TRIDENT] {_ts()} trusted via toast: {p}")
|
|
130
|
+
try:
|
|
131
|
+
target = os.path.normcase(os.path.abspath(p))
|
|
132
|
+
except Exception:
|
|
133
|
+
continue
|
|
134
|
+
for proc in psutil.process_iter(["pid", "exe"]):
|
|
135
|
+
try:
|
|
136
|
+
exe = proc.info.get("exe")
|
|
137
|
+
if not exe:
|
|
138
|
+
continue
|
|
139
|
+
if os.path.normcase(os.path.abspath(exe)) == target:
|
|
140
|
+
runtime_trusted_pids.add(proc.info["pid"])
|
|
141
|
+
print(
|
|
142
|
+
f"[TRIDENT] {_ts()} silenced running pid "
|
|
143
|
+
f"{proc.info['pid']} (trusted)"
|
|
144
|
+
)
|
|
145
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
batch = telemetry.next_batch(cfg.telemetry.batch_size_max)
|
|
149
|
+
if not batch:
|
|
150
|
+
time.sleep(0.2)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
filtered = [ev for ev in batch if ev.ppid != self_pid]
|
|
154
|
+
skipped_internal = len(batch) - len(filtered)
|
|
155
|
+
if skipped_internal:
|
|
156
|
+
print(
|
|
157
|
+
f"[TRIDENT] {_ts()} skipped {skipped_internal} "
|
|
158
|
+
f"self-spawned process(es)"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
before = len(filtered)
|
|
162
|
+
filtered = [ev for ev in filtered if not _is_self_venv(ev.image_path)]
|
|
163
|
+
skipped_venv = before - len(filtered)
|
|
164
|
+
if skipped_venv:
|
|
165
|
+
print(
|
|
166
|
+
f"[TRIDENT] {_ts()} skipped {skipped_venv} "
|
|
167
|
+
f"self-venv process(es)"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
to_score = []
|
|
171
|
+
skipped_system = 0
|
|
172
|
+
for ev in filtered:
|
|
173
|
+
name = Path(ev.image_path).name if ev.image_path else ""
|
|
174
|
+
if is_system_safe(name, bool(ev.is_system_path)):
|
|
175
|
+
audit.write({
|
|
176
|
+
"pid": ev.pid,
|
|
177
|
+
"name": name,
|
|
178
|
+
"path": ev.image_path,
|
|
179
|
+
"outcome": "system_pretrusted",
|
|
180
|
+
})
|
|
181
|
+
skipped_system += 1
|
|
182
|
+
continue
|
|
183
|
+
to_score.append(ev)
|
|
184
|
+
|
|
185
|
+
if skipped_system:
|
|
186
|
+
print(
|
|
187
|
+
f"[TRIDENT] {_ts()} skipped {skipped_system} "
|
|
188
|
+
f"known system binar{'y' if skipped_system == 1 else 'ies'}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if not to_score:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
decisions = kernel.score_batch(to_score)
|
|
195
|
+
for ev, dec in zip(to_score, decisions):
|
|
196
|
+
name = Path(ev.image_path).name if ev.image_path else "?"
|
|
197
|
+
tier = cascade.route(dec, ev)
|
|
198
|
+
is_trusted = bool(ev.image_path) and trust_store.is_trusted(ev.image_path)
|
|
199
|
+
is_runtime_trusted = ev.pid in runtime_trusted_pids
|
|
200
|
+
|
|
201
|
+
suffix = ""
|
|
202
|
+
if is_trusted:
|
|
203
|
+
suffix = " [trusted]"
|
|
204
|
+
elif is_runtime_trusted:
|
|
205
|
+
suffix = " [trusted:runtime]"
|
|
206
|
+
|
|
207
|
+
print(
|
|
208
|
+
f"[TRIDENT] {_ts()} {ev.pid} {name} -> "
|
|
209
|
+
f"action={dec.action} trust={dec.trust_level} "
|
|
210
|
+
f"conf={dec.confidence:.3f} tier={tier}{suffix}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if is_trusted or is_runtime_trusted:
|
|
214
|
+
outcome = (
|
|
215
|
+
"trusted_override" if dec.action == "terminate"
|
|
216
|
+
else "trusted_allow"
|
|
217
|
+
)
|
|
218
|
+
elif tier == 1:
|
|
219
|
+
if mode == "enforce":
|
|
220
|
+
evid = Evidence(process_tree=[ev])
|
|
221
|
+
act = Action(
|
|
222
|
+
action_type="terminate",
|
|
223
|
+
target_pid=ev.pid,
|
|
224
|
+
evidence=evid,
|
|
225
|
+
)
|
|
226
|
+
action_layer.execute(act)
|
|
227
|
+
outcome = "killed"
|
|
228
|
+
toast.show_kill(
|
|
229
|
+
name, ev.pid, dec.threat_score, tier,
|
|
230
|
+
path=ev.image_path,
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
outcome = "would_kill"
|
|
234
|
+
print(
|
|
235
|
+
f"[TRIDENT] {_ts()} [{mode}] would kill "
|
|
236
|
+
f"{name} pid={ev.pid} conf={dec.confidence:.3f}"
|
|
237
|
+
)
|
|
238
|
+
elif tier == 2:
|
|
239
|
+
cascade.record_cycle(dec, ev)
|
|
240
|
+
outcome = "monitored"
|
|
241
|
+
else:
|
|
242
|
+
if cascade.should_escalate(dec, ev):
|
|
243
|
+
if mode == "dry-run":
|
|
244
|
+
outcome = "would_toast"
|
|
245
|
+
print(
|
|
246
|
+
f"[TRIDENT] {_ts()} [dry-run] would escalate "
|
|
247
|
+
f"{name} pid={ev.pid} score={dec.threat_score:.1f}"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
toast.show_escalation(
|
|
251
|
+
name, ev.pid, dec.threat_score, dec.confidence,
|
|
252
|
+
path=ev.image_path,
|
|
253
|
+
)
|
|
254
|
+
cascade.record_user_response(ev.pid, "ignore")
|
|
255
|
+
outcome = "toast"
|
|
256
|
+
elif cascade.is_suppressed(ev.pid):
|
|
257
|
+
outcome = "suppressed"
|
|
258
|
+
if ev.pid not in suppression_announced:
|
|
259
|
+
suppression_announced.add(ev.pid)
|
|
260
|
+
if mode != "dry-run":
|
|
261
|
+
toast.show_suppressed(
|
|
262
|
+
name, ev.pid, cascade.suppression_after
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
outcome = "silent_allow"
|
|
266
|
+
|
|
267
|
+
audit.write({
|
|
268
|
+
"pid": ev.pid,
|
|
269
|
+
"name": name,
|
|
270
|
+
"path": ev.image_path,
|
|
271
|
+
"action": str(dec.action),
|
|
272
|
+
"trust": str(dec.trust_level),
|
|
273
|
+
"conf": round(float(dec.confidence), 4),
|
|
274
|
+
"threat_score": round(float(dec.threat_score), 3),
|
|
275
|
+
"tier": tier,
|
|
276
|
+
"outcome": outcome,
|
|
277
|
+
"trusted_path": is_trusted or is_runtime_trusted,
|
|
278
|
+
"mode": mode,
|
|
279
|
+
})
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
print("\n[TRIDENT] Shutting down.")
|
|
282
|
+
finally:
|
|
283
|
+
telemetry.stop()
|
|
284
|
+
audit.close()
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
trident/_daemon.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Background daemon entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .runtime import (
|
|
11
|
+
DAEMON_LOG,
|
|
12
|
+
clear_pid,
|
|
13
|
+
clear_stop_signal,
|
|
14
|
+
ensure_runtime_dir,
|
|
15
|
+
stop_requested,
|
|
16
|
+
write_pid,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _setup_file_logging() -> None:
|
|
21
|
+
ensure_runtime_dir()
|
|
22
|
+
try:
|
|
23
|
+
if DAEMON_LOG.exists() and DAEMON_LOG.stat().st_size > 5_000_000:
|
|
24
|
+
backup = DAEMON_LOG.with_suffix(".log.1")
|
|
25
|
+
if backup.exists():
|
|
26
|
+
backup.unlink()
|
|
27
|
+
DAEMON_LOG.rename(backup)
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
log_file = open(DAEMON_LOG, "a", encoding="utf-8", buffering=1)
|
|
32
|
+
sys.stdout = log_file
|
|
33
|
+
sys.stderr = log_file
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(
|
|
36
|
+
level=logging.INFO,
|
|
37
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
38
|
+
stream=log_file,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
_setup_file_logging()
|
|
44
|
+
clear_stop_signal()
|
|
45
|
+
write_pid(os.getpid())
|
|
46
|
+
|
|
47
|
+
print(f"[TRIDENT daemon] starting pid={os.getpid()}")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from . import models as models_mod
|
|
51
|
+
if not models_mod.is_present():
|
|
52
|
+
print(f"[TRIDENT daemon] model not found in {models_mod.model_dir()}")
|
|
53
|
+
print("[TRIDENT daemon] run `trident models fetch` and try again")
|
|
54
|
+
clear_pid()
|
|
55
|
+
return 1
|
|
56
|
+
print(f"[TRIDENT daemon] model present in {models_mod.model_dir()}")
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"[TRIDENT daemon] model check failed: {e}")
|
|
59
|
+
clear_pid()
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
from . import __main__ as trident_main
|
|
63
|
+
|
|
64
|
+
import time as _time
|
|
65
|
+
_original_sleep = _time.sleep
|
|
66
|
+
|
|
67
|
+
def _interruptible_sleep(seconds: float) -> None:
|
|
68
|
+
end = _time.monotonic() + seconds
|
|
69
|
+
while _time.monotonic() < end:
|
|
70
|
+
if stop_requested():
|
|
71
|
+
print("[TRIDENT daemon] stop signal received")
|
|
72
|
+
raise KeyboardInterrupt
|
|
73
|
+
_original_sleep(0.1)
|
|
74
|
+
|
|
75
|
+
trident_main.time.sleep = _interruptible_sleep
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return trident_main.main()
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
print("[TRIDENT daemon] interrupted, shutting down")
|
|
81
|
+
return 0
|
|
82
|
+
finally:
|
|
83
|
+
clear_pid()
|
|
84
|
+
clear_stop_signal()
|
|
85
|
+
print("[TRIDENT daemon] exited")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
trident/action.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Layer 4 - Kill sequence and forensic uninstaller.
|
|
2
|
+
|
|
3
|
+
Kill tree uses psutil + TerminateProcess directly -- no subprocess
|
|
4
|
+
calls, so nothing flashes on screen when the daemon runs under
|
|
5
|
+
pythonw.exe. PowerShell is only invoked for the rarely-run forensic
|
|
6
|
+
lookups (find_matching_services, find_autostart_entries), and those
|
|
7
|
+
go through _run_hidden.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .contracts import Action, Evidence, ProcessEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
21
|
+
_NO_WINDOW_FLAGS = 0x08000000 if _IS_WINDOWS else 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _startup_info() -> subprocess.STARTUPINFO | None:
|
|
25
|
+
if not _IS_WINDOWS:
|
|
26
|
+
return None
|
|
27
|
+
si = subprocess.STARTUPINFO()
|
|
28
|
+
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
29
|
+
si.wShowWindow = subprocess.SW_HIDE
|
|
30
|
+
return si
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run_hidden(cmd, **kwargs) -> subprocess.CompletedProcess:
|
|
34
|
+
if _IS_WINDOWS:
|
|
35
|
+
kwargs.setdefault("startupinfo", _startup_info())
|
|
36
|
+
kwargs.setdefault("creationflags", _NO_WINDOW_FLAGS)
|
|
37
|
+
return subprocess.run(cmd, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_ps(cmd: str, timeout: int = 15) -> tuple[int, str]:
|
|
41
|
+
"""Run a PowerShell command with no console window.
|
|
42
|
+
|
|
43
|
+
Only used for forensic lookups that are not on the kill path.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
r = _run_hidden(
|
|
47
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
|
|
48
|
+
capture_output=True, text=True, timeout=timeout,
|
|
49
|
+
)
|
|
50
|
+
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
|
51
|
+
except subprocess.TimeoutExpired:
|
|
52
|
+
return -1, "timeout"
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return -1, str(e)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ActionLayer:
|
|
58
|
+
"""Kills process trees and generates forensic cleanup scripts."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, ps1_output_dir: Path, safety_whitelist: list[str]) -> None:
|
|
61
|
+
self.ps1_output_dir = Path(ps1_output_dir)
|
|
62
|
+
self.ps1_output_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
self.safety_whitelist = [p.lower() for p in safety_whitelist]
|
|
64
|
+
|
|
65
|
+
def _is_whitelisted(self, path: str | None) -> bool:
|
|
66
|
+
if not path:
|
|
67
|
+
return True
|
|
68
|
+
low = path.lower()
|
|
69
|
+
return any(low.startswith(w) for w in self.safety_whitelist)
|
|
70
|
+
|
|
71
|
+
def kill_tree(self, root_pid: int) -> bool:
|
|
72
|
+
"""Terminate a PID and all its descendants. Pure-Python.
|
|
73
|
+
|
|
74
|
+
No subprocess calls, so nothing can flash on screen. Uses
|
|
75
|
+
psutil to walk the tree and psutil.Process.kill() (which on
|
|
76
|
+
Windows maps to TerminateProcess) per node.
|
|
77
|
+
|
|
78
|
+
Falls back to `taskkill /T /F` via _run_hidden only if psutil
|
|
79
|
+
raises AccessDenied on a protected child.
|
|
80
|
+
"""
|
|
81
|
+
import psutil
|
|
82
|
+
|
|
83
|
+
# Collect the tree first. If the root is already gone, we're done.
|
|
84
|
+
try:
|
|
85
|
+
root = psutil.Process(root_pid)
|
|
86
|
+
except psutil.NoSuchProcess:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
descendants = root.children(recursive=True)
|
|
91
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
92
|
+
descendants = []
|
|
93
|
+
|
|
94
|
+
# Kill children before parents so nothing gets re-parented.
|
|
95
|
+
denied_any = False
|
|
96
|
+
for child in descendants:
|
|
97
|
+
try:
|
|
98
|
+
child.kill()
|
|
99
|
+
except psutil.NoSuchProcess:
|
|
100
|
+
continue
|
|
101
|
+
except psutil.AccessDenied:
|
|
102
|
+
denied_any = True
|
|
103
|
+
except Exception:
|
|
104
|
+
denied_any = True
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
root.kill()
|
|
108
|
+
except psutil.NoSuchProcess:
|
|
109
|
+
pass
|
|
110
|
+
except psutil.AccessDenied:
|
|
111
|
+
denied_any = True
|
|
112
|
+
except Exception:
|
|
113
|
+
denied_any = True
|
|
114
|
+
|
|
115
|
+
# If any node refused psutil.kill (protected or elevated), fall
|
|
116
|
+
# back to taskkill /T /F for the whole tree. Wrapped with the
|
|
117
|
+
# hidden-window flags so it doesn't flash.
|
|
118
|
+
if denied_any:
|
|
119
|
+
_run_hidden(
|
|
120
|
+
["taskkill", "/PID", str(root_pid), "/T", "/F"],
|
|
121
|
+
capture_output=True, text=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Give Windows a moment to reap the processes, then verify.
|
|
125
|
+
time.sleep(0.2)
|
|
126
|
+
return not psutil.pid_exists(root_pid)
|
|
127
|
+
|
|
128
|
+
def find_matching_services(self, image_path: str) -> list[str]:
|
|
129
|
+
"""List Windows services whose binary path matches image_path."""
|
|
130
|
+
if not image_path:
|
|
131
|
+
return []
|
|
132
|
+
name = Path(image_path).name
|
|
133
|
+
_rc, out = _run_ps(
|
|
134
|
+
f"Get-CimInstance Win32_Service | Where-Object "
|
|
135
|
+
f"{{$_.PathName -like '*{name}*'}} | "
|
|
136
|
+
f"Select-Object -ExpandProperty Name"
|
|
137
|
+
)
|
|
138
|
+
return [s.strip() for s in out.splitlines() if s.strip()]
|
|
139
|
+
|
|
140
|
+
def find_autostart_entries(self, image_path: str) -> dict:
|
|
141
|
+
"""Return scheduled tasks and autostart entries for this binary."""
|
|
142
|
+
if not image_path:
|
|
143
|
+
return {"run_keys": [], "tasks": [], "startup": []}
|
|
144
|
+
name = Path(image_path).name
|
|
145
|
+
_rc, out = _run_ps(
|
|
146
|
+
f"schtasks /Query /FO CSV /NH | Select-String '{name}' | "
|
|
147
|
+
f"ForEach-Object {{ ($_ -split ',')[0].Trim('\"') }}"
|
|
148
|
+
)
|
|
149
|
+
tasks = [t.strip() for t in out.splitlines() if t.strip()]
|
|
150
|
+
return {"run_keys": [], "tasks": tasks, "startup": []}
|
|
151
|
+
|
|
152
|
+
def generate_cleanup_script(
|
|
153
|
+
self, evidence: Evidence, ps1_name: str | None = None
|
|
154
|
+
) -> Path:
|
|
155
|
+
"""Write a reviewable .ps1 that removes the artefact."""
|
|
156
|
+
if ps1_name is None:
|
|
157
|
+
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
158
|
+
ps1_name = f"trident-cleanup-{ts}.ps1"
|
|
159
|
+
out = self.ps1_output_dir / ps1_name
|
|
160
|
+
|
|
161
|
+
root = evidence.process_tree[0] if evidence.process_tree else None
|
|
162
|
+
pid = root.pid if root else 0
|
|
163
|
+
name = Path(root.image_path).name if root and root.image_path else "unknown"
|
|
164
|
+
path = root.image_path if root else ""
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
"# TRIDENT Cleanup Script - review before running",
|
|
168
|
+
f"# Generated: {datetime.now().isoformat()}",
|
|
169
|
+
f"# Target: {name} (PID {pid})",
|
|
170
|
+
"",
|
|
171
|
+
"$ErrorActionPreference = 'Stop'",
|
|
172
|
+
"",
|
|
173
|
+
f'Write-Host "[TRIDENT] Stopping PID {pid}..."',
|
|
174
|
+
f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue",
|
|
175
|
+
"Start-Sleep -Milliseconds 500",
|
|
176
|
+
"",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
if path and not self._is_whitelisted(path):
|
|
180
|
+
lines += [
|
|
181
|
+
f'Write-Host "[TRIDENT] Removing {path}"',
|
|
182
|
+
f'if (Test-Path "{path}") {{ '
|
|
183
|
+
f'Remove-Item -Path "{path}" -Force -ErrorAction SilentlyContinue }}',
|
|
184
|
+
"",
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
lines += ['Write-Host "[TRIDENT] Cleanup complete."', ""]
|
|
188
|
+
|
|
189
|
+
out.write_text("\n".join(lines), encoding="utf-8")
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
def execute(self, action: Action) -> Action:
|
|
193
|
+
"""Execute a terminate Action. Returns the same Action, mutated."""
|
|
194
|
+
try:
|
|
195
|
+
root_pid = action.target_pid
|
|
196
|
+
root = (
|
|
197
|
+
action.evidence.process_tree[0]
|
|
198
|
+
if action.evidence.process_tree
|
|
199
|
+
else None
|
|
200
|
+
)
|
|
201
|
+
path = root.image_path if root else None
|
|
202
|
+
|
|
203
|
+
if path and not self._is_whitelisted(path):
|
|
204
|
+
ps1 = self.generate_cleanup_script(action.evidence)
|
|
205
|
+
action.generated_ps1_path = ps1
|
|
206
|
+
|
|
207
|
+
ok = self.kill_tree(root_pid)
|
|
208
|
+
action.executed = ok
|
|
209
|
+
if not ok:
|
|
210
|
+
action.error = "kill_tree returned False"
|
|
211
|
+
except Exception as e:
|
|
212
|
+
action.error = str(e)
|
|
213
|
+
return action
|
trident/audit.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Append-only JSONL decision log. One file per UTC day under ./logs/.
|
|
2
|
+
|
|
3
|
+
Usage from __main__.py:
|
|
4
|
+
from trident.audit import AuditLog
|
|
5
|
+
audit = AuditLog("logs")
|
|
6
|
+
...
|
|
7
|
+
audit.write({"pid": pid, "name": name, "action": action,
|
|
8
|
+
"trust": trust, "conf": conf, "tier": tier})
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog:
|
|
20
|
+
def __init__(self, log_dir: str | os.PathLike = "logs") -> None:
|
|
21
|
+
self._dir = Path(log_dir)
|
|
22
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self._fh = None
|
|
24
|
+
self._current_date: str | None = None
|
|
25
|
+
|
|
26
|
+
def _roll(self) -> None:
|
|
27
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
28
|
+
if today == self._current_date and self._fh is not None:
|
|
29
|
+
return
|
|
30
|
+
if self._fh is not None:
|
|
31
|
+
self._fh.close()
|
|
32
|
+
path = self._dir / f"decisions-{today}.jsonl"
|
|
33
|
+
self._fh = open(path, "a", encoding="utf-8", newline="\n")
|
|
34
|
+
self._current_date = today
|
|
35
|
+
|
|
36
|
+
def write(self, record: dict[str, Any]) -> None:
|
|
37
|
+
self._roll()
|
|
38
|
+
record.setdefault(
|
|
39
|
+
"ts", datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
40
|
+
)
|
|
41
|
+
self._fh.write(json.dumps(record, ensure_ascii=False, default=str))
|
|
42
|
+
self._fh.write("\n")
|
|
43
|
+
self._fh.flush()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
if self._fh is not None:
|
|
47
|
+
self._fh.close()
|
|
48
|
+
self._fh = None
|