基于 AST 的已装插件恶意行为扫描:识别动态 eval、跨插件写入、外泄型网络调用等模式,并给出可复核的报告。
安装
# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)
dsh plugin --profile web add github:rand0wn/dsh-malware-audit
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。
README
该插件的 README 只有英文版本。
A DeepSeek Harness (dsh) plugin that scans installed plugins' real syntax trees for patterns shaped like malicious intent — the one thing every other dsh audit tool explicitly declines to do — with an optional periodic schedule and opt-in auto-quarantine on critical findings.
Why
dsh plugins run with real filesystem and process access, and installing one is a one-line command. Existing audit tools (dsh-security-audit, dsh-plugin-audit) do this well for capability — they report that a plugin can touch the filesystem, network, or credentials, then explicitly stop short of judging intent: "an audit aid, not an antivirus."
dsh-malware-audit fills that specific gap: it looks for the handful of techniques that separate "this plugin can do a lot" from "this looks like it's hiding what it does" — dynamic code execution from strings, fetch-and-execute, cross-plugin file writes (the exact technique a real hot-reload plugin in the wild uses to inject code into other installed plugins), and exfiltration-shaped network calls.
Still not an antivirus. There is no signature database of known-bad packages here — this can't tell you a specific npm package was reported compromised. It's a heuristic scanner over a small, fixed rule set. What changed from an earlier version: detection now walks the real TypeScript-compiler AST instead of matching raw text, so a comment, a string literal, or a JSDoc example can no longer trigger a finding — only real syntax can. See Known false positives for what still can.
Install
dsh plugin --profile <name> add dsh-malware-audit
Add the package name to that profile's dsh.profile.bundles list (being a
listed dependency alone doesn't activate a plugin's dsh.bundle patch —
see dsh-minimal-anchor's README
for why):
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-malware-audit"
]
}
}
}
Confirm it composed with dsh --profile <name> --dump-config — look for a
malware-audit entry.
Usage
Type /scan-plugins in any session. It scans every other installed
package that declares dsh.bundle in its own package.json — the same
marker the plugin ecosystem's own registries use to mean "this is a dsh
plugin" — across every local profile, prints a findings summary, and saves
the full report to .dsh-malware-audit/scan-<timestamp>.txt under the
current working directory.
By default this is entirely read-only and manual. Two things make it more active, both opt-in and off unless you configure them:
scheduleMinutes— runs the same scan automatically on an interval, no command needed.autoQuarantine— on a scan (scheduled or manual) that finds a critical-severity pattern, automatically quarantines that plugin.
Configuration
# profiles/<name>/cordis.patch.yml
- insert:
- id: malware-audit
name: 'dsh-malware-audit'
config:
maxFiles: 400
maxFileBytes: 262144
ignoreRuleIds: []
ignorePlugins: []
scheduleMinutes: 0
autoQuarantine: false
| Field | Default | Description |
|---|---|---|
maxFiles |
400 |
Per-plugin file-count budget before the scan of that plugin truncates. |
maxFileBytes |
262144 (256 KiB) |
Files larger than this are skipped, not scanned. |
ignoreRuleIds |
[] |
Rule ids to skip entirely — see the table below for valid ids. |
ignorePlugins |
[] |
Plugin directory names to skip entirely — a plugin you already trust and don't want re-scanned every time. |
scheduleMinutes |
0 |
Minutes between automatic scans. 0 disables scheduling. Below 5 is rejected (logged, not silently clamped). |
autoQuarantine |
false |
Quarantine a plugin automatically on a critical finding. Read Quarantine before turning this on. |
What it checks
| Rule | Severity | What it catches |
|---|---|---|
dynamic-eval |
critical | A real call to eval(), new Function(), or vm.Script/runInNewContext/runInThisContext |
decode-then-execute |
critical | A base64-decoded value (Buffer.from(x, 'base64') or atob()) passed directly into eval() or require() |
fetch-and-execute |
critical | A child_process.exec/execSync call whose string argument shells out to curl/wget piped into a shell |
cross-plugin-write |
critical | A real fs.writeFile/writeFileSync/createWriteStream call targeting a path inside a different plugin's node_modules directory — the technique a real hot-reload plugin uses to inject code into other installed plugins |
raw-ip-network |
warning | fetch()/axios/http.request/https.request called with a raw IP-literal URL rather than a hostname |
env-exfil-shape |
warning | A network call (as above) whose own argument list references process.env |
child-process-shell |
notice | Any child_process.exec/execSync call — not inherently bad, worth a look given what it's handed |
Deliberately scoped to .js/.ts source files only (no .md/.json), and
only within each plugin's own directory (skipping its node_modules,
.git, lib, dist, build) — a resource budget caps files scanned
(400) and file size (256 KiB) per plugin. A file that fails to parse is
skipped, not treated as an error — the TypeScript parser is deliberately
error-tolerant and essentially never throws, but a genuinely unparseable
file just contributes no findings rather than crashing the scan.
Quarantine
When autoQuarantine: true and a scan finds a critical-severity pattern,
quarantinePlugin():
- Moves the plugin's
node_modulesentry into.dsh-malware-audit/quarantine/<name>-<timestamp>/. For a dev-linked install (dsh plugin add /local/checkout, a symlink innode_modules), this moves the symlink itself — never the real checkout it points to. Verified by test: the real target directory and its contents are confirmed still present and untouched afterward. - Removes the plugin's name from every profile's
dsh.profile.bundleslist that names it, across every local profile. This step is not optional — leaving a bundle entry pointing at a now-missingnode_modulespackage produces a hard boot failure next time (cannot resolve profile bundle), the exact crash this project hit by accident earlier in its own development. Quarantining without fixing the bundles list would trade "plugin might be malicious" for "profile cannot boot at all," which is strictly worse.
What quarantine does not do: stop an already-running instance of the plugin in the current process. There is no same-process API to dispose another plugin's live Cordis fiber from here — quarantine takes effect on the next boot of each affected profile, not immediately. If a scheduled scan quarantines something mid-session, that plugin keeps running until the next restart.
To restore a quarantined plugin, move its directory back from
.dsh-malware-audit/quarantine/ into the profile's node_modules under
its original name, and add it back to that profile's dsh.profile.bundles
list. There is no automated restore command yet — this is a manual,
deliberate step.
Given the real disruption a wrong quarantine causes (a plugin stops
loading, possibly one you use every day, on a heuristic finding that is
explicitly not proof of malice), autoQuarantine defaults to false.
Turn it on only once you've run /scan-plugins manually a few times and
trust the signal-to-noise for your actual installed plugins.
Periodic scanning
Set scheduleMinutes above 0 to run the same scan automatically, on the
same interval, for as long as the harness process stays up — via
ctx.interval() from @deepseek-ai/cordis-plugin-timer, which dsh-base
already mounts in every profile. This is a live, in-process timer, not an
OS-level cron job: it resets on every restart and only fires while the
dsh process is running, which is sufficient for dsh web's
long-running server but means nothing runs while the harness itself isn't.
Known false positives
Documented from the test suite, not hidden — the AST rewrite fixed the
worst false positives (comments, strings, JSDoc examples, and a plain
require() no longer trigger anything — all confirmed by test), but a few
real ones remain, all inherent to the rule shapes rather than parsing:
raw-ip-networkfires on well-known infra IPs like AWS's169.254.169.254metadata endpoint or the169.254.170.2ECS credentials endpoint, which are completely standard in any AWS SDK dependency's own source — expected, not a plugin doing anything wrong, but still reported since the rule can't tell "well-known infra address" from "attacker-controlled address" from a string literal alone.env-exfil-shapeis a same-call-expression heuristic, not real data flow — it only catchesprocess.envreferenced directly in a network call's own argument list, notconst e = process.env; ...; fetch(url, { body: e })a few lines later. Real dataflow analysis is out of scope for this tool; this catches the direct, careless case, not an evasive one.cross-plugin-writeonly recognizes a fixed list of write-call names (writeFile,writeFileSync,fs.writeFile,fs.writeFileSync,fs.promises.writeFile,createWriteStream,fs.createWriteStream) — a write performed through a renamed import, a wrapper function, or a lower-levelfs.open/fs.writefile-descriptor pair won't match.decode-then-execute's variable tracing is a flat, whole-file, unscoped map, not real scope analysis — found live, not by review: an earlier version only caughteval(Buffer.from(x, 'base64').toString())inline and missed the more commonconst payload = Buffer.from(x, 'base64').toString(); eval(payload)two-statement form entirely, which is exactly what a synthetic test plugin used during verification. Fixed by building aname -> initializermap across the whole file and checking it too, but that means a variable name reused in two unrelated functions can collide, and a reassignment after the initial declaration isn't tracked.
How it works
Discovery. There is no inventory service usable from inside a plugin
(@deepseek-ai/dsh-host-plugin-inventory is Remote-only, client-side), and
deriving a plugin's own location from import.meta.url doesn't work for a
locally-linked install — a dsh plugin add /local/checkout (pnpm link:)
install makes the profile's node_modules entry a symlink, and Node's
module resolution follows that symlink to its real location before
computing any node_modules-relative path, so there is no vanilla API that
reports the apparent (profile-relative) path the loader used. Confirmed by
direct test against a real dev-linked install before settling on the real
approach: @deepseek-ai/dsh-home-paths' dshHomePath() resolves
$DSH_HOME from explicit config / the $DSH_HOME env var / ~/.dsh —
never from a filesystem walk — so it's unaffected by how this plugin
itself is installed. From there, every profile's node_modules gets
scanned directly (deduped by real path across profiles), filtered to
packages that declare dsh.bundle.
That filter matters as much as the discovery mechanism: a hoisted
node_modules also contains every transitive dependency of every real
plugin. An earlier version of this scanner, verified against a real
multi-plugin profile, reported 30 "findings" across 316 packages — almost
all of them JSDoc comments in zod's and Node's own type declarations
mentioning eval(), not anything an installed plugin actually does. Only
packages declaring dsh.bundle — the same convention the plugin
ecosystem's own registries use — count as a plugin to scan.
Detection. scanText parses each file with ts.createSourceFile and
walks the real AST (ts.forEachChild), matching specific node shapes —
CallExpressions to eval, a NewExpression naming Function, a
CallExpression to a known write function whose first argument is a
string literal containing a sibling node_modules path — rather than
scanning raw text. Comments and unrelated string content are trivia the
parser strips before any node exists, so they structurally cannot trigger
a finding; this is what fixed the JSDoc/comment/string false positives an
earlier regex-based version had, and what let cross-plugin-write
distinguish an actual write call from a plain require() for the first
time.
scanText/scanPluginDir are pure/IO-separated functions covered by unit
tests, including fixtures proving each documented false-positive fix (a
comment containing eval(x), a JSDoc block quoting eval(), a bare
require() of a sibling plugin); findInstalledPluginDirs is tested
against a real temp-directory $DSH_HOME layout; quarantinePlugin is
tested against a real symlinked directory, confirming the real target is
never touched and only the link moves.
Verified against a real local dsh web boot, not just types: installed
alongside another real plugin and a synthetic dsh.bundle-declaring
plugin planted with eval(), a base64-decode-then-execute pattern, and a
child_process.exec call, ran /scan-plugins through a real browser
session with ignoreRuleIds configured, and confirmed via the session log
that exactly the non-ignored findings fired on the synthetic plugin and
zero on the real one.
Development
npm install
npm run typecheck
npm test
scanPluginDir needs no dsh runtime — examples/scan-standalone.ts
scans any directory on disk directly, useful for checking a plugin
checkout before you even install it, or in a CI step for your own plugin:
npx tsx examples/scan-standalone.ts /path/to/some/plugin/checkout
License
MIT
链接
同类插件
yjh051108/dsh-routing-suite★ 6997
一个仓库三件套:DSH 插件包的运行时注入器(注入、热重载、卸载、开发侧挂区一键转正、路由自愈,外带设置页插件管理:列出、卸载、拖入文件夹内化)、任务感知的思维模式路由 agent 预设(router-standard / router-spec / router-react)、以及分级两级任务协议(commit_star / lock_stage / revise_do / edit_plan / mark_task / redteam_verdict 六个工具,任务状态落盘)。注入器实现直接在库内,安装的是它自己的行为而不是一份依赖清单。
strukto-ai/mirage#dsh★ 3696
把文件系统与 bash 提供者换成 mirage 虚拟工作区:文件工具与 shell 命令作用于挂载的资源(RAM、S3、Redis、Slack、Gmail、Notion、Postgres)而非宿主磁盘,支持按挂载点设置读/写/执行模式、按命令选择沙箱(进程内 monty、pyodide、quickjs;远程 docker、e2b、daytona),并可在虚拟终端中安装 CLI(git、gh、slack、linear、ntn、gws,或自行注册的程序树)作为命令头词。
first-fluke/oh-my-agent#dsh★ 1344
在工具执行前后以及尝试结束当前轮次时运行项目 OMA 检查。随插件安装 OMA CLI 依赖,并在首次使用时初始化未配置的项目。需要 Node.js 22+;首次初始化需要网络连接和项目目录写入权限。
hust-open-atom-club/oh-dsh★ 322
社区发行版:TUI、桌面端与 Web UI 统一体验,分层安装、一步到位。
weijiafu14/pi2dsh★ 217
Pi Host ABI 兼容引擎:装一次之后,npm 上的 Pi 扩展原包经 `dsh plugin add <pi-package>` 直接作为 DSH 原生插件挂载。已在官方 DSH 上端到端验证 pi-mcp-adapter(完整 MCP 管理面:OAuth、resources、prompts、MCP Apps、elicitation、sampling)、@tintinweb/pi-subagents、pi-code、pi-hermes-memory、pi-background-tasks;`pi2dsh inspect` 在安装前报告一个包的兼容情况。
Fishquito7/dsh-skill-mcp-panel★ 207
在 DSH Web 设置中管理技能与 MCP 服务器:技能卡片热启停、工作区作用域、分组、批量迁移与拖拽导入,以及 stdio/HTTP MCP 增删改查、连接测试、密钥脱敏,并附带统一 dsh-panel 命令行。
社区评论
评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。