dsh-plugin-inspector 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -388
- package/lib/checks/tier-a.js +9 -5
- package/lib/checks/tier-b.js +38 -6
- package/lib/checks/tier-c.js +25 -0
- package/lib/cli.js +5 -0
- package/lib/cordis-yaml.js +78 -42
- package/lib/files.js +2 -0
- package/lib/inspect.js +2 -0
- package/lib/knowledge.js +5 -6
- package/lib/manifest.js +1 -0
- package/lib/publish.js +2 -0
- package/lib/registry.js +1 -0
- package/lib/report.js +5 -0
- package/lib/source.js +11 -8
- package/lib/types/checks/tier-b.d.ts +20 -0
- package/lib/types/cordis-yaml.d.ts +15 -2
- package/lib/types/knowledge.d.ts +5 -6
- package/lib/types/source.d.ts +9 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
**Know what a plugin does before you install it.**
|
|
4
4
|
|
|
5
|
-
`dsh-inspect` reads a DeepSeek Harness plugin — a directory, an npm tarball, or a published
|
|
6
|
-
|
|
5
|
+
`dsh-inspect` reads a DeepSeek Harness plugin — a directory, an npm tarball, or a published package
|
|
6
|
+
fetched by name and checked against the hash the registry published — and tells you what it
|
|
7
7
|
declares and what its code is capable of. It does not install it, build it, import it, spawn it, or
|
|
8
8
|
evaluate any part of it.
|
|
9
9
|
|
|
@@ -11,6 +11,16 @@ evaluate any part of it.
|
|
|
11
11
|
$ dsh-inspect --from-npm some-dsh-plugin@1.4.0
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
📖 **[Full documentation](https://charlotten7.github.io/dsh-plugin-inspector/)**
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
`dsh plugin add` is a thin pnpm forwarder: it passes your arguments to pnpm verbatim, and a plugin
|
|
19
|
+
is ordinary Node code that runs in the agent's process at the agent's uid. Nothing between the
|
|
20
|
+
registry and that process tells you what the code can reach.
|
|
21
|
+
|
|
22
|
+
[The full argument →](https://charlotten7.github.io/dsh-plugin-inspector/)
|
|
23
|
+
|
|
14
24
|
## Install
|
|
15
25
|
|
|
16
26
|
Node `^22.19.0 || >=24`.
|
|
@@ -20,113 +30,14 @@ npm install -g dsh-plugin-inspector
|
|
|
20
30
|
dsh-inspect --help
|
|
21
31
|
```
|
|
22
32
|
|
|
23
|
-
From a checkout instead — `lib/` is generated, so a fresh clone has no `dsh-inspect` until
|
|
24
|
-
built:
|
|
33
|
+
From a checkout instead — `lib/` is generated, so a fresh clone has no `dsh-inspect` until built:
|
|
25
34
|
|
|
26
35
|
```console
|
|
27
36
|
git clone https://github.com/CharlotteN7/dsh-plugin-inspector
|
|
28
37
|
cd dsh-plugin-inspector
|
|
29
|
-
pnpm install
|
|
30
|
-
|
|
31
|
-
node lib/cli.js --help # or `pnpm link --global` for a `dsh-inspect` on PATH
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
To run it from source without building, `pnpm run inspect <target>`.
|
|
35
|
-
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
## Why
|
|
39
|
-
|
|
40
|
-
`dsh plugin add` is a thin pnpm forwarder. It passes your arguments to pnpm verbatim — no spec
|
|
41
|
-
parsing, no added flags, no subcommand allowlist, no confirmation prompt — and then reconciles
|
|
42
|
-
the profile's layer list from the installed state. Any package whose `package.json` declares
|
|
43
|
-
`dsh.bundle.patch` is promoted to a **mounted patch layer**: an ESM module imported into the
|
|
44
|
-
harness process at the agent's uid, with ungated top-level side effects, and a YAML layer that
|
|
45
|
-
applies *after* `@deepseek-ai/dsh-base` and can therefore override any field of any core row by
|
|
46
|
-
id — or set `disabled: true` on it.
|
|
47
|
-
|
|
48
|
-
The only thing `dsh plugin add` prints is a warning for the harmless case:
|
|
49
|
-
|
|
38
|
+
pnpm install && pnpm run build
|
|
39
|
+
node lib/cli.js --help
|
|
50
40
|
```
|
|
51
|
-
dsh: warning: <pkg> declares no dsh.bundle — installed as a plain dependency, not a profile layer
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
The dangerous case prints nothing.
|
|
55
|
-
|
|
56
|
-
There are over 5,000 repos tagged `dsh-plugin` — 5,071 when this was last counted, on 16 August
|
|
57
|
-
2026 — and no registry, no review, and no signing between any of them and your process. This tool
|
|
58
|
-
exists so that the moment before you install one is not a blank.
|
|
59
|
-
|
|
60
|
-
## What you get
|
|
61
|
-
|
|
62
|
-
The report has two halves, and the first one is the point of the tool.
|
|
63
|
-
|
|
64
|
-
**Facts** — no severity, always printed. Whether the package mounts as a patch layer and from
|
|
65
|
-
which file, whether it ships a browser bundle, which rows it inserts and which existing rows it
|
|
66
|
-
modifies, the `!!js` inventory of the mounted layer, cordis YAML it ships that nothing mounts,
|
|
67
|
-
commands it puts on your PATH, its dependencies, what model-visible text it ships, and how much of
|
|
68
|
-
it could be read. A well-behaved plugin has a full facts section and an empty findings section.
|
|
69
|
-
That is a useful answer, not an empty one.
|
|
70
|
-
|
|
71
|
-
**Findings** — ranked, in three tiers:
|
|
72
|
-
|
|
73
|
-
| Tier | What it reads | What it can say |
|
|
74
|
-
|---|---|---|
|
|
75
|
-
| **A** | Structured declarations: `package.json` keys, Cordis patch rows, the `!!js` expression inventory | A verdict. Confidence is `certain`, because the harness reads the same bytes the same way |
|
|
76
|
-
| **B** | Shipped source, through the TypeScript parser | A capability report: "this plugin **can** do X" |
|
|
77
|
-
| **C** | Whether the package could be read at all — minification, computed names, sourceless builds, binaries | That the analysis is degraded, and that no Tier B negative can be trusted |
|
|
78
|
-
|
|
79
|
-
Every Tier B and Tier C finding carries a `bypass` field naming the one-line evasion for that
|
|
80
|
-
specific check. It is inside the finding, not in a footnote, so a report cannot be rendered
|
|
81
|
-
without its caveat.
|
|
82
|
-
|
|
83
|
-
A finding is **per package, not per syntax site**. A package importing `node:fs` from eleven files
|
|
84
|
-
gets one finding with `occurrences: 11` and three example locations, because the eleventh import
|
|
85
|
-
warrants no decision the first did not. Findings are grouped by check and `subject` — the module
|
|
86
|
-
specifier, the row id, the seam name, the matched rule — so `node:child_process` and
|
|
87
|
-
`node:worker_threads` stay two findings, and a gate can accept `B13`/`node:fs` without accepting
|
|
88
|
-
every `B13`.
|
|
89
|
-
|
|
90
|
-
## What it reports on the real ecosystem
|
|
91
|
-
|
|
92
|
-
Measured **2026-08-17** against the 40 most-starred GitHub repositories tagged `dsh-plugin` that
|
|
93
|
-
publish a resolvable npm package, each pinned to the version it resolved to on 2026-08-16. Re-run
|
|
94
|
-
it with `pnpm run sweep`; the corpus is `scripts/ecosystem-corpus.json` and the recorded
|
|
95
|
-
measurement is `tests/ecosystem-baseline.json`.
|
|
96
|
-
|
|
97
|
-
Both columns come from the same corpus and the same pinned versions, so the difference is this
|
|
98
|
-
tool's doing and not the ecosystem's. "0.1" is `dsh-plugin-inspector@0.1.0`; "0.2" is this tree,
|
|
99
|
-
which reports itself as `0.2.1` because it reads the version out of its own manifest.
|
|
100
|
-
|
|
101
|
-
| | 0.1 | 0.2 |
|
|
102
|
-
|---|---|---|
|
|
103
|
-
| Findings | 1,420 | **295** |
|
|
104
|
-
| Critical | 252 | **3** |
|
|
105
|
-
| Median findings per package | 10.5 | **5.5** |
|
|
106
|
-
| Packages with a high or critical | 27 of 40 (68 %) | **21 of 40 (53 %)** |
|
|
107
|
-
| Packages failing `--fail-on critical` | 40 of 40 | **1 of 40** |
|
|
108
|
-
| Clean packages | 0 of 40 | **0 of 40** |
|
|
109
|
-
|
|
110
|
-
**The 0.1 README quoted "49 findings, 0 critical" and that number was worthless.** It was measured
|
|
111
|
-
on twelve targets — the harness's own bundles and our own sibling plugins — which is a sample
|
|
112
|
-
selected for being trusted already. Against published third-party plugins the same build produced
|
|
113
|
-
1,420 findings and 252 criticals, and no package came out clean.
|
|
114
|
-
|
|
115
|
-
Read the 0.2 column honestly:
|
|
116
|
-
|
|
117
|
-
- **`--fail-on critical` is now a usable gate.** It stops one package in forty. That package,
|
|
118
|
-
`@struktoai/mirage-dsh`, ships a patch layer that switches off `fs-sandbox`, `bash-sandbox` and
|
|
119
|
-
`pwsh-sandbox`, and its three findings lead the report. Under 0.1 the same three sat somewhere in
|
|
120
|
-
a list of 252.
|
|
121
|
-
- **The default `--fail-on high` still stops a majority of the ecosystem**, and that is not a
|
|
122
|
-
finished job. The largest remaining driver is `C2` — the analyzer saying it could not read the
|
|
123
|
-
package, on 33 % of the corpus. That is a true statement rather than a false positive, but a gate
|
|
124
|
-
that fires on a third of npm for reasons about the *tool* is not yet a gate.
|
|
125
|
-
- **No package is clean, and that is expected rather than alarming.** `C3` alone — "ships built
|
|
126
|
-
output and no source" — fires on 65 % of published packages, because that is what publishing a
|
|
127
|
-
package is. It is `low`, it does not degrade the analysis, and it is not a defect.
|
|
128
|
-
|
|
129
|
-
A readable report is not yet an installable gate.
|
|
130
41
|
|
|
131
42
|
## Usage
|
|
132
43
|
|
|
@@ -134,21 +45,14 @@ A readable report is not yet an installable gate.
|
|
|
134
45
|
dsh-inspect <target> [options]
|
|
135
46
|
dsh-inspect --from-npm <name>[@<version>] [options]
|
|
136
47
|
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
Options
|
|
140
|
-
--from-npm <spec> Fetch a published package from the registry, verify its
|
|
141
|
-
dist.integrity hash, and analyse it in memory.
|
|
48
|
+
--from-npm <spec> Fetch from the registry, verify dist.integrity, analyse in memory.
|
|
142
49
|
--registry <url> Registry base URL for --from-npm.
|
|
143
|
-
(default: https://registry.npmjs.org)
|
|
144
50
|
--json Emit the machine-readable JSON document on stdout.
|
|
145
|
-
--fail-on <severity> Exit 1 at or above this severity.
|
|
146
|
-
critical | high | medium | low | none (default: high)
|
|
51
|
+
--fail-on <severity> Exit 1 at or above this severity. (default: high)
|
|
147
52
|
--no-color Plain text, no ANSI.
|
|
148
|
-
--version, --help
|
|
149
53
|
```
|
|
150
54
|
|
|
151
|
-
**Exit codes
|
|
55
|
+
**Exit codes are the CI contract:**
|
|
152
56
|
|
|
153
57
|
| Code | Meaning |
|
|
154
58
|
|---|---|
|
|
@@ -156,297 +60,52 @@ Options
|
|
|
156
60
|
| `1` | Analysis completed; at least one finding at or above `--fail-on` |
|
|
157
61
|
| `2` | Analysis could not be performed |
|
|
158
62
|
|
|
159
|
-
`2` is deliberately distinct from `1`. A job that cannot tell "the analyzer broke" from "the
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
### Getting a package without installing it
|
|
63
|
+
`2` is deliberately distinct from `1`. A job that cannot tell "the analyzer broke" from "the plugin
|
|
64
|
+
is clean" is the failure this split exists to prevent.
|
|
163
65
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
```console
|
|
167
|
-
# From the registry, in one step. Reads the ~3 KB version document, downloads the tarball into
|
|
168
|
-
# memory, verifies dist.integrity BEFORE anything parses it, and analyses it there.
|
|
169
|
-
dsh-inspect --from-npm <name>@<version>
|
|
170
|
-
|
|
171
|
-
# From git. Clone shallow and point the tool at the directory — do NOT use `npm pack` on a git
|
|
172
|
-
# spec, which runs the package's `prepare` script.
|
|
173
|
-
git clone --depth 1 https://github.com/… /tmp/plugin
|
|
174
|
-
dsh-inspect /tmp/plugin
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
`--from-npm` is the only mode that opens a socket, and it is one flag per invocation: it cannot be
|
|
178
|
-
combined with a local target, and a directory or tarball scan can never reach it — the fetch lives
|
|
179
|
-
in a module the analysis path does not import. **A network fetch is not execution.** No subprocess,
|
|
180
|
-
no disk write, no lifecycle script, and no `npm pack`. The report records the tarball URL, the
|
|
181
|
-
digest that matched, and the registry's own `hasInstallScript` flag under `target.registry`.
|
|
182
|
-
|
|
183
|
-
If the hash does not match what the registry published, the tool refuses and parses nothing. If the
|
|
184
|
-
package predates `dist.integrity` entirely, the weaker `dist.shasum` is used and the report says
|
|
185
|
-
`sha1` rather than claiming more. If neither is published, that is a refusal too.
|
|
186
|
-
|
|
187
|
-
A tarball is decoded **entirely in memory**, from a file or from a fetch alike. Nothing is written
|
|
188
|
-
to disk, which makes tar path traversal structurally impossible rather than something a filter has
|
|
189
|
-
to catch. Every read ceiling is applied to the arriving stream rather than to a finished buffer, so
|
|
190
|
-
a 28 MB archive holding one 8 GB member is a refusal in under two seconds, not an out-of-memory
|
|
191
|
-
kill.
|
|
192
|
-
|
|
193
|
-
### Directory mode reads the working tree, not "the package"
|
|
194
|
-
|
|
195
|
-
The two targets are not the same thing and the report says which one you gave it.
|
|
196
|
-
|
|
197
|
-
A **tarball** is the published package: exactly the bytes a user installs. A **directory** is a
|
|
198
|
-
repository checkout, which holds far more — tests, fixtures, CI config, build scratch. None of that
|
|
199
|
-
is installed, none of it is mounted, and none of it can act on anybody, so the directory reader is
|
|
200
|
-
narrowed to the set `npm pack` would produce: the `files` allowlist when the manifest declares one,
|
|
201
|
-
otherwise `.npmignore` or `.gitignore` under npm's defaults. The facts section names which rule it
|
|
202
|
-
used and how many working-tree files it skipped.
|
|
203
|
-
|
|
204
|
-
This matters more than it sounds. Reading a checkout whole means a hostile *test fixture* — a file
|
|
205
|
-
that ships nowhere and mounts nothing — is reported at `critical` with `certain` confidence. That
|
|
206
|
-
is not a conservative error; it is the tool being confidently wrong about the one tier it treats as
|
|
207
|
-
a verdict.
|
|
66
|
+
[Full usage, and getting a package without installing it →](https://charlotten7.github.io/dsh-plugin-inspector/usage.html)
|
|
208
67
|
|
|
209
68
|
## What it looks for
|
|
210
69
|
|
|
211
|
-
|
|
70
|
+
Findings are tiered by how much you should trust them:
|
|
212
71
|
|
|
213
|
-
|
|
|
72
|
+
| Tier | What it means |
|
|
214
73
|
|---|---|
|
|
215
|
-
|
|
|
216
|
-
|
|
|
217
|
-
|
|
|
218
|
-
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
| `modelVisibleFiles` — shipped `SKILL.md` / skills / `AGENTS.md` / `CLAUDE.md` | file walk |
|
|
222
|
-
| `filesRead`, `bytesRead`, `sourceFilesParsed` | analysis run |
|
|
223
|
-
|
|
224
|
-
### Tier A — decidable, structured declaration, a real verdict
|
|
225
|
-
|
|
226
|
-
Tier A reads declarations, not code. It is *much* harder to hide from than Tier B, because the
|
|
227
|
-
harness itself must be able to read these fields literally in order to act on them: an attacker
|
|
228
|
-
cannot obfuscate `disabled: true` and still have it disable anything. Every Tier A finding has
|
|
229
|
-
confidence `certain`.
|
|
230
|
-
|
|
231
|
-
| id | Check | Severity | Method |
|
|
232
|
-
|---|---|---|---|
|
|
233
|
-
| A1 | Install lifecycle script (`preinstall`, `install`, `postinstall`, `prepare`, `prepublish`, `preprepare`, `postprepare`) | medium; **high** when the command itself fetches, decodes, pipes to a shell, or evaluates inline code | `package.json.scripts` key set. `dsh plugin add` forwards to pnpm verbatim and adds no `--ignore-scripts`, but pnpm ≥ 10 blocks a dependency's lifecycle scripts by default until the package is listed under `allowBuilds`, and `apps/cli/src/plugin.ts` prints that instruction when a build is blocked. The script is one approval away from running, not already running — which is why the category alone is `medium`, and why the measured 5 of 40 legitimate packages that declare one (`tsdown`, `npm run build`, `husky`, `node scripts/prepare.mjs`) stay there. The escalation reads the command line itself, and is calibrated to fire on none of them |
|
|
234
|
-
| A2 | Patch row sets `disabled` **truthily** on a **security-relevant** core row (`approval`, `permission`, `sandbox`, `sandbox-policy`, `bash-sandbox`, `pwsh-sandbox`, `fs-sandbox`, `fs-observation-policy`, `subprocess`, `credentials`, `timeout-policy`, `spill-policy`, `session-persistence-jsonl`) | **critical** | patch YAML row with `id ∈ SECURITY_ROWS`. The loader coerces — `disabledOf` is `Boolean(options.disabled)` (`vendor/loader/src/config/entry.ts`) — so `null`, `0` and `""` leave the row **running** and are not this finding. A `!!js` node is an object and stays truthy, so an expression is judged by what it can evaluate to |
|
|
235
|
-
| A3 | Patch row disables any other known core row | high for a `@deepseek-ai/dsh-base` row, medium for one only a surface bundle inserts | same, `id ∈ CORE_ROWS`. The row inventory records which of the three shipped bundles inserts each row, because they are not one profile: a `ui-*` row exists only where the web bundle is mounted. Suppressed entirely when the package under analysis *is* one of the three bundles — `@deepseek-ai/dsh-web-app` disabling two dozen rows `@deepseek-ai/dsh-base` inserted is what composing a surface bundle is |
|
|
236
|
-
| A4 | Patch row carries a `name` that does not match the targeted row's `name` | medium | `applyEntryPatches` treats `name` on a non-insert patch as an **assertion guard**, not an override: on mismatch it warns and `continue`s, skipping the whole patch. So this row does nothing at all. Either the author is targeting a row that has been renamed, or the patch is stale — in both cases what the user reads and what mounts disagree |
|
|
237
|
-
| A5 | Patch row overrides `config` / `inject` / `isolate` / `intercept` / `group` / any other key of an existing core row | medium (high for a security row) | patch YAML. Override is a **shallow whole-value replacement** (`target[key] = value`), never a deep merge, so overriding `config` discards the core row's entire configuration. `PatchOptions` carries a `[key: string]: any` index signature, so *any* key that is not `id`/`insert`/`name` is copied onto the target verbatim |
|
|
238
|
-
| A6 | `!!js` expression inventory, with AST sub-classification (see the `!!js` table below) | low → critical by class | dialect parse + `new Function` parse-compile, never evaluated |
|
|
239
|
-
| A7 | `!!js` in a field where the loader never interpolates it (`id`, `name`, `group`, `inject`, `intercept`, `isolate`) | medium | mirrors `metadataExpressionErrors`. Signal: the author believes it is live when it is inert — the plugin was very likely never validated |
|
|
240
|
-
| A8 | `!js` (single bang) anywhere in the patch YAML | medium | `!js` is a **hard YAML parse error**, verified. Its presence proves the plugin has never been successfully loaded by any harness |
|
|
241
|
-
| A9 | `insert` row naming a module that is neither this package nor any of its declared dependencies | high | set difference against `dependencies` ∪ `peerDependencies` ∪ own name. The layer mounts code whose provenance the manifest does not admit to |
|
|
242
|
-
| A10 | MCP server row — an `insert`ed row whose `name` is `@deepseek-ai/dsh-mcp-client`. `transport: stdio` → **critical**; `transport: streamable-http` → high | **critical** / high | The stdio config is `{ command, args, env, cwd }` and it spawns that executable directly — **not** through `ctx.subprocess` or `ctx.sandbox`, with no approval and no tool gate. Every tool the server advertises is then registered as `mcp__<serverName>__<tool>` with model-visible descriptions this package does not control. `streamable-http` does not spawn but still imports an untrusted remote tool catalogue. Structured declaration, so Tier A |
|
|
243
|
-
| A11 | Non-registry dependency specifier (`git+`, `github:`, `http(s):`, `file:`, `link:`) | high | the referenced code can change under a fixed version string |
|
|
244
|
-
| A12 | Shipped model-visible instruction text (`SKILL.md`, `**/skills/*/SKILL.md`, `**/skills/*.md`, `AGENTS.md`, `CLAUDE.md`) | low as presence; escalated by B10 | file walk. See the reach note below |
|
|
245
|
-
| A13 | No `files` allowlist in `package.json` | low | the published tarball is whatever happened to be in the working tree |
|
|
246
|
-
| A14 | `dsh.bundle.patch` climbs out of the package directory — contains a `..` that escapes | **critical** | `loadProfile` computes the patch path as `join(packageDir, declared)` with **no sanitization** of `declared`, and `..` segments survive that join. An **absolute** path does not escape and is not this finding: `join('/…/pkg', '/etc/passwd')` is `/…/pkg/etc/passwd`, which is inside the package and simply does not exist — that is A16 |
|
|
247
|
-
| A15 | Patch row redirects skill discovery into this package — sets `customSkillDirs` or `bundledSkillDir` on the `skill-filesystem` row | high | this is the declaration that turns shipped markdown into model-visible instructions. `bundledSkillDir` additionally carries `trustedHost: true`, which reads through raw Node `fs` and **bypasses the `ctx.fs` sandbox** |
|
|
248
|
-
| A16 | `dsh.bundle.patch` names a file the package does not ship | medium | commonly a `files` allowlist that forgets it. Mounting the bundle fails the profile boot |
|
|
249
|
-
| A17 | The declared patch layer does not parse | medium | the layer cannot load, and nothing inside it could be analysed |
|
|
250
|
-
| A18 | `package.json` field of the wrong shape | low | the field was ignored. A manifest that npm and the harness read differently is worth knowing about |
|
|
251
|
-
| A19 | Patch row sets `disabled` **falsily** on a core row | medium | the inverse of A2 and A3, and the one the coercion rule makes visible. Bundle layers apply after the profile's own, so a row the user deliberately switched off is switched back on by this one while the user's file still reads `disabled: true` |
|
|
252
|
-
| A20 | `dsh.profile.bundles` names packages to mount as bundles | high | the launcher resolves each named package, reads its `dsh.bundle.patch`, and mounts that layer (`packages/boot/app-boot/src/profile.ts`). This package is then a profile, and everything those packages declare composes into it — none of which is in this analysis |
|
|
253
|
-
| A21 | Injection phrasing in shipped instruction markdown | high | **Tier A rather than Tier B, and exempt from the Tier C downgrade.** There is no syntax between a `SKILL.md` and the model: the shipped bytes *are* the prompt, so there is nothing to obfuscate and nothing for a degraded parse to have made unreliable. What is heuristic is the reading of the sentence, not the reading of the file. Tool `description` hits stay Tier B (B10), because code assembles those |
|
|
254
|
-
| A22 | `bin` installs a command on the user's PATH | low | linked into the profile's `node_modules/.bin` at install time. The harness never runs it; the user, a script, or an agent shell tool can |
|
|
255
|
-
| A23 | Inserted row carries `isolate` or `intercept` on a catalogued service | **critical** for a security seam, high otherwise | `vendor/loader/src/config/isolate.ts` re-maps the named service to a fresh symbol realm for the row and every row beneath it, so a descendant injecting that name receives this subtree's implementation instead of the profile's. The same substitution as replacing the service in code, declared in YAML |
|
|
256
|
-
|
|
257
|
-
**Reach note for A12, stated because getting this wrong would be dishonest.** Shipping a `SKILL.md`
|
|
258
|
-
inside an npm package does **not** by itself put it in front of the model. There is no
|
|
259
|
-
`dsh.skills` manifest field. The filesystem provider scans a fixed root set —
|
|
260
|
-
`<project>/.dsh/skills`, `<project>/.agents/skills`, `$DSH_HOME/skills`,
|
|
261
|
-
`$DSH_AGENTS_HOME/skills`, `bundledSkillDir` — at depth 1 only (`<root>/<name>/SKILL.md` or `<root>/<name>.md`), and a plugin's own `node_modules`
|
|
262
|
-
directory is none of those. The three ways shipped text actually reaches the model are: the plugin
|
|
263
|
-
calls `ctx.skills.register()` / `ctx.skills.registerProvider()` (→ B10 on the registered body), a
|
|
264
|
-
patch row redirects a skill root into the package (→ A15), or the file is copied into the user's
|
|
265
|
-
workspace by something else. `AGENTS.md` / `CLAUDE.md` are a separate subsystem again — discovered
|
|
266
|
-
by walking the *workspace*, not the profile. So A12 on its own is `low` and its text says
|
|
267
|
-
"shipped, reaches the model only if registered or redirected"; it escalates to `high` only when
|
|
268
|
-
A15 or a `ctx.skills.register*` call is also present, or when B10's injection heuristics fire.
|
|
269
|
-
|
|
270
|
-
### Tier B — AST capability detection, "this plugin CAN do X"
|
|
271
|
-
|
|
272
|
-
Tier B parses shipped `.ts`/`.mts`/`.cts`/`.js`/`.mjs`/`.cjs` with the `typescript` compiler API —
|
|
273
|
-
`ts.createSourceFile`, syntax only, **no program, no type checker, no module resolution, no
|
|
274
|
-
transpilation, no execution**. Default confidence `high`, dropped to `moderate` when any Tier C
|
|
275
|
-
readability finding fires.
|
|
276
|
-
|
|
277
|
-
| id | Check | Severity | Method |
|
|
278
|
-
|---|---|---|---|
|
|
279
|
-
| B1 | Replaces a core capability seam — `ctx.provide(<seam>, …)` / `ctx.set(<seam>, …)` where `<seam>` is a key from `api-catalog.ts` | **critical** | call expression, literal first argument matched against the seam key set |
|
|
280
|
-
| B5 | System-prompt mutation — `system-prompt/assemble` listener, or `ctx.systemPrompt.{section,context,variable,tools,suppressRuntimeContext}` | high | call matching |
|
|
281
|
-
| B6 | Credential read — `process.env.*(TOKEN\|KEY\|SECRET\|PASSWORD\|CREDENTIAL)*`, `~/.dsh/credentials`, `~/.npmrc`, `~/.aws`, `~/.ssh`, `ctx.credentials.*` | medium alone | identifier + literal matching |
|
|
282
|
-
| B7 | Network egress — `fetch`, `node:http(s).request`, `node:net`, `WebSocket`, `undici` | medium alone | import + call matching |
|
|
283
|
-
| B8 | **Exfiltration pair** — B6 ∧ B7 in the same package | high | set intersection. Reported explicitly as *capability, not dataflow*: the tool cannot prove the credential value reaches the socket. `high` rather than critical because it fires on 18 % of published plugins |
|
|
284
|
-
| B9 | Direct `node:child_process` / `node:worker_threads` / `node:vm` | medium alone, high paired with B8's two halves | import specifier. Bypasses `ctx.subprocess` and `ctx.sandbox` entirely. `medium` alone because a bare import fires on half the published ecosystem |
|
|
285
|
-
| B10 | Prompt-injection heuristics on **model-visible text only** — registered tool `description` string literals, and shipped skill/instruction files | high | imperative-override phrasing, role reassignment, exfiltration instructions, hidden-text markers — zero-width and bidirectional controls, the tag block, and runs of four or more variation selectors, the encoding GlassWorm shipped executable JavaScript in. Run on *exactly* the text that reaches the model, never on ordinary source comments |
|
|
286
|
-
| B11 | Nested plugin mounting — `ctx.plugin(…)`, loader manipulation | high | call matching. A layer that mounts further layers moves the analysis target |
|
|
287
|
-
| B12 | Dynamic code construction — `eval`, `new Function`, `vm.runInNewContext`, `module._load` | high | call matching |
|
|
288
|
-
| B13 | Filesystem access outside `ctx.fs` — imports `node:fs` or `node:fs/promises` | medium | Reads and writes through the Node API are invisible to `fs/write-intent`, `fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and nothing appears in the session log |
|
|
289
|
-
|
|
290
|
-
**The framing B7, B9, and B13 share.** The harness's own dynamic-package sandbox
|
|
291
|
-
(`cordis-host-runner/src/sandbox.ts`) traps exactly `require`, `setTimeout`, `setInterval`,
|
|
292
|
-
`setImmediate`, `clearTimeout`, `clearInterval`, and `fetch`, redirecting each to a `ctx` service;
|
|
293
|
-
it leaves `process` `undefined` and exposes only the seven `HOST_BUILTIN_INSPECTION` globals.
|
|
294
|
-
**An installed npm bundle layer gets none of that** — it is a plain ESM import into the harness
|
|
295
|
-
process. So these three checks report a gap the harness itself defines: *the harness denies
|
|
296
|
-
untrusted code this capability, and this package uses it from a position where nothing denies it.*
|
|
297
|
-
That is the harness's reckoning, not a rule invented here.
|
|
298
|
-
|
|
299
|
-
### Tier C — heuristic; "we cannot read this" is itself the finding
|
|
300
|
-
|
|
301
|
-
| id | Check | Severity | Effect |
|
|
302
|
-
|---|---|---|---|
|
|
303
|
-
| C1 | Minified or obfuscated source — long lines that are **most of the file**, or a dense file of under five lines. One long line is an embedded prompt or a base64 asset, not minification, and the harness's own web bundle has one | medium | **degrades** |
|
|
304
|
-
| C2 | Dynamic dispatch — computed member access on `ctx` (`ctx[expr]`), non-literal `import()`/`require()`, `atob`/`Buffer.from(…, 'base64')`, an assembled name passed to `.on`/`.set`/`.emit` **on a known context binding**. The receiver guard is the whole check: `.set` and `.get` are `Map`'s names too, and ``this.steps.set(`${turn}:${step}`, t)`` is a composite key, not evasion | high | **degrades** |
|
|
305
|
-
| C3 | Ships built output with no corresponding source (`lib/` without `src/`) | low | **does not degrade** — the bytes were read exactly as written and exactly as they will run; what cannot be checked is whether they match the repository. Treating that as an unreadable package marks every ordinary published tarball degraded, because shipping built output and no source is what publishing *is* |
|
|
306
|
-
| C4 | Unreadable payload — `.node`, `.wasm`, binaries, files over the size cap | medium | **degrades** |
|
|
307
|
-
| C5 | The mounted layer hit a walk ceiling — nesting depth or node count | high | **degrades**. Rows past the ceiling were not read |
|
|
308
|
-
| C6 | A `.min.js` artifact | low | **degrades** |
|
|
309
|
-
|
|
310
|
-
### `!!js` sub-classification (A6)
|
|
311
|
-
|
|
312
|
-
Every `!!js` node is inventoried with its YAML path and text, then parse-compiled with
|
|
313
|
-
`new Function('return (' + expr + ')')` — compilation only; the constructor never executes the
|
|
314
|
-
body — and the resulting AST is classified.
|
|
315
|
-
|
|
316
|
-
Classification is by **reach**, not by syntactic form. `dshHomePath('sessions')` and `steal()` are
|
|
317
|
-
both `CallExpression`s; the first is a helper `dsh-app-boot` puts in scope with
|
|
318
|
-
`ctx.provide('dshHomePath', dshHomePath)` before any entry mounts, documented as such in that
|
|
319
|
-
package's README, and used by the base bundle's own `session-persistence-jsonl` row.
|
|
320
|
-
|
|
321
|
-
| Class | Example | Severity | Finding |
|
|
322
|
-
|---|---|---|---|
|
|
323
|
-
| `literal` | `true`, `3` | — | fact only |
|
|
324
|
-
| `inert-read` | `process.env.DSH_TOOLS_MODE`, `process.platform === 'win32'`, `ctx.webStartup.host` | — | fact only |
|
|
325
|
-
| `harness-call` | `dshHomePath('sessions')`, `process.cwd()` | low | A6 |
|
|
326
|
-
| `call` | a call this tool cannot resolve | medium | A6 |
|
|
327
|
-
| `mutation` | `process.env.X = …` | high | A6 |
|
|
328
|
-
| `module-access` | `require(…)`, `import(…)`, `globalThis[…]` | **critical** | A6 |
|
|
329
|
-
| `unparseable` | syntax error | medium — and it means the plugin cannot boot | A6 |
|
|
330
|
-
|
|
331
|
-
The two classes with no reach are counted in `facts.jsExpressions` and never raised: a constant, or
|
|
332
|
-
a read of a service the profile already handed the row, warrants no decision, and the shipped
|
|
333
|
-
bundles are mostly made of them.
|
|
334
|
-
|
|
335
|
-
The escalation of the rest is justified: the evaluator is
|
|
336
|
-
`new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, with `ctx`
|
|
337
|
-
in scope. And `disabled` re-evaluates at **every mount decision**, so a `!!js` there is not a
|
|
338
|
-
one-shot: it is a recurring execution point that user patch layers HMR-reload live.
|
|
74
|
+
| **Facts** | No severity, always emitted — what the package declares about itself |
|
|
75
|
+
| **Tier A** | Decidable from a structured declaration. A real verdict. |
|
|
76
|
+
| **Tier B** | AST capability detection — "this plugin *can* do X" |
|
|
77
|
+
| **Tier C** | Heuristic; "we cannot read this" is itself the finding |
|
|
78
|
+
|
|
79
|
+
[Every check, by tier →](https://charlotten7.github.io/dsh-plugin-inspector/checks.html)
|
|
339
80
|
|
|
340
81
|
## The ceiling
|
|
341
82
|
|
|
342
|
-
**This is
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
A seam at which an install *could* be stopped does exist — `dsh plugin add` runs pnpm in the
|
|
349
|
-
profile directory, pnpm honours a `.pnpmfile.cjs` there, and throwing from its async `readPackage`
|
|
350
|
-
hook aborts the install with nothing written to `node_modules`. Nothing in 0.2 uses it.
|
|
351
|
-
[`ADR.md`](./ADR.md) §11 records the seam and why shipping a gate on this release's calibration
|
|
352
|
-
would have burned the idea.
|
|
353
|
-
|
|
354
|
-
### What is not statically decidable
|
|
355
|
-
|
|
356
|
-
1. **`!!js` semantics.** The loader evaluates these with
|
|
357
|
-
`new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, under
|
|
358
|
-
`with (ctx)` scoping. Which identifiers resolve, and to what, depends on the runtime context
|
|
359
|
-
object. This tool reports the expression text and its syntactic class. It cannot tell you what
|
|
360
|
-
the expression will do.
|
|
361
|
-
2. **Transitive dependencies.** One package is read. A clean package with one hostile dependency
|
|
362
|
-
reads as clean. The dependency list is printed as a fact for exactly this reason.
|
|
363
|
-
3. **Runtime-fetched code.** Anything downloaded and evaluated after mount is invisible.
|
|
364
|
-
4. **Post-install mutation of `node_modules`.** The bytes analysed are not guaranteed to be the
|
|
365
|
-
bytes that run.
|
|
366
|
-
5. **A later version acquiring `dsh.bundle`.** Reconciliation is by *installed state*, not by
|
|
367
|
-
dependency diff. A package installed today as a plain library that gains a `dsh.bundle`
|
|
368
|
-
declaration in a patch release is mounted automatically by the next `dsh plugin update`, with
|
|
369
|
-
no notice. This is the most likely real-world bypass, and it means **a verdict is about one
|
|
370
|
-
version and only that version.**
|
|
371
|
-
6. **Intent.** Tier B's `B8` is the sharpest case: the tool proves a package *can* read a
|
|
372
|
-
credential and *can* open a socket. It has not shown that the value flows between them, and it
|
|
373
|
-
cannot — that needs value tracking this tool does not do. Any telemetry library or
|
|
374
|
-
authenticated API client trips `B8` legitimately. It fires on 18 % of published plugins, which
|
|
375
|
-
is why it is `high` and not `critical`.
|
|
376
|
-
7. **Injection phrasing that is not spelled in ASCII.** The injection heuristics are Latin-alphabet
|
|
377
|
-
regexes. Substituting Cyrillic homoglyphs — `о` U+043E for `o`, `е` U+0435 for `e` — defeats
|
|
378
|
-
**every one of the eleven rules**, including the two hidden-character rules, which look for
|
|
379
|
-
invisible characters and not for visible ones that are the wrong letter. Verified against the
|
|
380
|
-
rule table, not assumed. Normalisation is not in 0.2; do not read a clean `A21`/`B10` as
|
|
381
|
-
evidence that shipped markdown carries no instructions.
|
|
382
|
-
|
|
383
|
-
### Every Tier B check has a one-line bypass
|
|
384
|
-
|
|
385
|
-
`ctx['pro' + 'vide']('approval', …)` defeats seam detection. A computed specifier defeats every
|
|
386
|
-
import check. A base64 event name defeats every listener check. Splitting a credential read and a
|
|
387
|
-
network call across two packages defeats `B8`. A Cyrillic `о` defeats every injection rule.
|
|
388
|
-
|
|
389
|
-
**Tier A is much harder to hide from, because it is structured declaration rather than code.**
|
|
390
|
-
The harness must read `disabled: true` literally in order to disable anything, so there is no
|
|
391
|
-
obfuscation that leaves it working. That asymmetry is why Tier A issues verdicts and Tier B
|
|
392
|
-
issues capability reports.
|
|
393
|
-
|
|
394
|
-
### And when the tool cannot read the package
|
|
395
|
-
|
|
396
|
-
If any Tier C check that says something could not be *read* fires, every Tier B confidence drops to
|
|
397
|
-
`moderate`, `analysis.integrity` becomes `degraded`, `analysis.negativesReliable` becomes `false`,
|
|
398
|
-
and the human report is **forbidden from printing "no findings"**. A clean-looking report on a
|
|
399
|
-
minified bundle would be worse than no report, so the tool refuses to produce one.
|
|
400
|
-
|
|
401
|
-
The honest form of a clean result is: *nothing was found at or above the threshold, in the parts
|
|
402
|
-
that could be read.*
|
|
83
|
+
**This is not a malware scanner and it cannot be one.** Capability is decidable from source;
|
|
84
|
+
intent is not. Every Tier B check has a one-line bypass, and the tool says so per finding rather
|
|
85
|
+
than implying a completeness it does not have. What it does guarantee is that it never runs the
|
|
86
|
+
code it analyses — asserted from outside the unit suite by a CI canary whose fixture writes
|
|
87
|
+
sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config and module top level. Any
|
|
88
|
+
sentinel on disk after a full analysis is a release blocker.
|
|
403
89
|
|
|
404
|
-
|
|
90
|
+
[What is not statically decidable →](https://charlotten7.github.io/dsh-plugin-inspector/ceiling.html) ·
|
|
91
|
+
[What it reports on the real ecosystem →](https://charlotten7.github.io/dsh-plugin-inspector/ecosystem.html)
|
|
405
92
|
|
|
406
|
-
|
|
407
|
-
checkout: every registry case injects its own `fetch`, and one of them replaces the global with a
|
|
408
|
-
throwing stub to prove a directory or tarball scan never calls it.
|
|
93
|
+
## Development
|
|
409
94
|
|
|
410
|
-
```
|
|
95
|
+
```sh
|
|
96
|
+
nvm use 22 # Node ^22.19.0 || >=24, and pnpm 11
|
|
411
97
|
pnpm install
|
|
412
98
|
pnpm run typecheck
|
|
413
|
-
pnpm run test
|
|
414
|
-
pnpm run test:
|
|
415
|
-
pnpm run test:e2e # builds, then runs the real binary as a subprocess
|
|
416
|
-
pnpm run inspect <target> # run from source without building
|
|
417
|
-
pnpm run sweep -- --check # the one thing here that DOES use a network
|
|
99
|
+
pnpm run test:coverage
|
|
100
|
+
pnpm run test:e2e
|
|
418
101
|
```
|
|
419
102
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
distribution, and with `--check` exits non-zero when a fresh run is worse than
|
|
423
|
-
`tests/ecosystem-baseline.json`. `--discover` rebuilds the corpus from the most-starred repositories
|
|
424
|
-
carrying the topic; `--pin` moves every entry to the version current now; `--record` rewrites the
|
|
425
|
-
baseline. It runs from its own weekly workflow, never from CI — every other workflow here runs
|
|
426
|
-
without a network, and a unit suite that cannot reach one is easier to trust.
|
|
427
|
-
|
|
428
|
-
Hostile fixtures live in `tests/fixtures/` and are authored here — a plugin that disables the
|
|
429
|
-
approval row, one whose `!!js` calls `child_process`, one with a `postinstall`, one pairing a
|
|
430
|
-
credential read with `fetch`, one shipping a `SKILL.md` full of injection text, one declaring an
|
|
431
|
-
MCP stdio server, a minified one, one using the `!js` tag, one whose bundle patch path escapes the
|
|
432
|
-
package, and a benign control that must produce **zero** findings. They are deliberately hostile
|
|
433
|
-
and structurally inert; [`tests/fixtures/README.md`](./tests/fixtures/README.md) says why, and
|
|
434
|
-
which of them is a live prompt-injection payload you should not copy anywhere.
|
|
435
|
-
|
|
436
|
-
`tests/fixtures/execution-canary/` is the proof that nothing runs: its install scripts, its `!!js`
|
|
437
|
-
expressions, and its module top level all write a sentinel file, and the test asserts the sentinel
|
|
438
|
-
does not exist after a full analysis. `node:child_process` and the write half of `node:fs` are
|
|
439
|
-
mocked to throw for the whole suite, so a stray call fails the tests rather than passing quietly.
|
|
440
|
-
|
|
441
|
-
Design decisions are in [`ADR.md`](./ADR.md); the check catalogue is under
|
|
442
|
-
[What it looks for](#what-it-looks-for).
|
|
443
|
-
|
|
444
|
-
## Reporting a problem
|
|
103
|
+
Severity calibration is pinned against a corpus of published packages, so a change that starts
|
|
104
|
+
firing on ordinary code fails CI rather than shipping.
|
|
445
105
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
ordinary code is a real defect — please open a normal issue for it.
|
|
106
|
+
Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
|
|
107
|
+
[SECURITY.md](SECURITY.md).
|
|
449
108
|
|
|
450
109
|
## License
|
|
451
110
|
|
|
452
|
-
MIT
|
|
111
|
+
MIT
|
package/lib/checks/tier-a.js
CHANGED
|
@@ -103,6 +103,7 @@ function coreRowSeverity(id) {
|
|
|
103
103
|
*/
|
|
104
104
|
function coreRowOrigin(id) {
|
|
105
105
|
const row = CORE_ROWS.get(id);
|
|
106
|
+
/* v8 ignore next -- only called once `coreRowSeverity` has found the id in the same map. */
|
|
106
107
|
if (row === undefined)
|
|
107
108
|
return 'a shipped bundle';
|
|
108
109
|
return row.bundles.map(bundle => `@deepseek-ai/dsh-${bundle}`).join(' and ');
|
|
@@ -205,16 +206,16 @@ function checkOverriddenRows(input) {
|
|
|
205
206
|
const rewritten = override.overriddenKeys.filter(key => key !== 'disabled');
|
|
206
207
|
if (rewritten.length === 0)
|
|
207
208
|
continue;
|
|
208
|
-
const
|
|
209
|
+
const provides = SECURITY_ROW_IDS.get(override.id);
|
|
209
210
|
findings.push(tierA({
|
|
210
211
|
checkId: 'A5',
|
|
211
212
|
name: 'core-row-overridden',
|
|
212
213
|
subject: `${override.id}:${rewritten.join(',')}`,
|
|
213
|
-
severity:
|
|
214
|
+
severity: provides === undefined ? 'medium' : 'high',
|
|
214
215
|
title: `Patch layer rewrites ${rewritten.map(key => `\`${key}\``).join(', ')} on the core row "${override.id}"`,
|
|
215
216
|
detail: `The row is ${coreName}. Patch overrides are shallow whole-value replacements, not merges, so `
|
|
216
217
|
+ `overriding \`config\` discards that row's entire shipped configuration rather than adding to it.`
|
|
217
|
-
+ (
|
|
218
|
+
+ (provides === undefined ? '' : ` This row provides ${provides}.`),
|
|
218
219
|
evidence: { file: patch.file, path: override.path, snippet: snippet(rewritten.join(', ')) },
|
|
219
220
|
}));
|
|
220
221
|
}
|
|
@@ -421,6 +422,7 @@ function checkManifest(input) {
|
|
|
421
422
|
const { manifest, source } = input;
|
|
422
423
|
const lifecycle = INSTALL_LIFECYCLE_SCRIPTS.filter(name => name in manifest.scripts);
|
|
423
424
|
for (const name of lifecycle) {
|
|
425
|
+
/* v8 ignore next -- `name` came from filtering the same object's own keys. */
|
|
424
426
|
const command = manifest.scripts[name] ?? '';
|
|
425
427
|
const signals = LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
|
|
426
428
|
findings.push(tierA({
|
|
@@ -438,8 +440,8 @@ function checkManifest(input) {
|
|
|
438
440
|
+ (signals.length === 0
|
|
439
441
|
? ''
|
|
440
442
|
: ` The command ${signals.map(signal => signal.meaning).join(', and ')}. A build hook runs something `
|
|
441
|
-
+ 'this package shipped
|
|
442
|
-
+ '
|
|
443
|
+
+ 'this package shipped; this one does not. The whole of it is in `package.json`, with no module to '
|
|
444
|
+
+ 'read.'),
|
|
443
445
|
evidence: { file: 'package.json', path: `scripts.${name}`, snippet: snippet(command) },
|
|
444
446
|
}));
|
|
445
447
|
}
|
|
@@ -560,6 +562,7 @@ function checkModelVisibleText(input) {
|
|
|
560
562
|
+ 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
|
|
561
563
|
+ 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
|
|
562
564
|
+ 'something copies it into the user\'s workspace. The text itself is scored separately by B10.',
|
|
565
|
+
/* v8 ignore next -- the caller returns early on an empty list. */
|
|
563
566
|
evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
|
|
564
567
|
})];
|
|
565
568
|
}
|
|
@@ -609,6 +612,7 @@ function checkInjectionText(input) {
|
|
|
609
612
|
const findings = [];
|
|
610
613
|
for (const path of input.modelVisibleFiles) {
|
|
611
614
|
const text = input.source.files.get(path);
|
|
615
|
+
/* v8 ignore next -- `modelVisibleFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
|
|
612
616
|
if (text === undefined)
|
|
613
617
|
continue;
|
|
614
618
|
for (const match of scanInjection(text)) {
|
package/lib/checks/tier-b.js
CHANGED
|
@@ -20,8 +20,35 @@ import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES,
|
|
|
20
20
|
const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
|
|
21
21
|
/** `process.env` keys whose names say they hold a secret. */
|
|
22
22
|
const SECRET_ENV_KEY = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|APIKEY|SESSION)(?:_|$)|API_?KEY|ACCESS_?TOKEN/i;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Filesystem locations that hold credentials.
|
|
25
|
+
*
|
|
26
|
+
* A table rather than one regular expression so each location can be pinned by
|
|
27
|
+
* name: `tests/unit/rule-tables.spec.ts` iterates this export, and a location
|
|
28
|
+
* added without a fixture fails there.
|
|
29
|
+
*/
|
|
30
|
+
export const CREDENTIAL_PATHS = [
|
|
31
|
+
{ id: 'npmrc', pattern: String.raw `\.npmrc` },
|
|
32
|
+
{ id: 'netrc', pattern: String.raw `\.netrc` },
|
|
33
|
+
{ id: 'ssh-directory', pattern: String.raw `\.ssh\/` },
|
|
34
|
+
{ id: 'ssh-key-rsa', pattern: 'id_rsa' },
|
|
35
|
+
{ id: 'ssh-key-ed25519', pattern: 'id_ed25519' },
|
|
36
|
+
{ id: 'aws-directory', pattern: String.raw `\.aws\/` },
|
|
37
|
+
{ id: 'docker-config', pattern: String.raw `\.docker\/config\.json` },
|
|
38
|
+
{ id: 'git-credentials', pattern: String.raw `\.git-credentials` },
|
|
39
|
+
{ id: 'service-account-json', pattern: String.raw `credentials\.json` },
|
|
40
|
+
{ id: 'dsh-credentials', pattern: String.raw `\.dsh\/credentials` },
|
|
41
|
+
{ id: 'dotenv', pattern: String.raw `\.env(?:\.[a-z]+)?$` },
|
|
42
|
+
];
|
|
43
|
+
const CREDENTIAL_PATH = new RegExp(`(?:${CREDENTIAL_PATHS.map(path => path.pattern).join('|')})`, 'i');
|
|
44
|
+
/**
|
|
45
|
+
* Whether a string names a location that holds credentials.
|
|
46
|
+
* @param text - the literal text of a string in shipped source.
|
|
47
|
+
* @returns true when it names one of {@link CREDENTIAL_PATHS}.
|
|
48
|
+
*/
|
|
49
|
+
export function matchesCredentialPath(text) {
|
|
50
|
+
return CREDENTIAL_PATH.test(text);
|
|
51
|
+
}
|
|
25
52
|
/** Members of `ctx` that construct or evaluate code, or mount further plugins. */
|
|
26
53
|
const DYNAMIC_CODE_CALLEES = new Set([
|
|
27
54
|
'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
|
|
@@ -71,6 +98,7 @@ function moduleSpecifiers(file) {
|
|
|
71
98
|
const visit = (node) => {
|
|
72
99
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined) {
|
|
73
100
|
const text = literalText(node.moduleSpecifier);
|
|
101
|
+
/* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
|
|
74
102
|
if (text !== null)
|
|
75
103
|
found.push({ specifier: text, node });
|
|
76
104
|
}
|
|
@@ -298,7 +326,7 @@ function checkCredentialRead(file, node, accumulator) {
|
|
|
298
326
|
subject = `env:${key}`;
|
|
299
327
|
}
|
|
300
328
|
}
|
|
301
|
-
if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) &&
|
|
329
|
+
if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && matchesCredentialPath(node.text)) {
|
|
302
330
|
title = `References the credential location \`${node.text}\``;
|
|
303
331
|
subject = `path:${node.text}`;
|
|
304
332
|
}
|
|
@@ -375,6 +403,7 @@ export function runTierB(input) {
|
|
|
375
403
|
const accumulator = { findings: [], credentialRead: null, networkCall: null };
|
|
376
404
|
for (const path of input.sourceFiles) {
|
|
377
405
|
const text = input.source.files.get(path);
|
|
406
|
+
/* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
|
|
378
407
|
if (text === undefined)
|
|
379
408
|
continue;
|
|
380
409
|
const file = {
|
|
@@ -450,15 +479,18 @@ function pairFinding(accumulator) {
|
|
|
450
479
|
// finding's own text says it is not a verdict. A severity that says "do not
|
|
451
480
|
// treat this as a verdict" cannot be the top one.
|
|
452
481
|
const severity = 'high';
|
|
482
|
+
/* v8 ignore start -- `at()` records a line and column for every finding these two come from. */
|
|
483
|
+
const credentialSite = `${credential.evidence.file}:${credential.evidence.path ?? '?'}`;
|
|
484
|
+
const networkSite = `${network.evidence.file}:${network.evidence.path ?? '?'}`;
|
|
485
|
+
/* v8 ignore stop */
|
|
453
486
|
return tierB({
|
|
454
487
|
checkId: 'B8',
|
|
455
488
|
name: 'exfiltration-capability',
|
|
456
489
|
subject: 'credential-and-egress',
|
|
457
490
|
severity,
|
|
458
491
|
title: 'This package can read a credential and can make a network call',
|
|
459
|
-
detail:
|
|
460
|
-
+
|
|
461
|
-
+ `${network.evidence.file}:${network.evidence.path ?? '?'}. It has NOT shown that the credential value `
|
|
492
|
+
detail: `This is a capability, not a dataflow. The tool found a credential read at ${credentialSite} `
|
|
493
|
+
+ `and a network call at ${networkSite}. It has NOT shown that the credential value `
|
|
462
494
|
+ 'reaches the request, and it cannot: proving that needs value tracking this tool does not do. Many '
|
|
463
495
|
+ 'legitimate packages — any telemetry or authenticated API client — trip this pair for good reasons. Treat '
|
|
464
496
|
+ 'it as a prompt to read those two sites, not as a verdict.',
|
package/lib/checks/tier-c.js
CHANGED
|
@@ -40,6 +40,7 @@ function checkMinification(input) {
|
|
|
40
40
|
const findings = [];
|
|
41
41
|
for (const path of input.sourceFiles) {
|
|
42
42
|
const text = input.source.files.get(path);
|
|
43
|
+
/* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
|
|
43
44
|
if (text === undefined)
|
|
44
45
|
continue;
|
|
45
46
|
const lines = text.split('\n');
|
|
@@ -64,6 +65,7 @@ function checkMinification(input) {
|
|
|
64
65
|
+ `that long are ${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability `
|
|
65
66
|
+ 'detection reads syntax, and it reads minified syntax no better than a person does. Every Tier B '
|
|
66
67
|
+ 'negative for this package is unreliable while a file like this is in it.',
|
|
68
|
+
/* v8 ignore next -- `split` returns at least one element for any string, so the fallback is unreachable. */
|
|
67
69
|
evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
|
|
68
70
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
69
71
|
}));
|
|
@@ -75,6 +77,7 @@ function checkDynamicDispatch(input) {
|
|
|
75
77
|
const findings = [];
|
|
76
78
|
for (const path of input.sourceFiles) {
|
|
77
79
|
const text = input.source.files.get(path);
|
|
80
|
+
/* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
|
|
78
81
|
if (text === undefined)
|
|
79
82
|
continue;
|
|
80
83
|
const source = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
|
@@ -156,9 +159,11 @@ function isDispatchReceiver(node) {
|
|
|
156
159
|
function receiverName(node) {
|
|
157
160
|
if (ts.isIdentifier(node))
|
|
158
161
|
return node.text;
|
|
162
|
+
/* v8 ignore start -- only called after `isDispatchReceiver`, which accepts these two forms and no other. */
|
|
159
163
|
if (ts.isPropertyAccessExpression(node))
|
|
160
164
|
return node.name.text;
|
|
161
165
|
return '?';
|
|
166
|
+
/* v8 ignore stop */
|
|
162
167
|
}
|
|
163
168
|
/**
|
|
164
169
|
* Whether a node builds a string at runtime rather than naming one. A plain
|
|
@@ -217,6 +222,7 @@ function checkSourcelessBuild(input) {
|
|
|
217
222
|
detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
|
|
218
223
|
+ 'package to check the build against. Whether the source that produced it matches the repository is not '
|
|
219
224
|
+ 'decidable from here.',
|
|
225
|
+
/* v8 ignore next -- guarded by `built.length > 0` two lines above. */
|
|
220
226
|
evidence: { file: built[0] ?? '', snippet: snippet(built.slice(0, 5).join(', ')) },
|
|
221
227
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
222
228
|
}));
|
|
@@ -255,6 +261,7 @@ function checkUnreadableFiles(input) {
|
|
|
255
261
|
? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
|
|
256
262
|
+ 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
|
|
257
263
|
: 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
|
|
264
|
+
/* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
|
|
258
265
|
evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
|
|
259
266
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
260
267
|
}));
|
|
@@ -278,6 +285,23 @@ function checkPatchWalkLimit(input) {
|
|
|
278
285
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
279
286
|
}));
|
|
280
287
|
}
|
|
288
|
+
/** C7 — a patch layer whose rows are assembled out of YAML anchors and aliases. */
|
|
289
|
+
function checkPatchAliases(input) {
|
|
290
|
+
return input.patches.filter(patch => patch.aliased).map(patch => tierC({
|
|
291
|
+
checkId: 'C7',
|
|
292
|
+
name: 'patch-uses-aliases',
|
|
293
|
+
subject: patch.file,
|
|
294
|
+
severity: 'medium',
|
|
295
|
+
title: `\`${patch.file}\` builds rows out of YAML anchors and aliases`,
|
|
296
|
+
detail: 'An alias is not a copy: `*a` hands the loader the same node again, so one row in the file can be two '
|
|
297
|
+
+ 'rows in the composed profile, and the row a reader sees under an inert key can be the row that lands in a '
|
|
298
|
+
+ 'live one. This tool expands every alias to its own node before reading the layer, which is what makes the '
|
|
299
|
+
+ 'reading match the loader — but the layer a person reviews and the layer that mounts are no longer the same '
|
|
300
|
+
+ 'document, and no Tier B negative about this package is claimed while that is true.',
|
|
301
|
+
evidence: { file: patch.file },
|
|
302
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
281
305
|
/**
|
|
282
306
|
* Run every Tier C check.
|
|
283
307
|
* @param input - the decoded package.
|
|
@@ -290,5 +314,6 @@ export function runTierC(input) {
|
|
|
290
314
|
...checkSourcelessBuild(input),
|
|
291
315
|
...checkUnreadableFiles(input),
|
|
292
316
|
...checkPatchWalkLimit(input),
|
|
317
|
+
...checkPatchAliases(input),
|
|
293
318
|
];
|
|
294
319
|
}
|
package/lib/cli.js
CHANGED
|
@@ -80,6 +80,7 @@ export function parseArgs(argv) {
|
|
|
80
80
|
return next;
|
|
81
81
|
};
|
|
82
82
|
for (let index = 0; index < argv.length; index += 1) {
|
|
83
|
+
/* v8 ignore next -- `index` is bounded by the loop condition. */
|
|
83
84
|
const argument = argv[index] ?? '';
|
|
84
85
|
if (argument === '--help' || argument === '-h') {
|
|
85
86
|
process.stdout.write(USAGE);
|
|
@@ -152,6 +153,7 @@ export async function main(argv) {
|
|
|
152
153
|
options = parseArgs(argv);
|
|
153
154
|
}
|
|
154
155
|
catch (error) {
|
|
156
|
+
/* v8 ignore next -- `parseArgs` refuses a command line only with a UsageError. */
|
|
155
157
|
process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
|
|
156
158
|
return EXIT.unanalysable;
|
|
157
159
|
}
|
|
@@ -165,6 +167,7 @@ export async function main(argv) {
|
|
|
165
167
|
return exceedsThreshold(report, options.failOn) ? EXIT.findings : EXIT.clean;
|
|
166
168
|
}
|
|
167
169
|
catch (error) {
|
|
170
|
+
/* v8 ignore next -- every refusal on the read path is a SourceError, ManifestError or RegistryError. */
|
|
168
171
|
process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
169
172
|
return EXIT.unanalysable;
|
|
170
173
|
}
|
|
@@ -187,6 +190,7 @@ export function reportFatal(error) {
|
|
|
187
190
|
process.stderr.write(`dsh-inspect: the analysis could not be completed: ${message}\n`);
|
|
188
191
|
return EXIT.unanalysable;
|
|
189
192
|
}
|
|
193
|
+
/* v8 ignore start -- the process entry, exercised by tests/e2e/cli.e2e.ts against the built CLI rather than by the instrumented unit run. */
|
|
190
194
|
if (import.meta.main) {
|
|
191
195
|
process.on('uncaughtException', (error) => {
|
|
192
196
|
process.exit(reportFatal(error));
|
|
@@ -196,3 +200,4 @@ if (import.meta.main) {
|
|
|
196
200
|
});
|
|
197
201
|
process.exitCode = await main(process.argv.slice(2));
|
|
198
202
|
}
|
|
203
|
+
/* v8 ignore stop */
|
package/lib/cordis-yaml.js
CHANGED
|
@@ -28,6 +28,7 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
|
|
28
28
|
kind: 'scalar',
|
|
29
29
|
resolve: data => typeof data === 'string',
|
|
30
30
|
construct: (data) => {
|
|
31
|
+
/* v8 ignore next -- js-yaml calls `construct` only for data its `resolve` accepted, which is a string. */
|
|
31
32
|
if (typeof data !== 'string')
|
|
32
33
|
throw new TypeError('!!js requires a scalar string');
|
|
33
34
|
return { __jsExpr: data };
|
|
@@ -44,35 +45,77 @@ export const patchSchema = yaml.JSON_SCHEMA.extend(jsExprType);
|
|
|
44
45
|
export const EXPRESSION_CLASSES = [
|
|
45
46
|
'literal', 'inert-read', 'harness-call', 'call', 'mutation', 'module-access', 'unparseable',
|
|
46
47
|
];
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The two ceilings that make reading a patch layer terminate.
|
|
50
|
+
*
|
|
51
|
+
* They apply to {@link expandAliases}, and through it to everything downstream:
|
|
52
|
+
* the walk runs over the tree the expansion produced, which holds at most
|
|
53
|
+
* `MAX_WALK_NODES` nodes nested at most `MAX_WALK_DEPTH` deep, so the walk needs
|
|
54
|
+
* no ceiling of its own.
|
|
55
|
+
*/
|
|
48
56
|
export const MAX_WALK_NODES = 200_000;
|
|
49
|
-
/** Nesting one patch layer may reach before the
|
|
57
|
+
/** Nesting one patch layer may reach before the reader gives up. */
|
|
50
58
|
export const MAX_WALK_DEPTH = 200;
|
|
51
59
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Materialise a parsed patch layer's alias graph as a tree, so that every
|
|
61
|
+
* occurrence of an anchored node is a distinct node at its own path.
|
|
62
|
+
*
|
|
63
|
+
* js-yaml resolves `*a` to the *same JavaScript object* as `&a`, not to a copy.
|
|
64
|
+
* A reader that walks the result as a graph and skips an object it has already
|
|
65
|
+
* seen therefore attributes an anchored node to whichever position it reached
|
|
66
|
+
* first and drops every other one — which is how a row anchored in an inert
|
|
67
|
+
* `inject:` slot and aliased into a real patch slot came to be read as inert
|
|
68
|
+
* and analysed no further. The loader has no such notion: `interpolate` in
|
|
69
|
+
* `vendor/loader/src/config/utils.ts` maps over arrays and objects as a tree
|
|
70
|
+
* and evaluates each occurrence it reaches, and `applyEntryPatches` reads each
|
|
71
|
+
* element of the patch list on its own. Expanding first makes this module agree
|
|
72
|
+
* with both.
|
|
73
|
+
*
|
|
74
|
+
* The expansion is bounded, which is what keeps an alias bomb from becoming a
|
|
75
|
+
* hang: a 475-byte file can describe 100 nodes with 31 billion paths through
|
|
76
|
+
* them, and materialising those paths is exactly the non-terminating walk the
|
|
77
|
+
* ceilings exist to stop. Past either ceiling the subtree becomes `null` and
|
|
78
|
+
* the limit is recorded, so a truncated read is reported rather than presented
|
|
79
|
+
* as a complete one.
|
|
80
|
+
* @param entries - the entry list js-yaml returned.
|
|
81
|
+
* @returns the expanded entries, whether an alias was used, and the ceiling hit.
|
|
57
82
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
function expandAliases(entries) {
|
|
84
|
+
const state = { aliased: false, nodes: 1, limit: null };
|
|
85
|
+
const seen = new WeakSet([entries]);
|
|
86
|
+
const expand = (value, depth) => {
|
|
87
|
+
if (typeof value !== 'object' || value === null)
|
|
88
|
+
return value;
|
|
89
|
+
if (state.limit !== null)
|
|
90
|
+
return null;
|
|
91
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
92
|
+
state.limit = 'depth';
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
state.nodes += 1;
|
|
96
|
+
if (state.nodes > MAX_WALK_NODES) {
|
|
97
|
+
state.limit = 'nodes';
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (seen.has(value))
|
|
101
|
+
state.aliased = true;
|
|
102
|
+
else
|
|
103
|
+
seen.add(value);
|
|
104
|
+
if (Array.isArray(value))
|
|
105
|
+
return value.map(item => expand(item, depth + 1));
|
|
106
|
+
const clone = {};
|
|
107
|
+
for (const [key, child] of Object.entries(value)) {
|
|
108
|
+
// Assignment would invoke the `__proto__` setter, and js-yaml keeps a
|
|
109
|
+
// `__proto__` key from the document as an own property precisely so that
|
|
110
|
+
// it stays data. Defining the property keeps it data here too.
|
|
111
|
+
Object.defineProperty(clone, key, {
|
|
112
|
+
value: expand(child, depth + 1), enumerable: true, writable: true, configurable: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return clone;
|
|
116
|
+
};
|
|
117
|
+
const expanded = entries.map(entry => expand(entry, 1));
|
|
118
|
+
return { entries: expanded, aliased: state.aliased, limit: state.limit };
|
|
76
119
|
}
|
|
77
120
|
/** Thrown when the patch file cannot be parsed as an entry list. */
|
|
78
121
|
export class PatchParseError extends Error {
|
|
@@ -126,6 +169,7 @@ export function classifyExpression(expression) {
|
|
|
126
169
|
new Function(`return (${expression})`);
|
|
127
170
|
}
|
|
128
171
|
catch (error) {
|
|
172
|
+
/* v8 ignore next -- the Function constructor rejects a body only with a SyntaxError. */
|
|
129
173
|
return { class: 'unparseable', parseError: error instanceof Error ? error.message : String(error) };
|
|
130
174
|
}
|
|
131
175
|
const source = ts.createSourceFile('expr.ts', `(${expression})`, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
|
|
@@ -218,8 +262,6 @@ function isInertCall(node) {
|
|
|
218
262
|
* @param depth - the current nesting depth.
|
|
219
263
|
*/
|
|
220
264
|
function collect(value, path, slot, sink, depth) {
|
|
221
|
-
if (!admit(sink.budget, value, depth))
|
|
222
|
-
return;
|
|
223
265
|
if (isJsExpr(value)) {
|
|
224
266
|
const classified = classifyExpression(value.__jsExpr);
|
|
225
267
|
sink.expressions.push({
|
|
@@ -294,8 +336,6 @@ function isTreeCarrier(entry) {
|
|
|
294
336
|
function walkRow(value, path, intoGroupId, sink, depth) {
|
|
295
337
|
if (!isRecord(value))
|
|
296
338
|
return;
|
|
297
|
-
if (!admit(sink.budget, value, depth))
|
|
298
|
-
return;
|
|
299
339
|
const carrier = isTreeCarrier(value);
|
|
300
340
|
sink.inserts.push({
|
|
301
341
|
path,
|
|
@@ -334,8 +374,6 @@ function walkPatchList(list, prefix, sink, depth) {
|
|
|
334
374
|
const path = `${prefix}[${index}]`;
|
|
335
375
|
if (!isRecord(patch))
|
|
336
376
|
continue;
|
|
337
|
-
if (!admit(sink.budget, patch, depth))
|
|
338
|
-
continue;
|
|
339
377
|
const id = typeof patch.id === 'string' ? patch.id : null;
|
|
340
378
|
if (Array.isArray(patch.insert)) {
|
|
341
379
|
for (const [rowIndex, row] of patch.insert.entries()) {
|
|
@@ -365,29 +403,27 @@ function walkPatchList(list, prefix, sink, depth) {
|
|
|
365
403
|
* @throws PatchParseError when the text is not a loadable entry list.
|
|
366
404
|
*/
|
|
367
405
|
export function parsePatchDocument(file, text) {
|
|
368
|
-
let
|
|
406
|
+
let loaded;
|
|
369
407
|
try {
|
|
370
|
-
|
|
408
|
+
loaded = yaml.load(text, { schema: patchSchema });
|
|
371
409
|
}
|
|
372
410
|
catch (error) {
|
|
411
|
+
/* v8 ignore next -- js-yaml rejects a document only with a YAMLException. */
|
|
373
412
|
const message = error instanceof Error ? error.message : String(error);
|
|
374
413
|
throw new PatchParseError(message, /(?<!!)!js(?![a-zA-Z0-9_-])/.test(text));
|
|
375
414
|
}
|
|
376
|
-
if (!Array.isArray(
|
|
415
|
+
if (!Array.isArray(loaded)) {
|
|
377
416
|
throw new PatchParseError('a patch layer must be a top-level array of entries', false);
|
|
378
417
|
}
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
expressions: [],
|
|
383
|
-
budget: { visited: new WeakSet(), nodes: 0, limit: null },
|
|
384
|
-
};
|
|
385
|
-
walkPatchList(document, '', sink, 0);
|
|
418
|
+
const expansion = expandAliases(loaded);
|
|
419
|
+
const sink = { overrides: [], inserts: [], expressions: [] };
|
|
420
|
+
walkPatchList(expansion.entries, '', sink, 0);
|
|
386
421
|
return {
|
|
387
422
|
file,
|
|
388
423
|
overrides: sink.overrides,
|
|
389
424
|
inserts: sink.inserts,
|
|
390
425
|
expressions: sink.expressions,
|
|
391
|
-
limit:
|
|
426
|
+
limit: expansion.limit,
|
|
427
|
+
aliased: expansion.aliased,
|
|
392
428
|
};
|
|
393
429
|
}
|
package/lib/files.js
CHANGED
|
@@ -34,6 +34,7 @@ export function isSourceFile(path) {
|
|
|
34
34
|
*/
|
|
35
35
|
export function isModelVisibleText(path) {
|
|
36
36
|
const segments = path.split('/');
|
|
37
|
+
/* v8 ignore next -- `split` returns at least one element for any string. */
|
|
37
38
|
const base = segments.at(-1) ?? '';
|
|
38
39
|
if (base === 'SKILL.md' || base === 'AGENTS.md' || base === 'CLAUDE.md')
|
|
39
40
|
return true;
|
|
@@ -49,6 +50,7 @@ export function isModelVisibleText(path) {
|
|
|
49
50
|
* @returns true for a cordis YAML file.
|
|
50
51
|
*/
|
|
51
52
|
export function isCordisConfigFile(path) {
|
|
53
|
+
/* v8 ignore next -- `split` returns at least one element for any string. */
|
|
52
54
|
const base = path.split('/').at(-1) ?? '';
|
|
53
55
|
return /cordis/.test(base) && (base.endsWith('.yml') || base.endsWith('.yaml'));
|
|
54
56
|
}
|
package/lib/inspect.js
CHANGED
|
@@ -108,11 +108,13 @@ export function analyze(source, registry) {
|
|
|
108
108
|
const patches = [];
|
|
109
109
|
const patchFailures = [];
|
|
110
110
|
if (mounted !== null) {
|
|
111
|
+
/* v8 ignore next -- `mounted` is non-null only when `source.files` holds that key. */
|
|
111
112
|
const text = source.files.get(mounted) ?? '';
|
|
112
113
|
try {
|
|
113
114
|
patches.push(parsePatchDocument(mounted, text));
|
|
114
115
|
}
|
|
115
116
|
catch (error) {
|
|
117
|
+
/* v8 ignore next -- `parsePatchDocument` reports every refusal as a PatchParseError. */
|
|
116
118
|
if (!(error instanceof PatchParseError))
|
|
117
119
|
throw error;
|
|
118
120
|
patchFailures.push({ file: mounted, error });
|
package/lib/knowledge.js
CHANGED
|
@@ -295,12 +295,11 @@ export const INSTALL_LIFECYCLE_SCRIPTS = [
|
|
|
295
295
|
* Command shapes that make an install lifecycle script the attack rather than
|
|
296
296
|
* the build.
|
|
297
297
|
*
|
|
298
|
-
* The
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* command
|
|
303
|
-
* to read.
|
|
298
|
+
* The case this table is for is the one where the command line itself fetches,
|
|
299
|
+
* decodes, or evaluates: the whole attack sits in `package.json` and there is
|
|
300
|
+
* no shipped module to read. A lifecycle hook alone does not distinguish that
|
|
301
|
+
* from a build, which is why the hook is a category at `medium` and only the
|
|
302
|
+
* command raises it.
|
|
304
303
|
*
|
|
305
304
|
* Each pattern is chosen against the measured false-positive side rather than
|
|
306
305
|
* against the idea of a build script. The five packages in the pinned corpus
|
package/lib/manifest.js
CHANGED
|
@@ -103,6 +103,7 @@ export function parseManifest(text) {
|
|
|
103
103
|
parsed = JSON.parse(text);
|
|
104
104
|
}
|
|
105
105
|
catch (error) {
|
|
106
|
+
/* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
|
|
106
107
|
throw new ManifestError(`package.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
108
|
}
|
|
108
109
|
if (!isRecord(parsed))
|
package/lib/publish.js
CHANGED
|
@@ -54,6 +54,7 @@ function matchSegments(pattern, path) {
|
|
|
54
54
|
}
|
|
55
55
|
if (s === path.length)
|
|
56
56
|
return false;
|
|
57
|
+
/* v8 ignore next -- both indices are checked against their lengths above. */
|
|
57
58
|
if (!matchSegment(pattern[p] ?? '', path[s] ?? ''))
|
|
58
59
|
return false;
|
|
59
60
|
return step(p + 1, s + 1);
|
|
@@ -152,6 +153,7 @@ function ignoreMatches(rule, path) {
|
|
|
152
153
|
return true;
|
|
153
154
|
continue;
|
|
154
155
|
}
|
|
156
|
+
/* v8 ignore next -- `end` runs from 1 to `segments.length`, so the index is in range. */
|
|
155
157
|
if (globMatch(rule.pattern, segments[end - 1] ?? ''))
|
|
156
158
|
return true;
|
|
157
159
|
}
|
package/lib/registry.js
CHANGED
|
@@ -127,6 +127,7 @@ export async function resolvePackage(spec, options = {}) {
|
|
|
127
127
|
document = JSON.parse(body.toString('utf8'));
|
|
128
128
|
}
|
|
129
129
|
catch (error) {
|
|
130
|
+
/* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
|
|
130
131
|
throw new RegistryError(`${url} did not return JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
132
|
}
|
|
132
133
|
const record = asRecord(document);
|
package/lib/report.js
CHANGED
|
@@ -91,6 +91,7 @@ function wrap(text, width) {
|
|
|
91
91
|
current = word;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/* v8 ignore next -- every wrapped string is a finding's detail, and none is empty. */
|
|
94
95
|
if (current !== '')
|
|
95
96
|
lines.push(current);
|
|
96
97
|
return lines;
|
|
@@ -144,9 +145,11 @@ function renderFacts(report, paint) {
|
|
|
144
145
|
? 'yes — the registry marks this package as running one at install time'
|
|
145
146
|
: 'no — the registry does not mark this package as running one'],
|
|
146
147
|
],
|
|
148
|
+
/* v8 ignore start -- `mountsAsBundle` is true exactly when the manifest declared a path. */
|
|
147
149
|
['mounted layer', facts.mountsAsBundle
|
|
148
150
|
? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
|
|
149
151
|
: 'no — installs as a plain library, and dsh plugin add prints a warning saying so'],
|
|
152
|
+
/* v8 ignore stop */
|
|
150
153
|
['browser bundle', facts.shipsClientBundle ? 'yes — dsh.client with an ./client export, executed in the user\'s browser' : 'no'],
|
|
151
154
|
['rows inserted', facts.insertedRows.length === 0
|
|
152
155
|
? 'none'
|
|
@@ -176,7 +179,9 @@ function renderFacts(report, paint) {
|
|
|
176
179
|
* @returns the rendered text, ending in a newline.
|
|
177
180
|
*/
|
|
178
181
|
export function renderHuman(report, color) {
|
|
182
|
+
/* v8 ignore start -- every call site passes a literal key of COLOR. */
|
|
179
183
|
const paint = (code, text) => color ? `${COLOR[code] ?? ''}${text}${COLOR.reset}` : text;
|
|
184
|
+
/* v8 ignore stop */
|
|
180
185
|
const lines = ['', ...renderFacts(report, paint)];
|
|
181
186
|
if (report.findings.length > 0) {
|
|
182
187
|
const counts = SEVERITIES
|
package/lib/source.js
CHANGED
|
@@ -31,11 +31,19 @@ export const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
|
31
31
|
export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
32
32
|
/** Largest number of files the analyzer will consider. */
|
|
33
33
|
export const MAX_ENTRIES = 10_000;
|
|
34
|
+
/**
|
|
35
|
+
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
36
|
+
*
|
|
37
|
+
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
38
|
+
* one that is has already answered the only question worth asking about it.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_STREAM_BYTES = 8 * MAX_TOTAL_BYTES;
|
|
34
41
|
/** The shipping ceilings. Tests substitute smaller ones to exercise each cap. */
|
|
35
42
|
export const DEFAULT_LIMITS = {
|
|
36
43
|
maxFileBytes: MAX_FILE_BYTES,
|
|
37
44
|
maxTotalBytes: MAX_TOTAL_BYTES,
|
|
38
45
|
maxEntries: MAX_ENTRIES,
|
|
46
|
+
maxStreamBytes: MAX_STREAM_BYTES,
|
|
39
47
|
};
|
|
40
48
|
/** Directories never descended into: not shipped, and not the package's own code. */
|
|
41
49
|
const SKIPPED_DIRECTORIES = new Set([
|
|
@@ -76,6 +84,7 @@ function countEntry(collector, path) {
|
|
|
76
84
|
* @param buffer - the file content.
|
|
77
85
|
*/
|
|
78
86
|
function store(collector, path, buffer) {
|
|
87
|
+
/* v8 ignore next 4 -- both callers check the size before reading; this is the same ceiling held at the last point that could still allocate. */
|
|
79
88
|
if (buffer.byteLength > collector.limits.maxFileBytes) {
|
|
80
89
|
collector.skipped.push({ path, reason: 'size-cap' });
|
|
81
90
|
return;
|
|
@@ -170,6 +179,7 @@ function stripRoot(entryPath) {
|
|
|
170
179
|
if (slash < 0)
|
|
171
180
|
return undefined;
|
|
172
181
|
const remainder = normalized.slice(slash + 1);
|
|
182
|
+
/* v8 ignore next -- an entry name ending in `/` is a directory entry, which the parser never hands to this. */
|
|
173
183
|
if (remainder === '')
|
|
174
184
|
return undefined;
|
|
175
185
|
const segments = [];
|
|
@@ -186,13 +196,6 @@ function stripRoot(entryPath) {
|
|
|
186
196
|
}
|
|
187
197
|
return segments.length === 0 ? undefined : segments.join('/');
|
|
188
198
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
191
|
-
*
|
|
192
|
-
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
193
|
-
* one that is has already answered the only question worth asking about it.
|
|
194
|
-
*/
|
|
195
|
-
export const MAX_STREAM_BYTES = 8 * MAX_TOTAL_BYTES;
|
|
196
199
|
/**
|
|
197
200
|
* A stage that fails the pipeline once the decompressed stream passes a
|
|
198
201
|
* ceiling. Nothing downstream keeps the bytes, but *producing* eight gigabytes
|
|
@@ -308,7 +311,7 @@ async function readTarStream(bytes, gzipped, collector) {
|
|
|
308
311
|
},
|
|
309
312
|
});
|
|
310
313
|
const inflate = gzipped ? createGunzip() : new PassThrough();
|
|
311
|
-
await pipeline(bytes, inflate, byteCeiling(
|
|
314
|
+
await pipeline(bytes, inflate, byteCeiling(collector.limits.maxStreamBytes), parser);
|
|
312
315
|
await Promise.all(pending);
|
|
313
316
|
if (failure !== null)
|
|
314
317
|
throw failure;
|
|
@@ -14,6 +14,26 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Finding } from '../model.ts';
|
|
16
16
|
import type { CheckInput } from './input.ts';
|
|
17
|
+
/** One filesystem location that holds credentials, and how it is spelled. */
|
|
18
|
+
export interface CredentialPath {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** Pattern source, matched case-insensitively anywhere in a string literal. */
|
|
21
|
+
readonly pattern: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Filesystem locations that hold credentials.
|
|
25
|
+
*
|
|
26
|
+
* A table rather than one regular expression so each location can be pinned by
|
|
27
|
+
* name: `tests/unit/rule-tables.spec.ts` iterates this export, and a location
|
|
28
|
+
* added without a fixture fails there.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CREDENTIAL_PATHS: readonly CredentialPath[];
|
|
31
|
+
/**
|
|
32
|
+
* Whether a string names a location that holds credentials.
|
|
33
|
+
* @param text - the literal text of a string in shipped source.
|
|
34
|
+
* @returns true when it names one of {@link CREDENTIAL_PATHS}.
|
|
35
|
+
*/
|
|
36
|
+
export declare function matchesCredentialPath(text: string): boolean;
|
|
17
37
|
/**
|
|
18
38
|
* Run every Tier B check.
|
|
19
39
|
* @param input - the decoded package.
|
|
@@ -82,10 +82,23 @@ export interface PatchDocument {
|
|
|
82
82
|
* means the layer was read in part, which Tier C reports.
|
|
83
83
|
*/
|
|
84
84
|
readonly limit: WalkLimit;
|
|
85
|
+
/**
|
|
86
|
+
* True when the layer reached at least one node twice, which is what a YAML
|
|
87
|
+
* alias does and what nothing else does. Tier C reports it, because a reader
|
|
88
|
+
* of the file sees one row where the loader sees two.
|
|
89
|
+
*/
|
|
90
|
+
readonly aliased: boolean;
|
|
85
91
|
}
|
|
86
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* The two ceilings that make reading a patch layer terminate.
|
|
94
|
+
*
|
|
95
|
+
* They apply to {@link expandAliases}, and through it to everything downstream:
|
|
96
|
+
* the walk runs over the tree the expansion produced, which holds at most
|
|
97
|
+
* `MAX_WALK_NODES` nodes nested at most `MAX_WALK_DEPTH` deep, so the walk needs
|
|
98
|
+
* no ceiling of its own.
|
|
99
|
+
*/
|
|
87
100
|
export declare const MAX_WALK_NODES = 200000;
|
|
88
|
-
/** Nesting one patch layer may reach before the
|
|
101
|
+
/** Nesting one patch layer may reach before the reader gives up. */
|
|
89
102
|
export declare const MAX_WALK_DEPTH = 200;
|
|
90
103
|
/** Thrown when the patch file cannot be parsed as an entry list. */
|
|
91
104
|
export declare class PatchParseError extends Error {
|
package/lib/types/knowledge.d.ts
CHANGED
|
@@ -121,12 +121,11 @@ export interface LifecycleSignal {
|
|
|
121
121
|
* Command shapes that make an install lifecycle script the attack rather than
|
|
122
122
|
* the build.
|
|
123
123
|
*
|
|
124
|
-
* The
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* command
|
|
129
|
-
* to read.
|
|
124
|
+
* The case this table is for is the one where the command line itself fetches,
|
|
125
|
+
* decodes, or evaluates: the whole attack sits in `package.json` and there is
|
|
126
|
+
* no shipped module to read. A lifecycle hook alone does not distinguish that
|
|
127
|
+
* from a build, which is why the hook is a category at `medium` and only the
|
|
128
|
+
* command raises it.
|
|
130
129
|
*
|
|
131
130
|
* Each pattern is chosen against the measured false-positive side rather than
|
|
132
131
|
* against the idea of a build script. The five packages in the pinned corpus
|
package/lib/types/source.d.ts
CHANGED
|
@@ -26,11 +26,20 @@ export declare const MAX_FILE_BYTES: number;
|
|
|
26
26
|
export declare const MAX_TOTAL_BYTES: number;
|
|
27
27
|
/** Largest number of files the analyzer will consider. */
|
|
28
28
|
export declare const MAX_ENTRIES = 10000;
|
|
29
|
+
/**
|
|
30
|
+
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
31
|
+
*
|
|
32
|
+
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
33
|
+
* one that is has already answered the only question worth asking about it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const MAX_STREAM_BYTES: number;
|
|
29
36
|
/** The resource ceilings one read runs under. */
|
|
30
37
|
export interface ReadLimits {
|
|
31
38
|
readonly maxFileBytes: number;
|
|
32
39
|
readonly maxTotalBytes: number;
|
|
33
40
|
readonly maxEntries: number;
|
|
41
|
+
/** Decompressed tar bytes one tarball may produce before the read is abandoned. */
|
|
42
|
+
readonly maxStreamBytes: number;
|
|
34
43
|
}
|
|
35
44
|
/** The shipping ceilings. Tests substitute smaller ones to exercise each cap. */
|
|
36
45
|
export declare const DEFAULT_LIMITS: ReadLimits;
|
|
@@ -58,13 +67,6 @@ export interface PluginSource {
|
|
|
58
67
|
/** Working-tree files npm would not publish, and which were therefore not read. */
|
|
59
68
|
readonly unpublishedFiles: number;
|
|
60
69
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
63
|
-
*
|
|
64
|
-
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
65
|
-
* one that is has already answered the only question worth asking about it.
|
|
66
|
-
*/
|
|
67
|
-
export declare const MAX_STREAM_BYTES: number;
|
|
68
70
|
/**
|
|
69
71
|
* Read the package under analysis.
|
|
70
72
|
* @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-inspector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Know what a DeepSeek Harness plugin does before you install it — static pre-install analysis of a plugin directory or tarball",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko",
|