command-gate 0.2.4__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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Sync business logic for the three MCP tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import getpass
|
|
5
|
+
from typing import TYPE_CHECKING, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
from cgate.db.commands import all_terminal
|
|
8
|
+
from cgate.db.mode import AppModeNotSetError, Mode
|
|
9
|
+
from cgate.db.types import BatchId, CommandStatus
|
|
10
|
+
from cgate.executor import selector
|
|
11
|
+
from cgate.mcp_server.auto_resolution import resolve_auto_behavior
|
|
12
|
+
from cgate.risk import find_risk
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from cgate.connections.store import ConnectionsRepo
|
|
16
|
+
from cgate.db.batches import BatchesRepo
|
|
17
|
+
from cgate.db.commands import CommandsRepo
|
|
18
|
+
from cgate.db.mode import AppModeRepo
|
|
19
|
+
from cgate.db.server_settings import ServerSettingsRepo
|
|
20
|
+
from cgate.db.types import Command, Connection
|
|
21
|
+
from cgate.mcp_server.auto_resolution import BehaviorDecision
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProposeCommandResult(TypedDict):
|
|
25
|
+
"""JSON-compatible result returned after queueing a command."""
|
|
26
|
+
|
|
27
|
+
batch_id: str
|
|
28
|
+
command_id: str
|
|
29
|
+
position: int
|
|
30
|
+
status: str
|
|
31
|
+
mode: NotRequired[str | None]
|
|
32
|
+
server_auto_allowed: NotRequired[bool | None]
|
|
33
|
+
effective_reason: NotRequired[str | None]
|
|
34
|
+
result: NotRequired[str | None]
|
|
35
|
+
approved_by: NotRequired[str | None]
|
|
36
|
+
reason: NotRequired[str | None]
|
|
37
|
+
risk_label: NotRequired[str | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConnectionResult(TypedDict):
|
|
41
|
+
"""JSON-compatible saved connection details."""
|
|
42
|
+
|
|
43
|
+
alias: str
|
|
44
|
+
hostname: str
|
|
45
|
+
server_type: str
|
|
46
|
+
detection_ssh: bool
|
|
47
|
+
detection_winrm: bool
|
|
48
|
+
auto_allowed: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ModeResult(TypedDict):
|
|
52
|
+
"""JSON-compatible global mode and per-server auto-execution opt-ins."""
|
|
53
|
+
|
|
54
|
+
mode: str
|
|
55
|
+
auto_allowed_servers: list[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommandStatusResult(TypedDict):
|
|
59
|
+
"""JSON-compatible status and audit details for one command."""
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
position: int
|
|
63
|
+
server_alias: str
|
|
64
|
+
server_type: str
|
|
65
|
+
command: str
|
|
66
|
+
status: str
|
|
67
|
+
result: str | None
|
|
68
|
+
approved_by: str | None
|
|
69
|
+
created_at: str
|
|
70
|
+
resolved_at: str | None
|
|
71
|
+
reason: str | None
|
|
72
|
+
risk_label: str | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BatchStatusResult(TypedDict):
|
|
76
|
+
"""JSON-compatible batch metadata and ordered command statuses."""
|
|
77
|
+
|
|
78
|
+
batch_id: str
|
|
79
|
+
title: str
|
|
80
|
+
description: str | None
|
|
81
|
+
created_at: str
|
|
82
|
+
resolved_at: str | None
|
|
83
|
+
requested_by_agent: str | None
|
|
84
|
+
commands: list[CommandStatusResult]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ToolError(Exception):
|
|
88
|
+
"""Domain error converted into MCP error content by the async boundary."""
|
|
89
|
+
|
|
90
|
+
code: str
|
|
91
|
+
message: str
|
|
92
|
+
|
|
93
|
+
def __init__(self, code: str, message: str) -> None:
|
|
94
|
+
"""Create an error with a stable machine code and human-readable message."""
|
|
95
|
+
super().__init__(message)
|
|
96
|
+
self.code = code
|
|
97
|
+
self.message = message
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _require_batch_title(batch_title: str | None) -> str:
|
|
101
|
+
if not batch_title or not batch_title.strip():
|
|
102
|
+
code = "missing_batch_title"
|
|
103
|
+
message = "batch_title is required"
|
|
104
|
+
raise ToolError(code, message)
|
|
105
|
+
return batch_title.strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_known_alias(connections_repo: ConnectionsRepo, alias: str) -> Connection:
|
|
109
|
+
connection = connections_repo.get(alias)
|
|
110
|
+
if connection is None:
|
|
111
|
+
code = "unknown_alias"
|
|
112
|
+
message = f"unknown alias '{alias}'"
|
|
113
|
+
raise ToolError(code, message)
|
|
114
|
+
return connection
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _auto_approve_by() -> str:
|
|
118
|
+
"""Audit identity for auto-approvals.
|
|
119
|
+
|
|
120
|
+
Inlined replication of watch/approval.py's ``_approve_by()`` fallback
|
|
121
|
+
pattern (KeyError/OSError -> constant), prefixed so auto-decisions are
|
|
122
|
+
distinguishable from human ones in the audit column.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
return f"auto:watch:{getpass.getuser()}"
|
|
126
|
+
except (KeyError, OSError):
|
|
127
|
+
return "auto:mcp"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _execute_auto(
|
|
131
|
+
*,
|
|
132
|
+
batches_repo: BatchesRepo,
|
|
133
|
+
commands_repo: CommandsRepo,
|
|
134
|
+
connection: Connection,
|
|
135
|
+
queued: Command,
|
|
136
|
+
decision: BehaviorDecision,
|
|
137
|
+
) -> ProposeCommandResult:
|
|
138
|
+
"""Mirror watch/approval.approve_one's transition cycle for an auto-approved command.
|
|
139
|
+
|
|
140
|
+
The connection is guaranteed present: ``_require_known_alias`` already
|
|
141
|
+
converted a missing alias into ToolError("unknown_alias") before insert,
|
|
142
|
+
so approve_one's ConnectionNotFoundError path is unreachable here.
|
|
143
|
+
"""
|
|
144
|
+
approver = _auto_approve_by()
|
|
145
|
+
approved = commands_repo.update_status(
|
|
146
|
+
queued.id,
|
|
147
|
+
status=CommandStatus.APPROVED,
|
|
148
|
+
approved_by=approver,
|
|
149
|
+
expected_status=CommandStatus.PENDING,
|
|
150
|
+
)
|
|
151
|
+
if not approved:
|
|
152
|
+
# Lost a race with another decision on this command since the insert --
|
|
153
|
+
# report its current state, don't execute (mirrors approve_one).
|
|
154
|
+
current = commands_repo.get(queued.id)
|
|
155
|
+
return {
|
|
156
|
+
"batch_id": queued.batch_id,
|
|
157
|
+
"command_id": queued.id,
|
|
158
|
+
"position": queued.position,
|
|
159
|
+
"status": current.status.value if current is not None else queued.status.value,
|
|
160
|
+
"mode": decision["mode"],
|
|
161
|
+
"server_auto_allowed": decision["server_auto_allowed"],
|
|
162
|
+
"effective_reason": decision["reason"],
|
|
163
|
+
"result": current.result if current is not None else None,
|
|
164
|
+
"approved_by": current.approved_by if current is not None else None,
|
|
165
|
+
"reason": queued.reason,
|
|
166
|
+
"risk_label": queued.risk_label,
|
|
167
|
+
}
|
|
168
|
+
execution = selector.execute_command(connection, queued.command)
|
|
169
|
+
status = CommandStatus.EXECUTED if execution.ok else CommandStatus.FAILED
|
|
170
|
+
output = execution.stdout
|
|
171
|
+
if execution.stderr:
|
|
172
|
+
separator = "\n" if output else ""
|
|
173
|
+
output = f"{output}{separator}--- stderr ---\n{execution.stderr}"
|
|
174
|
+
_ = commands_repo.update_status(
|
|
175
|
+
queued.id,
|
|
176
|
+
status=status,
|
|
177
|
+
approved_by=approver,
|
|
178
|
+
result=output,
|
|
179
|
+
)
|
|
180
|
+
# Auto-execution bypasses watch/approval.py entirely, so nothing else
|
|
181
|
+
# ever stamps `resolved_at` for this batch -- without this, a fully
|
|
182
|
+
# terminal batch sits at the head of the FIFO queue forever, blocking
|
|
183
|
+
# every batch behind it from ever becoming approvable.
|
|
184
|
+
if all_terminal(commands_repo.list_for_batch(queued.batch_id)):
|
|
185
|
+
batches_repo.mark_resolved(queued.batch_id)
|
|
186
|
+
return {
|
|
187
|
+
"batch_id": queued.batch_id,
|
|
188
|
+
"command_id": queued.id,
|
|
189
|
+
"position": queued.position,
|
|
190
|
+
"status": status.value,
|
|
191
|
+
"mode": decision["mode"],
|
|
192
|
+
"server_auto_allowed": decision["server_auto_allowed"],
|
|
193
|
+
"effective_reason": decision["reason"],
|
|
194
|
+
"result": output,
|
|
195
|
+
"approved_by": approver,
|
|
196
|
+
"reason": queued.reason,
|
|
197
|
+
"risk_label": queued.risk_label,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def propose_command( # noqa: PLR0913 - boundary mirrors the specified MCP tool arguments.
|
|
202
|
+
*,
|
|
203
|
+
batches_repo: BatchesRepo,
|
|
204
|
+
commands_repo: CommandsRepo,
|
|
205
|
+
connections_repo: ConnectionsRepo,
|
|
206
|
+
mode_repo: AppModeRepo,
|
|
207
|
+
settings_repo: ServerSettingsRepo,
|
|
208
|
+
server_alias: str,
|
|
209
|
+
command: str,
|
|
210
|
+
batch_title: str | None,
|
|
211
|
+
batch_description: str | None = None,
|
|
212
|
+
batch_id: str | None = None,
|
|
213
|
+
reason: str | None = None,
|
|
214
|
+
requested_by_agent: str = "mcp",
|
|
215
|
+
) -> ProposeCommandResult:
|
|
216
|
+
"""Register a proposed command in the queue.
|
|
217
|
+
|
|
218
|
+
Executes it immediately instead when the global mode is AUTO and the
|
|
219
|
+
server alias has explicitly opted in.
|
|
220
|
+
"""
|
|
221
|
+
title = _require_batch_title(batch_title)
|
|
222
|
+
connection = _require_known_alias(connections_repo, server_alias)
|
|
223
|
+
|
|
224
|
+
if batch_id is None:
|
|
225
|
+
batch = batches_repo.create(
|
|
226
|
+
title=title,
|
|
227
|
+
description=batch_description,
|
|
228
|
+
requested_by_agent=requested_by_agent,
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
batch = batches_repo.get(BatchId(batch_id))
|
|
232
|
+
if batch is None:
|
|
233
|
+
# An unknown optional ID is a stale hint, so create a fresh UUID-backed batch.
|
|
234
|
+
batch = batches_repo.create(
|
|
235
|
+
title=title,
|
|
236
|
+
description=batch_description,
|
|
237
|
+
requested_by_agent=requested_by_agent,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
risk_label = find_risk(command, connection.server_type)
|
|
241
|
+
queued = commands_repo.add(
|
|
242
|
+
batch_id=batch.id,
|
|
243
|
+
server_alias=connection.alias,
|
|
244
|
+
server_type=connection.server_type,
|
|
245
|
+
command=command,
|
|
246
|
+
reason=reason,
|
|
247
|
+
risk_label=risk_label,
|
|
248
|
+
)
|
|
249
|
+
decision = resolve_auto_behavior(
|
|
250
|
+
mode_repo=mode_repo,
|
|
251
|
+
settings_repo=settings_repo,
|
|
252
|
+
server_alias=connection.alias,
|
|
253
|
+
risk_label=risk_label,
|
|
254
|
+
)
|
|
255
|
+
if decision["action"] == "execute":
|
|
256
|
+
return _execute_auto(
|
|
257
|
+
batches_repo=batches_repo,
|
|
258
|
+
commands_repo=commands_repo,
|
|
259
|
+
connection=connection,
|
|
260
|
+
queued=queued,
|
|
261
|
+
decision=decision,
|
|
262
|
+
)
|
|
263
|
+
return {
|
|
264
|
+
"batch_id": batch.id,
|
|
265
|
+
"command_id": queued.id,
|
|
266
|
+
"position": queued.position,
|
|
267
|
+
"status": queued.status.value,
|
|
268
|
+
"mode": decision["mode"],
|
|
269
|
+
"server_auto_allowed": decision["server_auto_allowed"],
|
|
270
|
+
"effective_reason": decision["reason"],
|
|
271
|
+
"result": None,
|
|
272
|
+
"approved_by": None,
|
|
273
|
+
"reason": queued.reason,
|
|
274
|
+
"risk_label": queued.risk_label,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def list_connections(
|
|
279
|
+
*, connections_repo: ConnectionsRepo, settings_repo: ServerSettingsRepo
|
|
280
|
+
) -> list[ConnectionResult]:
|
|
281
|
+
"""Return saved connections, their server dialect metadata, and auto-execution opt-in."""
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
"alias": connection.alias,
|
|
285
|
+
"hostname": connection.hostname,
|
|
286
|
+
"server_type": connection.server_type.value,
|
|
287
|
+
"detection_ssh": connection.detection_ssh,
|
|
288
|
+
"detection_winrm": connection.detection_winrm,
|
|
289
|
+
"auto_allowed": settings_repo.get_or_default(connection.alias).auto_allowed,
|
|
290
|
+
}
|
|
291
|
+
for connection in connections_repo.list_all()
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_mode(*, mode_repo: AppModeRepo, settings_repo: ServerSettingsRepo) -> ModeResult:
|
|
296
|
+
"""Report the global mode and which servers are opted in for auto-execution.
|
|
297
|
+
|
|
298
|
+
Read-only: an unset global mode is reported as PROPOSE, matching
|
|
299
|
+
``resolve_auto_behavior``'s safe default.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
mode = mode_repo.get().mode
|
|
303
|
+
except AppModeNotSetError:
|
|
304
|
+
mode = Mode.PROPOSE
|
|
305
|
+
return {
|
|
306
|
+
"mode": mode.value,
|
|
307
|
+
"auto_allowed_servers": [
|
|
308
|
+
setting.server_alias for setting in settings_repo.list_all() if setting.auto_allowed
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_status(
|
|
314
|
+
*,
|
|
315
|
+
batches_repo: BatchesRepo,
|
|
316
|
+
commands_repo: CommandsRepo,
|
|
317
|
+
batch_id: str,
|
|
318
|
+
) -> BatchStatusResult:
|
|
319
|
+
"""Return batch metadata and ordered per-command status and audit fields."""
|
|
320
|
+
typed_batch_id = BatchId(batch_id)
|
|
321
|
+
batch = batches_repo.get(typed_batch_id)
|
|
322
|
+
if batch is None:
|
|
323
|
+
code = "unknown_batch"
|
|
324
|
+
message = f"unknown batch '{batch_id}'"
|
|
325
|
+
raise ToolError(code, message)
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"batch_id": batch.id,
|
|
329
|
+
"title": batch.title,
|
|
330
|
+
"description": batch.description,
|
|
331
|
+
"created_at": batch.created_at.isoformat(),
|
|
332
|
+
"resolved_at": batch.resolved_at.isoformat() if batch.resolved_at else None,
|
|
333
|
+
"requested_by_agent": batch.requested_by_agent,
|
|
334
|
+
"commands": [
|
|
335
|
+
{
|
|
336
|
+
"id": command.id,
|
|
337
|
+
"position": command.position,
|
|
338
|
+
"server_alias": command.server_alias,
|
|
339
|
+
"server_type": command.server_type.value,
|
|
340
|
+
"command": command.command,
|
|
341
|
+
"status": command.status.value,
|
|
342
|
+
"result": command.result,
|
|
343
|
+
"approved_by": command.approved_by,
|
|
344
|
+
"created_at": command.created_at.isoformat(),
|
|
345
|
+
"resolved_at": command.resolved_at.isoformat() if command.resolved_at else None,
|
|
346
|
+
"reason": command.reason,
|
|
347
|
+
"risk_label": command.risk_label,
|
|
348
|
+
}
|
|
349
|
+
for command in commands_repo.list_for_batch(typed_batch_id)
|
|
350
|
+
],
|
|
351
|
+
}
|
cgate/risk.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Heuristic detection of high-blast-radius commands.
|
|
2
|
+
|
|
3
|
+
Not a security boundary: a regex-based safety net, easy to defeat with
|
|
4
|
+
obfuscation (variables, aliases, unusual quoting/flag ordering, encoding).
|
|
5
|
+
Its job is narrower -- catch the common, obvious forms of "this could wipe
|
|
6
|
+
a disk or kill recovery" so AUTO mode's opt-in never silently runs one
|
|
7
|
+
unattended, and so a human scanning the queue sees a clear warning even in
|
|
8
|
+
PROPOSE mode. A command that matches nothing here is not "safe"; it just
|
|
9
|
+
didn't trip this particular tripwire.
|
|
10
|
+
|
|
11
|
+
Patterns cover Windows/PowerShell and Linux -- the latter also catches an
|
|
12
|
+
SSH-connected macOS box, since `cgate` has no separate macOS `ServerType`
|
|
13
|
+
and macOS shares the same Unix shell family (plus a few macOS-specific
|
|
14
|
+
tools: `diskutil`, `csrutil`, `spctl`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Final
|
|
21
|
+
|
|
22
|
+
from cgate.db.types import ServerType
|
|
23
|
+
|
|
24
|
+
# Sourced from widely-cited "dangerous commands" references (rm -rf, dd,
|
|
25
|
+
# mkfs, fork bombs) and MITRE ATT&CK T1490 "Inhibit System Recovery"
|
|
26
|
+
# (vssadmin/wbadmin/bcdedit/REAgentC -- the standard ransomware precursor
|
|
27
|
+
# set that deletes shadow copies and disables recovery before encrypting).
|
|
28
|
+
_LINUX_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = (
|
|
29
|
+
("fork bomb", re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:")),
|
|
30
|
+
("delete with --no-preserve-root", re.compile(r"--no-preserve-root", re.IGNORECASE)),
|
|
31
|
+
(
|
|
32
|
+
"writes directly to a raw disk device",
|
|
33
|
+
re.compile(
|
|
34
|
+
r"(\bdd\b[^\n;|&]*\bof=|[>]{1,2}\s*)/dev/(sd|nvme|hd|xvd|disk|rdisk)\w*", re.IGNORECASE
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
("formats a block device", re.compile(r"\bmkfs(\.\w+)?\s+[^\n;|&]*/dev/", re.IGNORECASE)),
|
|
38
|
+
("wipes/erases a device", re.compile(r"\b(wipefs|shred)\b[^\n;|&]*/dev/", re.IGNORECASE)),
|
|
39
|
+
(
|
|
40
|
+
"recursive chmod on the filesystem root",
|
|
41
|
+
re.compile(
|
|
42
|
+
r"\bchmod\b[^\n;|&]*-[a-zA-Z]*[Rr][a-zA-Z]*\b[^\n;|&]*\s/(\s|\*|$)", re.IGNORECASE
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"disables macOS System Integrity Protection",
|
|
47
|
+
re.compile(r"\bcsrutil\s+disable\b", re.IGNORECASE),
|
|
48
|
+
),
|
|
49
|
+
("disables macOS Gatekeeper", re.compile(r"\bspctl\b[^\n;|&]*--master-disable", re.IGNORECASE)),
|
|
50
|
+
(
|
|
51
|
+
"erases a macOS disk/volume",
|
|
52
|
+
re.compile(r"\bdiskutil\s+erase(disk|volume|all)?\b", re.IGNORECASE),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_WINDOWS_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = (
|
|
57
|
+
(
|
|
58
|
+
"deletes shadow copies (blocks recovery)",
|
|
59
|
+
re.compile(r"\bvssadmin\b[^\n;|&]*\bdelete\b[^\n;|&]*\bshadows\b", re.IGNORECASE),
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"deletes Windows backups (blocks recovery)",
|
|
63
|
+
re.compile(r"\bwbadmin\b[^\n;|&]*\bdelete\b", re.IGNORECASE),
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"disables startup recovery",
|
|
67
|
+
re.compile(r"\bbcdedit\b[^\n;|&]*(recoveryenabled\s+no|ignoreallfailures)", re.IGNORECASE),
|
|
68
|
+
),
|
|
69
|
+
("disables System Restore", re.compile(r"\bDisable-ComputerRestore\b", re.IGNORECASE)),
|
|
70
|
+
(
|
|
71
|
+
"disables Windows Recovery Environment",
|
|
72
|
+
re.compile(r"\breagentc\b[^\n;|&]*/disable", re.IGNORECASE),
|
|
73
|
+
),
|
|
74
|
+
("deletes volume shadow copies", re.compile(r"\bdiskshadow\b", re.IGNORECASE)),
|
|
75
|
+
(
|
|
76
|
+
"wipes/formats a disk",
|
|
77
|
+
re.compile(r"\b(Format-Volume|Clear-Disk|Remove-Partition)\b", re.IGNORECASE),
|
|
78
|
+
),
|
|
79
|
+
("uses the low-level disk partitioning tool", re.compile(r"\bdiskpart\b", re.IGNORECASE)),
|
|
80
|
+
(
|
|
81
|
+
"disables real-time antivirus protection",
|
|
82
|
+
re.compile(r"\bSet-MpPreference\b[^\n;|&]*DisableRealtimeMonitoring", re.IGNORECASE),
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"turns off the Windows Firewall",
|
|
86
|
+
re.compile(r"\bnetsh\s+advfirewall\s+set\s+allprofiles\s+state\s+off\b", re.IGNORECASE),
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"recursive force-delete (Remove-Item -Recurse -Force)",
|
|
90
|
+
re.compile(r"\bRemove-Item\b(?=[^\n;|&]*-Recurse\b)(?=[^\n;|&]*-Force\b)", re.IGNORECASE),
|
|
91
|
+
),
|
|
92
|
+
(
|
|
93
|
+
"recursive force-delete of a drive root or folder",
|
|
94
|
+
re.compile(
|
|
95
|
+
r"\b(rd|rmdir)\b[^\n;|&]*/s[^\n;|&]*/q|\bdel\b[^\n;|&]*/f[^\n;|&]*/s[^\n;|&]*/q",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_rm_rf(command: str) -> bool:
|
|
103
|
+
"""`rm` with both recursive and force flags, in any order or spelling."""
|
|
104
|
+
if not re.search(r"\brm\b", command, re.IGNORECASE):
|
|
105
|
+
return False
|
|
106
|
+
combined_short_flags = re.search(
|
|
107
|
+
r"-[a-zA-Z]*r[a-zA-Z]*f\b|-[a-zA-Z]*f[a-zA-Z]*r\b", command, re.IGNORECASE
|
|
108
|
+
)
|
|
109
|
+
long_flags = re.search(r"--recursive\b", command, re.IGNORECASE) and re.search(
|
|
110
|
+
r"--force\b", command, re.IGNORECASE
|
|
111
|
+
)
|
|
112
|
+
return bool(combined_short_flags) or bool(long_flags)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_risk(command: str, server_type: ServerType) -> str | None:
|
|
116
|
+
"""Return a short human-readable label for a matched high-blast-radius pattern.
|
|
117
|
+
|
|
118
|
+
None otherwise -- which is not the same as "safe" (see the module docstring).
|
|
119
|
+
"""
|
|
120
|
+
patterns = _WINDOWS_PATTERNS if server_type is ServerType.WINDOWS else _LINUX_PATTERNS
|
|
121
|
+
for label, pattern in patterns:
|
|
122
|
+
if pattern.search(command):
|
|
123
|
+
return label
|
|
124
|
+
if server_type is ServerType.LINUX and _is_rm_rf(command):
|
|
125
|
+
return "recursive force delete (rm -rf)"
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["find_risk"]
|