dsh-ssh-workbench 0.0.0-stage → 1.7.0
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.
- package/CHANGELOG.md +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target dialect helpers.
|
|
3
|
+
*
|
|
4
|
+
* A server record keeps a hand-maintained `platform` hint (`posix`/`windows`)
|
|
5
|
+
* for the UI and for pre-probe compatibility, but the only trustworthy source
|
|
6
|
+
* is a live probe. `facts.dialect` stores that probe result; every decision
|
|
7
|
+
* that changes the command syntax (which remote shell tool to use, how to
|
|
8
|
+
* wrap a timeout) reads it through `effectiveDialect()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The dialect values this plugin understands. */
|
|
12
|
+
const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
|
|
13
|
+
|
|
14
|
+
/** Normalize one raw dialect/platform value. */
|
|
15
|
+
export function normalizeDialect(value) {
|
|
16
|
+
return value === DIALECTS.WINDOWS || value === 'win32' ? DIALECTS.WINDOWS : DIALECTS.POSIX
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The dialect to build commands for.
|
|
21
|
+
* A probed value wins; the record's `platform` is only a fallback hint.
|
|
22
|
+
*/
|
|
23
|
+
export function effectiveDialect(server) {
|
|
24
|
+
const probed = server?.facts?.dialect
|
|
25
|
+
if (probed === DIALECTS.WINDOWS || probed === DIALECTS.POSIX) return probed
|
|
26
|
+
return normalizeDialect(server?.platform)
|
|
27
|
+
}
|
|
28
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSH error taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so the transport broker and the tool-facing runtime
|
|
5
|
+
* can share the same `SshError` class without an import cycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class SshError extends Error {
|
|
9
|
+
constructor(code, message, extra = {}) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'SshError'
|
|
12
|
+
this.code = code
|
|
13
|
+
Object.assign(this, extra)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeError(error, fingerprint) {
|
|
18
|
+
// Keep structured errors intact: a missing private key is an auth problem,
|
|
19
|
+
// not a generic execution failure.
|
|
20
|
+
if (error instanceof SshError) return error
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
22
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''
|
|
23
|
+
if (/host key verification failed|host denied \(verification failed\)|hostVerifier/i.test(message)) {
|
|
24
|
+
return new SshError('HOST_KEY_UNKNOWN', message, { fingerprint })
|
|
25
|
+
}
|
|
26
|
+
if (/all configured authentication methods failed|authentication failed|permission denied/i.test(message)) {
|
|
27
|
+
return new SshError('AUTH_FAILED', message)
|
|
28
|
+
}
|
|
29
|
+
if (/timed out while waiting|connect timeout|timeout/i.test(message) && !/command/i.test(message)) {
|
|
30
|
+
return new SshError('CONNECT_TIMEOUT', message)
|
|
31
|
+
}
|
|
32
|
+
if (/econnrefused|ehostunreach|enotfound|getaddrinfo|network unreachable|econnreset/i.test(message) || /^(ECONN|ENOTFOUND|EHOST)/.test(code)) {
|
|
33
|
+
return new SshError('NETWORK', message)
|
|
34
|
+
}
|
|
35
|
+
return new SshError('EXEC_FAILED', message)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function suggestedActionFor(code) {
|
|
39
|
+
switch (code) {
|
|
40
|
+
case 'AUTH_MISSING':
|
|
41
|
+
return 'Set the SSH password (or switch to key auth) in Settings → SSH Workbench → managed resources.'
|
|
42
|
+
case 'AUTH_FAILED':
|
|
43
|
+
return 'Check the username, private key path, or password for this resource.'
|
|
44
|
+
case 'HOST_KEY_UNKNOWN':
|
|
45
|
+
return 'Compare the shown fingerprint with the server operator, then trust it in Settings → SSH Workbench.'
|
|
46
|
+
case 'CONNECT_TIMEOUT':
|
|
47
|
+
return 'Check the host/port and network path; raise connectTimeoutMs if the host is slow.'
|
|
48
|
+
case 'NETWORK':
|
|
49
|
+
return 'The host is unreachable: check DNS, routing, VPN, or the SSH port.'
|
|
50
|
+
case 'TIMEOUT':
|
|
51
|
+
return 'The command exceeded its timeout. Narrow the command or raise timeoutMs.'
|
|
52
|
+
case 'CANCELLED':
|
|
53
|
+
return 'The operation was cancelled.'
|
|
54
|
+
case 'COMMAND_NOT_FOUND':
|
|
55
|
+
return 'The remote shell reported a missing command; check the remote PATH.'
|
|
56
|
+
case 'SFTP_FAILED':
|
|
57
|
+
return 'The SFTP operation failed; check the remote path, permissions, and the SSH server SFTP subsystem.'
|
|
58
|
+
case 'COMMAND_NOT_EXECUTABLE':
|
|
59
|
+
return 'The file exists but is not executable (exit 126); check the shebang and chmod +x.'
|
|
60
|
+
case 'PERMISSION_DENIED':
|
|
61
|
+
return 'Permission denied: check the remote user, file ownership/mode, or use a privileged account.'
|
|
62
|
+
case 'DISK_FULL':
|
|
63
|
+
return 'The remote filesystem is full or over quota; free space (df -h) and retry.'
|
|
64
|
+
case 'READ_ONLY_FS':
|
|
65
|
+
return 'The remote filesystem is read-only; remount it read-write or pick another path.'
|
|
66
|
+
case 'INTERRUPTED':
|
|
67
|
+
return 'The command was interrupted (exit 130 / SIGINT).'
|
|
68
|
+
case 'KILLED':
|
|
69
|
+
return 'The command was killed (exit 137 / SIGKILL); it may have hit the OOM killer or a manual kill.'
|
|
70
|
+
default:
|
|
71
|
+
return 'Inspect stderr for details, or run a smaller diagnostic command first.'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify a failed exec result into the most specific error code we can infer
|
|
77
|
+
* from the exit status and stderr. Used for both `errorCode` and the
|
|
78
|
+
* human-readable suggestion.
|
|
79
|
+
*/
|
|
80
|
+
export function classifyExecFailure({ exitCode, stderr, signal } = {}) {
|
|
81
|
+
const text = String(stderr ?? '')
|
|
82
|
+
if (/command not found/i.test(text)) return 'COMMAND_NOT_FOUND'
|
|
83
|
+
if (/permission denied/i.test(text)) return 'PERMISSION_DENIED'
|
|
84
|
+
if (/no space left on device|disk quota exceeded/i.test(text)) return 'DISK_FULL'
|
|
85
|
+
if (/read-only file system/i.test(text)) return 'READ_ONLY_FS'
|
|
86
|
+
if (signal) return 'KILLED'
|
|
87
|
+
switch (exitCode) {
|
|
88
|
+
case 126: return 'COMMAND_NOT_EXECUTABLE'
|
|
89
|
+
case 127: return 'COMMAND_NOT_FOUND'
|
|
90
|
+
case 130: return 'INTERRUPTED'
|
|
91
|
+
case 137: return 'KILLED'
|
|
92
|
+
default: return 'EXEC_FAILED'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function errorResult(code, message, extra = {}) {
|
|
97
|
+
return {
|
|
98
|
+
outcome: code === 'CANCELLED' ? 'cancelled' : code === 'TIMEOUT' ? 'timeout' : 'failed',
|
|
99
|
+
exitCode: null,
|
|
100
|
+
signal: null,
|
|
101
|
+
stdout: '',
|
|
102
|
+
stderr: message,
|
|
103
|
+
durationMs: 0,
|
|
104
|
+
truncated: { stdout: false, stderr: false },
|
|
105
|
+
errorCode: code,
|
|
106
|
+
suggestedAction: suggestedActionFor(code),
|
|
107
|
+
...extra,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `grep`/`diff`/`pgrep` exit 1 for "no match" / "files differ": that is a
|
|
113
|
+
* normal answer, not an execution failure. Reinterpret it only when the whole
|
|
114
|
+
* command was classified read-only and the remote wrote nothing to stderr —
|
|
115
|
+
* a read-only command that exits 1 with a silent stderr is almost certainly
|
|
116
|
+
* reporting content, not a broken execution.
|
|
117
|
+
*/
|
|
118
|
+
export function isBenignReadOnlyExit({ readOnly, exitCode, stderr, signal } = {}) {
|
|
119
|
+
return readOnly === true && exitCode === 1 && !signal && !String(stderr ?? '').trim()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Normalize any SFTP-layer error into a structured `SFTP_FAILED` SshError. */
|
|
123
|
+
export function toSftpError(error) {
|
|
124
|
+
if (error instanceof SshError) {
|
|
125
|
+
return error.code === 'EXEC_FAILED' ? new SshError('SFTP_FAILED', error.message) : error
|
|
126
|
+
}
|
|
127
|
+
return new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Connection-level failure detection (used to evict a dead pooled connection). */
|
|
131
|
+
export function isConnectionLevelError(error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error ?? '')
|
|
133
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''
|
|
134
|
+
if (['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH'].includes(code)) return true
|
|
135
|
+
return /not\s+connected|connection\s+(lost|reset|closed|force\s+closed)|socket\s+closed|force\s+closed|keepalive\s+timeout|read\s+ec\w+|broken\s+pipe|econn\w+|epipe|etimedout/i.test(
|
|
136
|
+
message,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session execution summary for the composer target dock.
|
|
3
|
+
*
|
|
4
|
+
* DSH owns conversation/model metrics (turns, TTFT, TPS); the plugin cannot
|
|
5
|
+
* read them. This module only summarizes the activity entries the plugin
|
|
6
|
+
* writes for remote executions, so the dock can show accurate SSH-side counts
|
|
7
|
+
* and latency without inventing values it does not own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function numericDuration(entry) {
|
|
11
|
+
return Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? entry.durationMs : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function emptyBucket(name) {
|
|
15
|
+
return { name, count: 0, success: 0, failed: 0, durationMs: 0, averageMs: 0, lastAt: null }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function addToBucket(bucket, entry) {
|
|
19
|
+
bucket.count += 1
|
|
20
|
+
if (entry.outcome === 'success') bucket.success += 1
|
|
21
|
+
else bucket.failed += 1
|
|
22
|
+
bucket.durationMs += numericDuration(entry)
|
|
23
|
+
if (typeof entry.at === 'string' && (bucket.lastAt === null || entry.at > bucket.lastAt)) bucket.lastAt = entry.at
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Summarize the plugin's remote execution activity for one Session.
|
|
28
|
+
*
|
|
29
|
+
* @param {object[]} entries newest-first activity entries
|
|
30
|
+
* @param {{ sessionId?: string, candidates?: string[] }} options
|
|
31
|
+
* @returns {{
|
|
32
|
+
* sessionId: string|null,
|
|
33
|
+
* count: number,
|
|
34
|
+
* success: number,
|
|
35
|
+
* failed: number,
|
|
36
|
+
* durationMs: number,
|
|
37
|
+
* averageMs: number,
|
|
38
|
+
* lastAt: string|null,
|
|
39
|
+
* servers: Array<{name:string,count:number,success:number,failed:number,durationMs:number,averageMs:number,lastAt:string|null}>
|
|
40
|
+
* }}
|
|
41
|
+
*/
|
|
42
|
+
export function summarizeExecution(entries, { sessionId, candidates = [] } = {}) {
|
|
43
|
+
const wanted = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
|
|
44
|
+
const byServer = new Map()
|
|
45
|
+
const summary = {
|
|
46
|
+
sessionId: wanted,
|
|
47
|
+
count: 0,
|
|
48
|
+
success: 0,
|
|
49
|
+
failed: 0,
|
|
50
|
+
durationMs: 0,
|
|
51
|
+
averageMs: 0,
|
|
52
|
+
lastAt: null,
|
|
53
|
+
servers: [],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (wanted !== null) {
|
|
57
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
58
|
+
if (!entry || entry.kind !== 'exec' || entry.sessionId !== wanted) continue
|
|
59
|
+
summary.count += 1
|
|
60
|
+
if (entry.outcome === 'success') summary.success += 1
|
|
61
|
+
else summary.failed += 1
|
|
62
|
+
summary.durationMs += numericDuration(entry)
|
|
63
|
+
if (typeof entry.at === 'string' && (summary.lastAt === null || entry.at > summary.lastAt)) summary.lastAt = entry.at
|
|
64
|
+
const name = typeof entry.server === 'string' && entry.server.length > 0 ? entry.server : null
|
|
65
|
+
if (name === null) continue
|
|
66
|
+
if (!byServer.has(name)) byServer.set(name, emptyBucket(name))
|
|
67
|
+
addToBucket(byServer.get(name), entry)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
summary.averageMs = summary.count > 0 ? Math.round(summary.durationMs / summary.count) : 0
|
|
72
|
+
|
|
73
|
+
// Keep the mentioned resources in mention order, then append any target that
|
|
74
|
+
// ran from an explicit remote_exec task without being a session candidate.
|
|
75
|
+
const names = []
|
|
76
|
+
for (const name of Array.isArray(candidates) ? candidates : []) {
|
|
77
|
+
const value = String(name)
|
|
78
|
+
if (value && !names.includes(value)) names.push(value)
|
|
79
|
+
}
|
|
80
|
+
for (const name of [...byServer.keys()].sort()) {
|
|
81
|
+
if (!names.includes(name)) names.push(name)
|
|
82
|
+
}
|
|
83
|
+
summary.servers = names.map((name) => {
|
|
84
|
+
const bucket = byServer.get(name) ?? emptyBucket(name)
|
|
85
|
+
return { ...bucket, averageMs: bucket.count > 0 ? Math.round(bucket.durationMs / bucket.count) : 0 }
|
|
86
|
+
})
|
|
87
|
+
return summary
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerShell blacklist for the Windows remote shell.
|
|
3
|
+
*
|
|
4
|
+
* Same philosophy as the POSIX gate: this is a deterministic accident stop,
|
|
5
|
+
* not a permission boundary. It only catches machine-destroying commands a
|
|
6
|
+
* model might emit by mistake (disk/volume formatting, partition edits, boot
|
|
7
|
+
* configuration edits, whole-drive recursive deletes). Everything else is left
|
|
8
|
+
* to the DSH session approval preset.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const RULES = [
|
|
12
|
+
{
|
|
13
|
+
ruleId: 'format-volume',
|
|
14
|
+
reason: 'formatting a volume (Format-Volume)',
|
|
15
|
+
pattern: /\bformat-volume\b/,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
ruleId: 'disk-management',
|
|
19
|
+
reason: 'disk/partition management (Clear-Disk, Initialize-Disk, Remove-Partition)',
|
|
20
|
+
pattern: /\b(?:clear-disk|initialize-disk|remove-partition|resize-partition)\b/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
ruleId: 'diskpart',
|
|
24
|
+
reason: 'interactive disk partitioning (diskpart)',
|
|
25
|
+
pattern: /\bdiskpart(?:\.exe)?\b/,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
ruleId: 'format-command',
|
|
29
|
+
reason: 'formatting a drive (format)',
|
|
30
|
+
pattern: /\bformat(?:\.com)?\s+[a-z]:/,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
ruleId: 'boot-config',
|
|
34
|
+
reason: 'boot configuration edit (bcdedit)',
|
|
35
|
+
pattern: /\bbcdedit(?:\.exe)?\b/,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
ruleId: 'wipe-free-space',
|
|
39
|
+
reason: 'wiping free space (cipher /w)',
|
|
40
|
+
pattern: /\bcipher(?:\.exe)?\s+\/w\b/,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
ruleId: 'shadow-delete',
|
|
44
|
+
reason: 'deleting volume shadow copies (vssadmin/wbadmin)',
|
|
45
|
+
pattern: /\b(?:vssadmin|wbadmin)(?:\.exe)?\s+delete\b/,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
ruleId: 'rm-root',
|
|
49
|
+
reason: 'recursive delete of a drive root or system directory',
|
|
50
|
+
pattern:
|
|
51
|
+
/\b(?:remove-item|rm|rd|rmdir|del)\b(?=[^|;]*(?:-recurse|-r\b|\/s)\b)[^|;]*?(?:[a-z]:[\\/]{1,2}(?:windows|users|programdata|program files)\b|[a-z]:[\\/]{0,2}(?:['"]|\s|$)|\$(?:env:)?systemdrive\b|%systemdrive%)/,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
ruleId: 'remove-root-recursive',
|
|
55
|
+
reason: 'recursive delete of a whole drive',
|
|
56
|
+
pattern: /\b(?:remove-item|rm|rd|rmdir|del)\b[^|;]*?['"]?[a-z]:[\\/]?['"]?\s+(?:-recurse|-force|\/s|\/q)/,
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** Split one PowerShell command into independently-executed statements. */
|
|
61
|
+
function powerShellStatements(command) {
|
|
62
|
+
return String(command ?? '')
|
|
63
|
+
.split(/[;\r\n]|\|\||&&|\|/)
|
|
64
|
+
.map((part) => part.trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lowercase + collapse whitespace, without touching quoted content semantics. */
|
|
69
|
+
function normalizeStatement(statement) {
|
|
70
|
+
return statement.toLowerCase().replace(/\s+/g, ' ').trim()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} command
|
|
75
|
+
* @returns {{decision: 'deny'|'allow', reason: string, ruleId: string}}
|
|
76
|
+
*/
|
|
77
|
+
export function classifyPowerShellCommand(command) {
|
|
78
|
+
if (!String(command ?? '').trim()) return { decision: 'allow', reason: 'empty command', ruleId: 'empty' }
|
|
79
|
+
for (const statement of powerShellStatements(command)) {
|
|
80
|
+
const text = normalizeStatement(statement)
|
|
81
|
+
for (const rule of RULES) {
|
|
82
|
+
if (rule.pattern.test(text)) {
|
|
83
|
+
return { decision: 'deny', reason: rule.reason, ruleId: rule.ruleId }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { decision: 'allow', reason: 'not on the PowerShell machine-destruction blacklist', ruleId: 'allow' }
|
|
88
|
+
}
|