Skip to content
dsh.fish
Bundle

dsh-malware-audit

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.

Source
rand0wn
License
MIT
Updated
Updated 16 hours ago

Readme

# dsh-malware-audit

[![CI](https://github.com/rand0wn/dsh-malware-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/rand0wn/dsh-malware-audit/actions/workflows/ci.yml)

A [DeepSeek Harness](https://github.com/deepseek-ai/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`](https://github.com/omdsh-dev/dsh-security-audit), [`dsh-plugin-audit`](https://github.com/jkrandom-sudo/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](#known-false-positives) for what still can.

## Install

```bash
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](https://github.com/rand0wn/dsh-minimal-anchor#install)
for why):

```json
{
  "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

```yaml
# 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](#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 —
`CallExpression`s 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

```bash
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:

```bash
npx tsx examples/scan-standalone.ts /path/to/some/plugin/checkout
```

## License

MIT

Install

dsh plugin --profile web add github:rand0wn/dsh-malware-audit

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source