dsh-plugin-inspector 0.5.0 → 0.6.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 +9 -4
- package/lib/checks/tier-a.js +7 -3
- package/lib/checks/tier-b.js +80 -4
- package/lib/checks/tier-c.js +3 -1
- package/lib/injection.js +7 -1
- package/lib/report.js +5 -1
- package/lib/source.js +11 -2
- package/lib/types/model.d.ts +3 -3
- package/lib/types/source.d.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,8 +84,8 @@ Findings are tiered by how much you should trust them:
|
|
|
84
84
|
intent is not. Every Tier B check has a one-line bypass, and the tool says so per finding rather
|
|
85
85
|
than implying a completeness it does not have. What it does guarantee is that it never runs the
|
|
86
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
|
|
88
|
-
sentinel on disk after a full analysis is a release blocker.
|
|
87
|
+
sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config, `!!js` disabled, and
|
|
88
|
+
module top level. Any sentinel on disk after a full analysis is a release blocker.
|
|
89
89
|
|
|
90
90
|
[What is not statically decidable →](https://charlotten7.github.io/dsh-plugin-inspector/ceiling.html) ·
|
|
91
91
|
[What it reports on the real ecosystem →](https://charlotten7.github.io/dsh-plugin-inspector/ecosystem.html)
|
|
@@ -100,8 +100,13 @@ pnpm run test:coverage
|
|
|
100
100
|
pnpm run test:e2e
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Severity calibration is pinned against a corpus of published packages,
|
|
104
|
-
|
|
103
|
+
Severity calibration is pinned against a corpus of forty published packages, in
|
|
104
|
+
`tests/ecosystem-baseline.json`. **The sweep is not part of CI** — every other workflow here runs
|
|
105
|
+
without a network, which is what lets the unit suite claim that analysing a package touches nothing
|
|
106
|
+
outside the process — so it runs as a weekly cron and on request, and a change that starts firing
|
|
107
|
+
on ordinary code does not fail the pull request that makes it. What catches it is the release: the
|
|
108
|
+
baseline records the build that measured it and a unit test fails unless that matches the version
|
|
109
|
+
in `package.json`, so a version bump is not finished until the sweep has been re-run against it.
|
|
105
110
|
|
|
106
111
|
Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
|
|
107
112
|
[SECURITY.md](SECURITY.md).
|
package/lib/checks/tier-a.js
CHANGED
|
@@ -123,8 +123,12 @@ function checkDisabledRows(input) {
|
|
|
123
123
|
// (vendor/loader/src/config/entry.ts). `null`, `0` and `""` therefore
|
|
124
124
|
// leave the row running, and reporting them as a disabled row would be
|
|
125
125
|
// confidently wrong about the one thing Tier A claims to be certain of.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
126
|
+
// A `!!js` node is not that case. The loader evaluates the expression
|
|
127
|
+
// first and coerces its *result*, so `disabled: !!js false` also leaves
|
|
128
|
+
// the row running — which this tool cannot know without running the
|
|
129
|
+
// expression, and running it is the one thing it may never do. An
|
|
130
|
+
// expression is therefore read as what it could evaluate to, which
|
|
131
|
+
// raises the finding rather than dropping it.
|
|
128
132
|
if (!override.disabled) {
|
|
129
133
|
const enabled = coreRowSeverity(override.id);
|
|
130
134
|
if (enabled === null)
|
|
@@ -643,7 +647,7 @@ function checkModelVisibleText(input) {
|
|
|
643
647
|
detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
|
|
644
648
|
+ 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
|
|
645
649
|
+ 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
|
|
646
|
-
+ 'something copies it into the user\'s workspace. The text itself is scored separately by
|
|
650
|
+
+ 'something copies it into the user\'s workspace. The text itself is scored separately by A21.',
|
|
647
651
|
/* v8 ignore next -- the caller returns early on an empty list. */
|
|
648
652
|
evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
|
|
649
653
|
})];
|
package/lib/checks/tier-b.js
CHANGED
|
@@ -53,6 +53,13 @@ export function matchesCredentialPath(text) {
|
|
|
53
53
|
const DYNAMIC_CODE_CALLEES = new Set([
|
|
54
54
|
'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
|
|
55
55
|
]);
|
|
56
|
+
/**
|
|
57
|
+
* The harness's own tool-definition helper, exported from
|
|
58
|
+
* `@deepseek-ai/dsh-tools`. Every registered tool in the harness is built by
|
|
59
|
+
* either calling it or handing `tools.register` a literal, so recognising the
|
|
60
|
+
* two shapes is what tells a tool `description` from every other kind.
|
|
61
|
+
*/
|
|
62
|
+
const TOOL_DEFINITION_HELPER = 'defineTool';
|
|
56
63
|
/** `ctx.systemPrompt` members that change what the model is told. */
|
|
57
64
|
const SYSTEM_PROMPT_MEMBERS = new Set([
|
|
58
65
|
'section', 'context', 'variable', 'tools', 'suppressRuntimeContext',
|
|
@@ -350,6 +357,70 @@ function checkCredentialRead(file, node, accumulator) {
|
|
|
350
357
|
accumulator.findings.push(finding);
|
|
351
358
|
accumulator.credentialRead ??= finding;
|
|
352
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Whether a call hands its arguments to the tool registry: the registry call
|
|
362
|
+
* itself, `<ctx>.tools.register(…)`, or the harness's `defineTool(…)` helper,
|
|
363
|
+
* whose argument is a tool definition and nothing else.
|
|
364
|
+
* @param node - the call expression.
|
|
365
|
+
* @returns true when its arguments are tool definitions.
|
|
366
|
+
*/
|
|
367
|
+
function isToolRegistration(node) {
|
|
368
|
+
const callee = node.expression;
|
|
369
|
+
if (ts.isIdentifier(callee))
|
|
370
|
+
return callee.text === TOOL_DEFINITION_HELPER;
|
|
371
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register'
|
|
372
|
+
&& ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'tools';
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Whether a name bound in this file is passed to a tool registration call, so
|
|
376
|
+
* a definition built as `const tool = {…}` and registered on a later line is
|
|
377
|
+
* still recognised as one.
|
|
378
|
+
* @param name - the bound identifier.
|
|
379
|
+
* @param file - the parsed file it was bound in.
|
|
380
|
+
* @returns true when a registration call in the same file receives it.
|
|
381
|
+
*/
|
|
382
|
+
function isRegisteredName(name, file) {
|
|
383
|
+
let registered = false;
|
|
384
|
+
const visit = (node) => {
|
|
385
|
+
if (ts.isCallExpression(node) && isToolRegistration(node)
|
|
386
|
+
&& node.arguments.some(argument => ts.isIdentifier(argument) && argument.text === name)) {
|
|
387
|
+
registered = true;
|
|
388
|
+
}
|
|
389
|
+
ts.forEachChild(node, visit);
|
|
390
|
+
};
|
|
391
|
+
ts.forEachChild(file.node, visit);
|
|
392
|
+
return registered;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Whether a `description` property belongs to a tool definition this package
|
|
396
|
+
* registers.
|
|
397
|
+
*
|
|
398
|
+
* The receiver guard is the whole check. `description` is one of the commonest
|
|
399
|
+
* property names in JavaScript — a JSON schema, an OpenAPI document, a
|
|
400
|
+
* changelog entry and a CLI option table all carry one — and none of that text
|
|
401
|
+
* reaches a model. Without the guard the injection heuristics run on release
|
|
402
|
+
* notes, and the finding's title then asserts something about a tool that the
|
|
403
|
+
* package does not have.
|
|
404
|
+
*
|
|
405
|
+
* Nested properties count, because the whole definition is model-visible: a
|
|
406
|
+
* parameter's `description` is rendered into the tool schema the model
|
|
407
|
+
* receives alongside the tool's own.
|
|
408
|
+
* @param node - the `description` property assignment.
|
|
409
|
+
* @param file - the parsed file it came from.
|
|
410
|
+
* @returns true when an enclosing object literal is a registered tool definition.
|
|
411
|
+
*/
|
|
412
|
+
function isRegisteredToolDescription(node, file) {
|
|
413
|
+
let parent = node.parent;
|
|
414
|
+
for (;;) {
|
|
415
|
+
if (ts.isCallExpression(parent))
|
|
416
|
+
return isToolRegistration(parent);
|
|
417
|
+
if (ts.isVariableDeclaration(parent))
|
|
418
|
+
return ts.isIdentifier(parent.name) && isRegisteredName(parent.name.text, file);
|
|
419
|
+
if (!ts.isObjectLiteralExpression(parent) && !ts.isPropertyAssignment(parent))
|
|
420
|
+
return false;
|
|
421
|
+
parent = parent.parent;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
353
424
|
/** B10 — injection phrasing in a registered tool description. */
|
|
354
425
|
function checkToolDescription(file, node, accumulator) {
|
|
355
426
|
if (!ts.isPropertyAssignment(node))
|
|
@@ -359,6 +430,8 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
359
430
|
const text = literalText(node.initializer);
|
|
360
431
|
if (text === null)
|
|
361
432
|
return;
|
|
433
|
+
if (!isRegisteredToolDescription(node, file))
|
|
434
|
+
return;
|
|
362
435
|
for (const match of scanInjection(text)) {
|
|
363
436
|
accumulator.findings.push(tierB({
|
|
364
437
|
checkId: 'B10',
|
|
@@ -366,11 +439,14 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
366
439
|
subject: match.ruleId,
|
|
367
440
|
severity: 'high',
|
|
368
441
|
title: `Tool description ${match.meaning}`,
|
|
369
|
-
detail: `Heuristic \`${match.ruleId}\` matched a
|
|
370
|
-
+ 'verbatim on every request that lists the tool. This is a natural-language
|
|
371
|
-
+ 'rephrasing, and it can fire on a description that legitimately discusses the
|
|
442
|
+
detail: `Heuristic \`${match.ruleId}\` matched a \`description\` inside a registered tool definition, which is `
|
|
443
|
+
+ 'prompt text the model receives verbatim on every request that lists the tool. This is a natural-language '
|
|
444
|
+
+ 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
|
|
445
|
+
+ 'subject.',
|
|
372
446
|
evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
|
|
373
|
-
bypass: 'any rephrasing the pattern does not cover,
|
|
447
|
+
bypass: 'any rephrasing the pattern does not cover, building the description by concatenation, or registering '
|
|
448
|
+
+ 'the definition through a value this tool does not track — a definition exported from one file and passed '
|
|
449
|
+
+ 'to `tools.register` in another is not matched',
|
|
374
450
|
}));
|
|
375
451
|
}
|
|
376
452
|
}
|
package/lib/checks/tier-c.js
CHANGED
|
@@ -330,7 +330,9 @@ function checkUnreadableFiles(input) {
|
|
|
330
330
|
detail: reason === 'binary'
|
|
331
331
|
? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
|
|
332
332
|
+ 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
|
|
333
|
-
: 'These files
|
|
333
|
+
: 'These files were not read: each either passed a size or count cap, or is not a regular file the reader '
|
|
334
|
+
+ 'can open — a symbolic link, a FIFO, a socket, or a directory it was refused. The subject names which. '
|
|
335
|
+
+ 'Nothing is claimed about their contents.',
|
|
334
336
|
/* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
|
|
335
337
|
evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
|
|
336
338
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
package/lib/injection.js
CHANGED
|
@@ -39,7 +39,13 @@ export const INJECTION_RULES = [
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
id: 'credential-exfiltration',
|
|
42
|
-
|
|
42
|
+
// The dotted filenames carry their own boundary. A `\b` in front of the
|
|
43
|
+
// whole alternation cannot match at the start of `.env` or `.npmrc`: the
|
|
44
|
+
// preceding character is a space and the next is a `.`, so neither side of
|
|
45
|
+
// that position is a word character and the boundary does not exist there.
|
|
46
|
+
// Under a shared `\b` those two alternatives match nothing, while the
|
|
47
|
+
// word-initial ones beside them keep working and hide it.
|
|
48
|
+
pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}(?:\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|id_rsa)\b|\.(?:env|npmrc)\b)/i,
|
|
43
49
|
meaning: 'instructs the model to move a credential somewhere',
|
|
44
50
|
},
|
|
45
51
|
{
|
package/lib/report.js
CHANGED
|
@@ -140,7 +140,11 @@ function renderFacts(report, paint) {
|
|
|
140
140
|
? []
|
|
141
141
|
: [
|
|
142
142
|
['fetched from', `${provenance.tarball} (${provenance.tarballBytes} bytes, never written to disk)`],
|
|
143
|
-
|
|
143
|
+
// Which field matched is not cosmetic: `dist.shasum` is SHA-1 and is
|
|
144
|
+
// only reached on packages published before npm 5, so naming
|
|
145
|
+
// `dist.integrity` there would report a stronger check than ran.
|
|
146
|
+
['verified', `${provenance.digest} matched `
|
|
147
|
+
+ `${provenance.algorithm === 'sha1' ? 'dist.shasum' : 'dist.integrity'} before anything parsed it`],
|
|
144
148
|
['install script', provenance.hasInstallScript
|
|
145
149
|
? 'yes — the registry marks this package as running one at install time'
|
|
146
150
|
: 'no — the registry does not mark this package as running one'],
|
package/lib/source.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* cap.
|
|
16
16
|
*
|
|
17
17
|
* Symbolic links are recorded and never followed, for the same reason: a link
|
|
18
|
-
* pointing outside the package is not part of the package.
|
|
18
|
+
* pointing outside the package is not part of the package. Anything else that
|
|
19
|
+
* is not a regular file — a FIFO, a socket, a device node — is recorded the
|
|
20
|
+
* same way and never opened.
|
|
19
21
|
* @module dsh-plugin-inspector/source
|
|
20
22
|
*/
|
|
21
23
|
import { createReadStream, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
|
|
@@ -131,8 +133,15 @@ function walkDirectory(root, directory, collector, published) {
|
|
|
131
133
|
walkDirectory(root, absolute, collector, published);
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
134
|
-
if (!entry.isFile())
|
|
136
|
+
if (!entry.isFile()) {
|
|
137
|
+
// A FIFO, socket or device node the publish set includes is content the
|
|
138
|
+
// analyzer did not read, and "how much could be read" is the one number
|
|
139
|
+
// that may never be overstated. Reading one is also not an option: a
|
|
140
|
+
// `readFileSync` on a FIFO blocks until somebody writes to it.
|
|
141
|
+
if (published.includes(path))
|
|
142
|
+
collector.skipped.push({ path, reason: 'unreadable' });
|
|
135
143
|
continue;
|
|
144
|
+
}
|
|
136
145
|
if (!published.includes(path)) {
|
|
137
146
|
collector.unpublished += 1;
|
|
138
147
|
continue;
|
package/lib/types/model.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
|
17
17
|
* `certain`; Tier B recognises syntax and is downgraded when Tier C fires.
|
|
18
18
|
*/
|
|
19
19
|
export type Confidence = 'certain' | 'high' | 'moderate' | 'low';
|
|
20
|
-
/** Which analysis produced a finding. See
|
|
20
|
+
/** Which analysis produced a finding. See the check catalogue, `docs/checks.md`. */
|
|
21
21
|
export type Tier = 'A' | 'B' | 'C';
|
|
22
22
|
/** Severity ordering, ascending. Used for `--fail-on` comparison and ranking. */
|
|
23
23
|
export declare const SEVERITY_RANK: Readonly<Record<Severity, number>>;
|
|
@@ -49,7 +49,7 @@ export declare const MAX_EXAMPLES = 3;
|
|
|
49
49
|
* finding that does.
|
|
50
50
|
*/
|
|
51
51
|
export interface Finding {
|
|
52
|
-
/** Catalogue id from
|
|
52
|
+
/** Catalogue id from `docs/checks.md`, e.g. `A2`. Stable across releases. */
|
|
53
53
|
readonly checkId: string;
|
|
54
54
|
/** Machine-readable check name, e.g. `core-row-disabled`. Stable across releases. */
|
|
55
55
|
readonly name: string;
|
|
@@ -116,7 +116,7 @@ export interface Facts {
|
|
|
116
116
|
readonly unmountedPatchFiles: readonly string[];
|
|
117
117
|
readonly dependencies: readonly string[];
|
|
118
118
|
readonly peerDependencies: readonly string[];
|
|
119
|
-
/** Shipped markdown that can reach the model. See
|
|
119
|
+
/** Shipped markdown that can reach the model. See the A12 reach note in `docs/checks.md`. */
|
|
120
120
|
readonly modelVisibleFiles: readonly string[];
|
|
121
121
|
readonly filesRead: number;
|
|
122
122
|
readonly bytesRead: number;
|
package/lib/types/source.d.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* cap.
|
|
16
16
|
*
|
|
17
17
|
* Symbolic links are recorded and never followed, for the same reason: a link
|
|
18
|
-
* pointing outside the package is not part of the package.
|
|
18
|
+
* pointing outside the package is not part of the package. Anything else that
|
|
19
|
+
* is not a regular file — a FIFO, a socket, a device node — is recorded the
|
|
20
|
+
* same way and never opened.
|
|
19
21
|
* @module dsh-plugin-inspector/source
|
|
20
22
|
*/
|
|
21
23
|
import type { SkippedFile } from './model.ts';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-inspector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|