dsh-malware-audit
开发工具 活跃维护

dsh-malware-audit

rand0wn/dsh-malware-audit

抽象语法树对已安装插件开展恶意意图模式扫描,支持自定义周期定时检测,发现高危风险可自动触发隔离,无需人工逐行排查即可快速定位潜在恶意插件。

0
Stars 标星
0
Forks 分支
0
Watchers 关注
0
Open Issues
TypeScript
主要语言
MIT
开源协议
49 KB
仓库大小
1 个月前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:rand0wn/dsh-malware-audit
git clone https://github.com/rand0wn/dsh-malware-audit.git
git clone git@github.com:rand0wn/dsh-malware-audit.git
README.md main

dsh-malware-audit

CI

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():

  1. Moves the plugin's node_modules entry into
    .dsh-malware-audit/quarantine/<name>-<timestamp>/. For a dev-linked
    install (dsh plugin add /local/checkout, a symlink in node_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.
  2. Removes the plugin's name from every profile's dsh.profile.bundles
    list
    that names it, across every local profile. This step is not
    optional — leaving a bundle entry pointing at a now-missing
    node_modules package 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-network fires on well-known infra IPs like AWS's
    169.254.169.254 metadata endpoint or the 169.254.170.2 ECS
    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-shape is a same-call-expression heuristic, not real data
    flow — it only catches process.env referenced directly in a network
    call's own argument list, not const 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-write only 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-level fs.open/fs.write file-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 caught eval(Buffer.from(x, 'base64').toString())
    inline and missed the more common const 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 a name -> initializer map 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