@cfitsec/dsh-ast-guard 0.0.0-stage → 2.0.1
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/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +25 -0
- package/docs//347/273/264/346/212/244/350/247/204/345/210/231.md +217 -0
- package/lib/ast-daemon.ps1 +138 -0
- package/lib/daemon.js +185 -0
- package/lib/deliberate.js +275 -0
- package/lib/evidence.js +121 -0
- package/lib/index.js +322 -0
- package/lib/rules.js +480 -0
- package/package.json +60 -4
- package/rules/default.yaml +210 -0
- package/test/hook.mjs +96 -0
- package/test/import-check.mjs +31 -0
- package/test/smoke.mjs +109 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# dsh-ast-guard · 默认规则
|
|
2
|
+
#
|
|
3
|
+
# 重点在前两组:
|
|
4
|
+
# ① 写文件内容(Set-Content / Add-Content / Clear-Content / Out-File / Tee-Object / Export-* / .NET 直写 / 重定向)
|
|
5
|
+
# ② 条目操作(Remove / Move / Rename / Copy-Item…)
|
|
6
|
+
# 它们共同的特点是:**不可逆,而且 shell 里做完全不留痕** —— 没有 diff、没有快照、进不了回退。
|
|
7
|
+
# 其余(内联代码、注册表、git 范围收缩)都是同一类"代价高、事后查不到"的动作。
|
|
8
|
+
#
|
|
9
|
+
# 判断方式:**按效应,不按命令名**。每条规则的 `why` 会逐字出现在拒绝文案里。
|
|
10
|
+
# 想改成自己的一套:复制本文件,然后在插件配置里用 `rulesFile` 指过去。
|
|
11
|
+
#
|
|
12
|
+
# ⚠️ 别名必须逐条写死:语法树**不解析别名**(`sc` 就只是 `sc`,认不出它是 Set-Content)。
|
|
13
|
+
# 本文件的别名取自本机 PowerShell 的 `Get-Alias` 实测,换机器请自己核一遍。
|
|
14
|
+
version: 1
|
|
15
|
+
|
|
16
|
+
deny:
|
|
17
|
+
# ── ① 写文件内容:命令词 + 别名 + .NET 直写 + 重定向 ───────────────────────
|
|
18
|
+
- id: content-write
|
|
19
|
+
why: |
|
|
20
|
+
PowerShell 写文件内容已禁用:会破坏编码(Get-Content 默认非 UTF-8,来回一趟毁中文),
|
|
21
|
+
而且 shell 写入没有 diff、没有快照、进不了回退。
|
|
22
|
+
正确姿势:定点替换用 edit;整份新写用 write;复制/搬运用 Copy-Item(纯字节搬运,不经过解码)。
|
|
23
|
+
commands:
|
|
24
|
+
- Set-Content # sc
|
|
25
|
+
- Add-Content # ac
|
|
26
|
+
- Clear-Content # clc
|
|
27
|
+
- Out-File # 无别名
|
|
28
|
+
- Tee-Object # tee
|
|
29
|
+
- Export-Csv # epcsv
|
|
30
|
+
- Export-Clixml # 无别名
|
|
31
|
+
- Start-Transcript # 无别名
|
|
32
|
+
- sc
|
|
33
|
+
- ac
|
|
34
|
+
- clc
|
|
35
|
+
- tee
|
|
36
|
+
- epcsv
|
|
37
|
+
# .NET 直写。名字本身不专指文件(RunspaceFactory::Create 也是 Create),
|
|
38
|
+
# 所以还要求调用目标里含 File 或 Stream 才算。
|
|
39
|
+
memberTargetContains: ['File', 'Stream']
|
|
40
|
+
memberNames:
|
|
41
|
+
- WriteAllText
|
|
42
|
+
- WriteAllBytes
|
|
43
|
+
- WriteAllLines
|
|
44
|
+
- AppendAllText
|
|
45
|
+
- AppendAllLines
|
|
46
|
+
- Create
|
|
47
|
+
- CreateText
|
|
48
|
+
- OpenWrite
|
|
49
|
+
- OpenText
|
|
50
|
+
- new
|
|
51
|
+
# 文件重定向 `>` / `>>`;目标是 $null 的自动豁免(那是抑制输出,不是写文件)。
|
|
52
|
+
redirects: true
|
|
53
|
+
|
|
54
|
+
# ── ② 内联代码:解释器 + 求值开关 ─────────────────────────────────────────
|
|
55
|
+
#
|
|
56
|
+
# ⚠️ 为什么不合并成一条:**同一个开关在不同解释器上含义不同**。
|
|
57
|
+
# `python -c` = 跑代码(该拦);`node -c` = 语法检查(正当,必须放行)。
|
|
58
|
+
- id: inline-code-node
|
|
59
|
+
why: |
|
|
60
|
+
内联代码执行已禁用(`node -e` / `deno eval` 这类"把代码塞进参数"的写法)。
|
|
61
|
+
代码不在文件系统里:事后没人看得到它改了什么,也没有 diff 可回退。
|
|
62
|
+
正确姿势:把脚本落到文件里再跑(可审、可回滚)。
|
|
63
|
+
commands: [node, node.exe, deno, deno.exe, bun, bun.exe]
|
|
64
|
+
when:
|
|
65
|
+
flags: ['-e', '-p', '--eval', '--print']
|
|
66
|
+
argsExact: ['eval']
|
|
67
|
+
|
|
68
|
+
- id: inline-code-interp
|
|
69
|
+
why: |
|
|
70
|
+
内联代码执行已禁用(`python -c` / `powershell -Command` / `-EncodedCommand` 这类写法)。
|
|
71
|
+
正确姿势:把脚本落到文件里再跑。
|
|
72
|
+
commands: [python, python3, python.exe, python3.exe, py, powershell, powershell.exe, pwsh, pwsh.exe]
|
|
73
|
+
when:
|
|
74
|
+
# `-c` 只在这一条里(对 python / powershell 是"跑代码";对 node 是语法检查)。
|
|
75
|
+
flags: ['-c', '-Co', '-Command', '-EncodedCommand', '-EncodedArguments', '-Enc']
|
|
76
|
+
|
|
77
|
+
# ── ②b 字符串里的写命令:`iex "Set-Content …"` 这类 ───────────────────────
|
|
78
|
+
# 语法树看得见 `iex` 这个命令节点,但**看不见字符串里的代码** —— 只能按参数子串兜一层。
|
|
79
|
+
# 这条同时演示 `argsContain` 维度(参数里**含**这串就算)。
|
|
80
|
+
- id: encoded-write
|
|
81
|
+
why: |
|
|
82
|
+
检测到用 Invoke-Expression 执行写入命令。**请直接用 edit 或 write 工具。**
|
|
83
|
+
代码在字符串里:事后没人看得到它改了什么,也没有 diff、没有快照、进不了回退。
|
|
84
|
+
(这条按参数子串判,所以只覆盖下面点名的写动词 —— 覆盖不全是有意的:
|
|
85
|
+
真正的兜底是"写内容一律走 edit / write",不是把所有写动词列全。)
|
|
86
|
+
commands: [invoke-expression, iex]
|
|
87
|
+
when:
|
|
88
|
+
argsContain: ['set-content', 'add-content', 'out-file']
|
|
89
|
+
|
|
90
|
+
# ── ③ 凭据文件:只看工具参数里的**路径**,不碰正文 ────────────────────────
|
|
91
|
+
# 这一支不经过 shell。所以在文件正文里"提到"这些名字是正当的,不算写它们。
|
|
92
|
+
- id: credentials-readonly
|
|
93
|
+
why: |
|
|
94
|
+
这些是凭据落点:写它们等于把凭据暴露或毁掉。
|
|
95
|
+
正确姿势:凭据配在环境变量或专门的凭据存储里;要动先 Copy-Item 备份到临时位置。
|
|
96
|
+
tools: [edit, write]
|
|
97
|
+
paths: ['.npmrc', '.credentials', '.netrc', 'id_rsa', 'id_ed25519', 'id_ecdsa']
|
|
98
|
+
|
|
99
|
+
# ── ④ 受保护目录:**删除/改名** 与 路径 同时命中才拦(读与列举不受影响)────
|
|
100
|
+
- id: protected-path
|
|
101
|
+
why: |
|
|
102
|
+
这些目录被删或改名之后,行为差异要到下次运行才暴露(依赖、版本历史、钩子都在里面)。
|
|
103
|
+
读取与列举不受限制。
|
|
104
|
+
commands: [Remove-Item, ri, rm, del, erase, rd, rmdir, Clear-Content, Move-Item, mi, move, mv, Rename-Item, ren, rni]
|
|
105
|
+
paths: ['.git', 'node_modules']
|
|
106
|
+
|
|
107
|
+
deliberate:
|
|
108
|
+
# ── ⑤ 删除:三思(受保护位置 / 递归 / 通配 / 强制,任一命中就停)──────────
|
|
109
|
+
- id: destructive-delete
|
|
110
|
+
cooldownSeconds: 10
|
|
111
|
+
why: |
|
|
112
|
+
删除不可逆:shell 删除没有回收站、没有 diff、进不了回退。
|
|
113
|
+
更便宜的做法:先 Copy-Item 备份;或别删只挪(Move-Item 到散放区,可逆)。
|
|
114
|
+
commands: [Remove-Item, del, erase, rd, ri, rm, rmdir, Clear-Item, cli]
|
|
115
|
+
when:
|
|
116
|
+
flags: ['-recurse', '-force']
|
|
117
|
+
wildcard: true
|
|
118
|
+
paths: ['.git', 'node_modules', '.ssh', '.aws', '.kube', '.env', 'src', 'docs']
|
|
119
|
+
|
|
120
|
+
# ── ⑥ 移动 / 改名到已存在的目标 = 覆盖 ───────────────────────────────────
|
|
121
|
+
- id: destructive-move
|
|
122
|
+
cooldownSeconds: 10
|
|
123
|
+
why: |
|
|
124
|
+
移动 / 改名到**已存在的目标**上就是覆盖 —— 和删除一样不可逆。
|
|
125
|
+
(实测:不加 -Force 时 Move-Item / Rename-Item 会报 "Cannot create a file when that file
|
|
126
|
+
already exists.",所以 **-Force 正好就是"允许覆盖"那一档**。)
|
|
127
|
+
commands: [Move-Item, mi, move, mv, Rename-Item, ren, rni]
|
|
128
|
+
when:
|
|
129
|
+
flags: ['-force']
|
|
130
|
+
wildcard: true
|
|
131
|
+
paths: ['.git', 'node_modules', '.ssh', '.aws', '.kube', '.env', 'src', 'docs']
|
|
132
|
+
|
|
133
|
+
# Copy-Item -Force 到已存在目标也是覆盖,但**默认不拦** ——
|
|
134
|
+
# 它是"先备份"那条建议用的动作;把备份也停下,等于罚自己推荐的做法。
|
|
135
|
+
# 想拦就取消注释;注意它会把"覆盖旧备份"一起停下。
|
|
136
|
+
# - id: destructive-copy-overwrite
|
|
137
|
+
# cooldownSeconds: 10
|
|
138
|
+
# commands: [Copy-Item, cp, copy, cpi]
|
|
139
|
+
# when:
|
|
140
|
+
# flags: ['-force']
|
|
141
|
+
|
|
142
|
+
# ── ⑦ 注册表增删改(读不受限)───────────────────────────────────────────
|
|
143
|
+
#
|
|
144
|
+
# ⚠️ 为什么拆成两条:`reg` 的子命令在**第一个位置参数**上;而 `*-ItemProperty` 这类 cmdlet
|
|
145
|
+
# 的第一个位置参数是**路径**,根本没有"子命令"这一维 —— 塞进同一条 `when.all`
|
|
146
|
+
# 会让 cmdlet 那一半永远不命中(2026-10-27 冒烟测试发现)。
|
|
147
|
+
- id: registry-write
|
|
148
|
+
cooldownSeconds: 10
|
|
149
|
+
why: |
|
|
150
|
+
注册表增删改的代价高:改错会影响整机行为,而且没有 diff、没有回退。
|
|
151
|
+
**读不受限**(reg query / Get-ItemProperty 都可以)。
|
|
152
|
+
commands: [reg, reg.exe]
|
|
153
|
+
when:
|
|
154
|
+
all:
|
|
155
|
+
- subcommands: [add, delete, import, restore, copy, load, unload]
|
|
156
|
+
- paths: ['HKCU', 'HKLM', 'HKCR', 'HKU', 'HKCC']
|
|
157
|
+
|
|
158
|
+
- id: registry-write-cmdlet
|
|
159
|
+
cooldownSeconds: 10
|
|
160
|
+
why: |
|
|
161
|
+
同上:这些 cmdlet 直接改注册表项 / 值。**读不受限**(Get-ItemProperty / Get-Item 都可以)。
|
|
162
|
+
commands:
|
|
163
|
+
- New-ItemProperty
|
|
164
|
+
- Set-ItemProperty
|
|
165
|
+
- Remove-ItemProperty
|
|
166
|
+
- Clear-ItemProperty
|
|
167
|
+
- Rename-ItemProperty
|
|
168
|
+
- Copy-ItemProperty
|
|
169
|
+
- Move-ItemProperty
|
|
170
|
+
- New-Item
|
|
171
|
+
- Remove-Item
|
|
172
|
+
- Rename-Item
|
|
173
|
+
- Move-Item
|
|
174
|
+
- Clear-Item
|
|
175
|
+
- ni
|
|
176
|
+
- sp
|
|
177
|
+
- rp
|
|
178
|
+
- clp
|
|
179
|
+
- rnp
|
|
180
|
+
- cpp
|
|
181
|
+
- mp
|
|
182
|
+
- del
|
|
183
|
+
- erase
|
|
184
|
+
- rd
|
|
185
|
+
- ri
|
|
186
|
+
- rm
|
|
187
|
+
- rmdir
|
|
188
|
+
- cli
|
|
189
|
+
when:
|
|
190
|
+
paths: ['HKCU', 'HKLM', 'HKCR', 'HKU', 'HKCC']
|
|
191
|
+
|
|
192
|
+
# ── ⑧ git 的范围收缩:会把工作区内容丢掉 ─────────────────────────────────
|
|
193
|
+
- id: destructive-git
|
|
194
|
+
cooldownSeconds: 10
|
|
195
|
+
why: |
|
|
196
|
+
git 的范围收缩子命令会把工作区的内容丢掉,而**未提交的部分不进 reflog** ——
|
|
197
|
+
reflog 只记 HEAD 移动过的东西,对"从未提交的文件"没有任何记录。
|
|
198
|
+
想看清楚再动(都只读):git status --short / git diff --stat / git stash list。
|
|
199
|
+
commands: [git, git.exe]
|
|
200
|
+
when:
|
|
201
|
+
all:
|
|
202
|
+
- subcommands: [checkout, restore, reset, clean, stash, branch, push]
|
|
203
|
+
- argsExact: ['.', 'drop', 'clear']
|
|
204
|
+
wildcard: true
|
|
205
|
+
flags: ['--hard', '--force', '-f', '-fd', '-fdx', '-d', '-D', '--delete']
|
|
206
|
+
# 进冷却之前跑这些**只读**探针,把"这条命令会丢什么"现算出来放进窗口。
|
|
207
|
+
evidence:
|
|
208
|
+
- git status --porcelain
|
|
209
|
+
- git diff --shortstat HEAD
|
|
210
|
+
- git ls-files --others --exclude-standard
|
package/test/hook.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// 插件级集成测试:走**真的 apply()**(假 ctx),而不是直接调 evaluate()。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:smoke.mjs 只验"裁决引擎"—— 它直接调 evaluate(),绕过了插件的真正入口:
|
|
4
|
+
// apply() → ctx.on('tools/pre-execute') → check(exec)
|
|
5
|
+
// 那一层会漏掉的东西:规则文件的加载与 mtime 缓存、非 shell 分支、规则读坏时的 fail-closed、
|
|
6
|
+
// 拒绝文案的组装、三思窗口的生成、钩子与工具的注册。
|
|
7
|
+
//
|
|
8
|
+
// 仍然**不启动宿主、不注册真实钩子**(假 ctx 只把注册记下来),也**不执行任何被测命令**。
|
|
9
|
+
//
|
|
10
|
+
// 用法:node test/hook.mjs
|
|
11
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const RULES = path.join(here, '..', 'rules', 'default.yaml')
|
|
18
|
+
const entry = new URL(`file://${path.join(here, '..', 'lib', 'index.js').replaceAll('\\', '/')}`)
|
|
19
|
+
const mod = await import(entry)
|
|
20
|
+
|
|
21
|
+
let bad = 0
|
|
22
|
+
function check(label, want, got) {
|
|
23
|
+
const ok = JSON.stringify(got) === JSON.stringify(want)
|
|
24
|
+
if (!ok) bad++
|
|
25
|
+
console.log(`${ok ? ' ✅' : ' ❌'} 期望=${JSON.stringify(want).padEnd(18)} 实际=${JSON.stringify(got).padEnd(18)} ${label}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── 假 ctx:只记录注册,不接事件总线 ─────────────────────────────────────
|
|
29
|
+
function makeCtx() {
|
|
30
|
+
const captured = { hooks: new Map(), tools: [] }
|
|
31
|
+
const ctx = {
|
|
32
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
33
|
+
on(event, handler) { captured.hooks.set(event, handler) },
|
|
34
|
+
tools: { register(tool) { captured.tools.push(tool) } },
|
|
35
|
+
}
|
|
36
|
+
return { ctx, captured }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { ctx, captured } = makeCtx()
|
|
40
|
+
mod.apply(ctx, { rulesFile: RULES, timeoutMs: 15000 })
|
|
41
|
+
|
|
42
|
+
const hook = captured.hooks.get('tools/pre-execute')
|
|
43
|
+
check('注册了 tools/pre-execute', 'function', typeof hook)
|
|
44
|
+
check('注册了 ast_confirm', true, captured.tools.some((tool) => tool.name === 'ast_confirm'))
|
|
45
|
+
|
|
46
|
+
// 假 exec:形状照宿主给的那份(name / arguments / agent.session.header.cwd)
|
|
47
|
+
const exec = (name, args) => ({
|
|
48
|
+
name,
|
|
49
|
+
arguments: args,
|
|
50
|
+
agent: { session: { header: { cwd: here, id: 'hook-test' } } },
|
|
51
|
+
})
|
|
52
|
+
const next = async () => ({ kind: 'allow' })
|
|
53
|
+
const decide = async (name, args) => hook(exec(name, args), next)
|
|
54
|
+
|
|
55
|
+
// ① shell + 写内容 ⇒ 拒,且文案里带规则 id(证明走到 buildDenialMessage)
|
|
56
|
+
const d1 = await decide('pwsh', { command: 'Set-Content -Path x.txt -Value y' })
|
|
57
|
+
check('Set-Content ⇒ kind', 'deny', d1 && d1.kind)
|
|
58
|
+
check('Set-Content ⇒ 文案带 id', true, String(d1 && d1.reason).includes('content-write'))
|
|
59
|
+
|
|
60
|
+
// ② shell + 只读 ⇒ 交给下游(本层返回 next() 的结果)
|
|
61
|
+
const d2 = await decide('pwsh', { command: 'Get-Content x.txt -TotalCount 3' })
|
|
62
|
+
check('只读 ⇒ 下游', 'allow', d2 && d2.kind)
|
|
63
|
+
|
|
64
|
+
// ③ 非 shell 分支:写凭据文件 ⇒ 拒(这一支不经过解析器)
|
|
65
|
+
const d3 = await decide('write', { file_path: '.npmrc', content: 'x' })
|
|
66
|
+
check('write .npmrc ⇒ kind', 'deny', d3 && d3.kind)
|
|
67
|
+
check('write .npmrc ⇒ 文案带 id', true, String(d3 && d3.reason).includes('credentials-readonly'))
|
|
68
|
+
|
|
69
|
+
// ④ 非 shell + 正文提到凭据文件名 ⇒ 放行(只比路径参数)
|
|
70
|
+
const d4 = await decide('write', { file_path: 'notes.md', content: '改 .npmrc 之前先备份' })
|
|
71
|
+
check('正文提到 ⇒ 下游', 'allow', d4 && d4.kind)
|
|
72
|
+
|
|
73
|
+
// ⑤ 三思:递归删除 ⇒ 拒,且文案是"冷却窗口"那一套(不是直接拒绝)
|
|
74
|
+
const d5 = await decide('pwsh', { command: 'Remove-Item .\\src\\a -Recurse' })
|
|
75
|
+
check('递归删除 ⇒ kind', 'deny', d5 && d5.kind)
|
|
76
|
+
check('递归删除 ⇒ 冷却窗口文案', true, String(d5 && d5.reason).includes('冷却'))
|
|
77
|
+
|
|
78
|
+
// ⑥ 规则文件读坏 ⇒ fail-closed(拒绝并说明怎么修),不是静默放行
|
|
79
|
+
const brokenDir = mkdtempSync(path.join(tmpdir(), 'ast-guard-broken-'))
|
|
80
|
+
const broken = path.join(brokenDir, 'rules.yaml')
|
|
81
|
+
writeFileSync(broken, 'deny:\n - id: [这不是合法的规则结构\n', 'utf8')
|
|
82
|
+
const second = makeCtx()
|
|
83
|
+
mod.apply(second.ctx, { rulesFile: broken, timeoutMs: 15000 })
|
|
84
|
+
const hook2 = second.captured.hooks.get('tools/pre-execute')
|
|
85
|
+
const d6 = await hook2(exec('pwsh', { command: 'Get-ChildItem .' }), next)
|
|
86
|
+
check('规则读坏 ⇒ kind', 'deny', d6 && d6.kind)
|
|
87
|
+
check('规则读坏 ⇒ 说明怎么修', true, String(d6 && d6.reason).includes('规则'))
|
|
88
|
+
|
|
89
|
+
console.log('')
|
|
90
|
+
const total = 12
|
|
91
|
+
if (bad === 0) {
|
|
92
|
+
console.log(`插件级集成测试全部通过(${total} 条;走真的 apply(),假 ctx,不执行任何命令)`)
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
|
95
|
+
console.log(`${bad} 条不符`)
|
|
96
|
+
process.exit(1)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 装载自检:只 import 插件的入口模块,**不启动**、不注册钩子。
|
|
2
|
+
// 目的:在动 profile 配置之前,先确认"这个包能不能被 require 进来"——
|
|
3
|
+
// 缺文件、写错路径、语法错误,都会在这一步炸,而不是等重启之后才发现守卫没装上。
|
|
4
|
+
//
|
|
5
|
+
// 用法:node test/import-check.mjs
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const entry = path.join(here, '..', 'lib', 'index.js')
|
|
11
|
+
|
|
12
|
+
let mod
|
|
13
|
+
try {
|
|
14
|
+
mod = await import(new URL(`file://${entry.replaceAll('\\', '/')}`))
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error('✗ 入口模块 import 失败:' + error.message)
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const problems = []
|
|
21
|
+
if (typeof mod.apply !== 'function') problems.push('没有导出 apply(ctx, config)')
|
|
22
|
+
if (typeof mod.name !== 'string' || mod.name === '') problems.push('没有导出 name')
|
|
23
|
+
if (!Array.isArray(mod.inject)) problems.push('没有导出 inject(数组)')
|
|
24
|
+
|
|
25
|
+
if (problems.length > 0) {
|
|
26
|
+
console.error('✗ 入口导出不对:\n - ' + problems.join('\n - '))
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`✓ 入口可装载:name=${mod.name} inject=[${mod.inject.join(', ')}]`)
|
|
31
|
+
process.exit(0)
|
package/test/smoke.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 冒烟测试 —— **全程 dry-run**:只判"裁决",不执行任何被测命令。
|
|
2
|
+
//
|
|
3
|
+
// 三件事:① 编译 rules/default.yaml(走真的编译链)② 喂命令文本给常驻解析器(只解析,不执行)
|
|
4
|
+
// ③ 断言裁决结果。不联网、不需要 git、不写任何文件。
|
|
5
|
+
//
|
|
6
|
+
// 用法:node test/smoke.mjs (或 npm test)
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const RULES = path.join(here, '..', 'rules', 'default.yaml')
|
|
12
|
+
|
|
13
|
+
const { AstDaemon } = await import('../lib/daemon.js')
|
|
14
|
+
const { parseRulesFile, evaluate, evaluateToolParams } = await import('../lib/rules.js')
|
|
15
|
+
const { DeliberateDesk } = await import('../lib/deliberate.js')
|
|
16
|
+
|
|
17
|
+
let bad = 0
|
|
18
|
+
function check(label, want, got) {
|
|
19
|
+
const ok = got === want
|
|
20
|
+
if (!ok) bad++
|
|
21
|
+
console.log(`${ok ? ' ✅' : ' ❌'} 期望=${String(want ?? 'pass').padEnd(22)} 实际=${String(got ?? 'pass').padEnd(22)} ${label}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rules = await parseRulesFile(RULES)
|
|
25
|
+
console.log(`规则已编译:deny ${rules.deny.length} 条 · deliberate ${rules.deliberate.length} 条`)
|
|
26
|
+
|
|
27
|
+
const daemon = new AstDaemon({
|
|
28
|
+
timeoutMs: 15000,
|
|
29
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
30
|
+
})
|
|
31
|
+
await daemon.start()
|
|
32
|
+
|
|
33
|
+
// ── ① 写文件内容(这套东西的重点交付物)────────────────────────────────────
|
|
34
|
+
const writeCases = [
|
|
35
|
+
['命令词 Set-Content', 'Set-Content -Path x.txt -Value y', 'content-write'],
|
|
36
|
+
['别名 sc', 'sc -Path x.txt -Value y', 'content-write'],
|
|
37
|
+
['别名 tee', 'Get-Date | tee x.txt', 'content-write'],
|
|
38
|
+
['重定向 >', 'Get-ChildItem . > out.txt', 'content-write'],
|
|
39
|
+
['.NET 直写', '[IO.File]::WriteAllText("x.txt","y")', 'content-write'],
|
|
40
|
+
['读文件(放行)', 'Get-Content x.txt -TotalCount 3', null],
|
|
41
|
+
['流合并 2>&1(放行)', 'node x.mjs 2>&1', null],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// ── ② 内联代码 ────────────────────────────────────────────────────────────
|
|
45
|
+
const inlineCases = [
|
|
46
|
+
['node -e', 'node -e "console.log(1)"', 'inline-code-node'],
|
|
47
|
+
['node -p', 'node -p "process.version"', 'inline-code-node'],
|
|
48
|
+
['node 跑脚本(放行)', 'node tool.mjs', null],
|
|
49
|
+
['node -c 语法检查(放行 —— 与 python -c 含义不同)', 'node -c tool.mjs', null],
|
|
50
|
+
['python -c', 'python -c "print(1)"', 'inline-code-interp'],
|
|
51
|
+
['powershell -Command', 'powershell -NoProfile -Command "Get-Date"', 'inline-code-interp'],
|
|
52
|
+
// 字符串里的写命令(语法树看不见内层代码,只按参数子串兜一层)
|
|
53
|
+
['iex 里藏写命令', 'iex "Set-Content -Path x.txt -Value y"', 'encoded-write'],
|
|
54
|
+
['iex 只读(放行)', 'iex "Get-Date"', null],
|
|
55
|
+
['python -m(放行)', 'python -m http.server 8000', null],
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
// ── ③ 受保护目录 ──────────────────────────────────────────────────────────
|
|
59
|
+
const pathCases = [
|
|
60
|
+
['删 node_modules', 'Remove-Item .\\node_modules\\x -Force', 'protected-path'],
|
|
61
|
+
['删 .git 下的东西', 'Remove-Item .\\\.git\\hooks\\x', 'protected-path'],
|
|
62
|
+
['读 node_modules(放行)', 'Get-ChildItem .\\node_modules', null],
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
// ── ④ 三思层(只断言"停在哪条规则上",不确认、不等冷却)──────────────────
|
|
66
|
+
const desk = new DeliberateDesk()
|
|
67
|
+
const deliberateCases = [
|
|
68
|
+
['递归删除', 'Remove-Item .\\src\\a -Recurse', 'destructive-delete'],
|
|
69
|
+
['删受保护目录', 'Remove-Item .\\node_modules -Force', 'destructive-delete'],
|
|
70
|
+
['移动覆盖', 'Move-Item .\\a.txt .\\src\\a.txt -Force', 'destructive-move'],
|
|
71
|
+
['注册表改键(cmdlet 那条)', "New-ItemProperty -Path 'HKCU:\\Software\\X' -Name a -Value b", 'registry-write-cmdlet'],
|
|
72
|
+
['注册表改键(reg 那条)', 'reg add HKCU\\Software\\X /v a /d b /f', 'registry-write'],
|
|
73
|
+
['读注册表(放行)', "reg query HKCU\\Software\\X", null],
|
|
74
|
+
['git 范围收缩', 'git checkout -- .', 'destructive-git'],
|
|
75
|
+
['git 建分支(放行)', 'git checkout -b feature-x', null],
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
// ── ⑤ 工具参数(不经过 shell)─────────────────────────────────────────────
|
|
79
|
+
const toolCases = [
|
|
80
|
+
['写凭据文件', 'write', { file_path: '.npmrc', content: 'x' }, 'credentials-readonly'],
|
|
81
|
+
['写别的文件、正文提到凭据文件名(放行)', 'write', { file_path: 'notes.md', content: '改 .npmrc 之前先备份' }, null],
|
|
82
|
+
['读凭据文件(放行)', 'read', { file_path: '.npmrc' }, null],
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
for (const [label, command, want] of [...writeCases, ...inlineCases, ...pathCases]) {
|
|
86
|
+
const facts = await daemon.parse(command)
|
|
87
|
+
const hit = evaluate(rules, facts)
|
|
88
|
+
check(label, want, hit === null ? null : hit.rule.id)
|
|
89
|
+
}
|
|
90
|
+
for (const [label, tool, args, want] of toolCases) {
|
|
91
|
+
const hit = evaluateToolParams(rules, tool, args)
|
|
92
|
+
check(label, want, hit === null ? null : hit.rule.id)
|
|
93
|
+
}
|
|
94
|
+
for (const [label, command, want] of deliberateCases) {
|
|
95
|
+
const facts = await daemon.parse(command)
|
|
96
|
+
const verdict = desk.check({ sessionId: 'smoke', command, facts, rules })
|
|
97
|
+
check(label, want, verdict === null ? null : verdict.pending.rule.id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
daemon.stop()
|
|
101
|
+
|
|
102
|
+
const total = writeCases.length + inlineCases.length + pathCases.length + toolCases.length + deliberateCases.length
|
|
103
|
+
console.log('')
|
|
104
|
+
if (bad === 0) {
|
|
105
|
+
console.log(`冒烟测试全部通过(${total} 条,全程 dry-run)`)
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
console.log(`${bad} 条不符`)
|
|
109
|
+
process.exit(1)
|