dsh-malware-audit
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
No comments yet. Be the first to write one.