DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

rand0wn /

rand0wn/dsh-malware-audit

Verified

DeepSeek Harness (dsh) plugin: real AST-based scan of installed plugins for malicious-intent patterns, with an optional periodic schedule and auto-quarantine on critical findings. Advisory-by-default, not an antivirus signature database.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@54a420c4

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

—/ 5

No ratings yet

Verified DSH bundle

Commit 54a420c4ba1e

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout