dsh-plugin-inspector 0.4.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 +93 -6
- package/lib/checks/tier-b.js +80 -4
- package/lib/checks/tier-c.js +95 -21
- package/lib/files.js +17 -0
- package/lib/injection.js +7 -1
- package/lib/knowledge.js +32 -0
- package/lib/report.js +5 -1
- package/lib/source.js +11 -2
- package/lib/types/checks/tier-c.d.ts +9 -8
- package/lib/types/files.d.ts +10 -0
- package/lib/types/knowledge.d.ts +30 -0
- 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
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* @module dsh-plugin-inspector/checks/tier-a
|
|
10
10
|
*/
|
|
11
11
|
import { isJsExpr } from "../cordis-yaml.js";
|
|
12
|
-
import { boundedJson, lineColumn, normalizePackagePath, snippet } from "../files.js";
|
|
12
|
+
import { boundedJson, isNativeSource, lineColumn, normalizePackagePath, snippet } from "../files.js";
|
|
13
13
|
import { scanInjection } from "../injection.js";
|
|
14
|
-
import { CORE_ROWS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS,
|
|
14
|
+
import { CORE_ROWS, GYP_COMMAND_KEYS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS, MCP_CLIENT_PACKAGE, NATIVE_BUILD_FILE, SECURITY_ROW_IDS, SECURITY_SEAM_KEYS, SEAM_KEYS, SKILL_FILESYSTEM_ROW, SKILL_ROOT_CONFIG_KEYS, matchingLifecycleSignals, } from "../knowledge.js";
|
|
15
15
|
import { declaredPackages } from "../manifest.js";
|
|
16
16
|
/**
|
|
17
17
|
* Checks that read a Cordis patch row. None of them may produce a finding
|
|
@@ -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)
|
|
@@ -424,7 +428,7 @@ function checkManifest(input) {
|
|
|
424
428
|
for (const name of lifecycle) {
|
|
425
429
|
/* v8 ignore next -- `name` came from filtering the same object's own keys. */
|
|
426
430
|
const command = manifest.scripts[name] ?? '';
|
|
427
|
-
const signals =
|
|
431
|
+
const signals = matchingLifecycleSignals(command);
|
|
428
432
|
findings.push(tierA({
|
|
429
433
|
checkId: 'A1',
|
|
430
434
|
name: 'install-lifecycle-script',
|
|
@@ -548,6 +552,88 @@ function checkManifest(input) {
|
|
|
548
552
|
}
|
|
549
553
|
return findings;
|
|
550
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Whether the package ships anything a native build would compile.
|
|
557
|
+
*
|
|
558
|
+
* Skipped files count: a `.cc` the reader passed over for its size is still a
|
|
559
|
+
* source in the tarball, and claiming a package has none because the analyzer
|
|
560
|
+
* declined to read one would be wrong in the direction that raises a finding.
|
|
561
|
+
* @param input - the decoded package.
|
|
562
|
+
* @returns true when C-family source is present.
|
|
563
|
+
*/
|
|
564
|
+
function shipsNativeSource(input) {
|
|
565
|
+
const paths = [...input.source.files.keys(), ...input.source.skipped.map(entry => entry.path)];
|
|
566
|
+
return paths.some(isNativeSource);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* A24 — a native build declaration, which is an install-time execution point
|
|
570
|
+
* that appears in no entry the manifest declares.
|
|
571
|
+
*
|
|
572
|
+
* Tier A because the decidable half is the whole finding: the file is at the
|
|
573
|
+
* package root or it is not, and npm's default install command for a package
|
|
574
|
+
* that ships one and declares no `install` or `preinstall` script is
|
|
575
|
+
* `node-gyp rebuild`. Nothing has to be inferred about the code to know that a
|
|
576
|
+
* build runs, which is the same standard A1 and A22 are read at — a field npm
|
|
577
|
+
* itself must read literally in order to act on it.
|
|
578
|
+
*
|
|
579
|
+
* **The file is not parsed and never evaluated.** GYP is Python-ish, not JSON:
|
|
580
|
+
* single-quoted strings, `#` comments, trailing commas, and `conditions` whose
|
|
581
|
+
* first element is a Python expression written as a string. There is no
|
|
582
|
+
* maintained JavaScript parser for it — `node-gyp` shells out to Python — so
|
|
583
|
+
* parsing it here would mean hand-rolling one for an attacker-controlled file,
|
|
584
|
+
* and evaluating a condition is the one thing this tool may never do. It also
|
|
585
|
+
* would not change the verdict: what distinguishes a build declaration from a
|
|
586
|
+
* build step is the presence of an `actions`, `rules` or `postbuilds` key and
|
|
587
|
+
* the shape of the command line under it, and both are literal text in the file
|
|
588
|
+
* either way. The severity is therefore keyed on a key match plus the same
|
|
589
|
+
* command signals A1 grades a lifecycle script by.
|
|
590
|
+
* @param input - the decoded package.
|
|
591
|
+
* @returns the finding, or none when the package ships no `binding.gyp`.
|
|
592
|
+
*/
|
|
593
|
+
function checkNativeBuild(input) {
|
|
594
|
+
const text = input.source.files.get(NATIVE_BUILD_FILE);
|
|
595
|
+
if (text === undefined)
|
|
596
|
+
return [];
|
|
597
|
+
const runsCommands = GYP_COMMAND_KEYS.test(text);
|
|
598
|
+
const signals = runsCommands ? matchingLifecycleSignals(text) : [];
|
|
599
|
+
const empty = shipsNativeSource(input) ? '' : ' The package ships no C or C++ source, so there is nothing here for '
|
|
600
|
+
+ 'a compiler to build and the build step is the only effect the file has.';
|
|
601
|
+
const first = signals[0];
|
|
602
|
+
let at = 0;
|
|
603
|
+
if (first !== undefined) {
|
|
604
|
+
/* v8 ignore next -- `first` is in the list because it matched this same text, so `exec` finds it again. */
|
|
605
|
+
at = first.pattern.exec(text)?.index ?? 0;
|
|
606
|
+
}
|
|
607
|
+
return [tierA({
|
|
608
|
+
checkId: 'A24',
|
|
609
|
+
name: 'native-build-declaration',
|
|
610
|
+
subject: NATIVE_BUILD_FILE,
|
|
611
|
+
severity: signals.length === 0 ? 'medium' : 'high',
|
|
612
|
+
title: signals.length === 0
|
|
613
|
+
? 'Ships `binding.gyp`, which npm turns into an install-time build'
|
|
614
|
+
: 'Ships a `binding.gyp` whose build steps run commands rather than a compiler',
|
|
615
|
+
detail: 'A package that ships this file and declares no `install` or `preinstall` script gets `node-gyp rebuild` '
|
|
616
|
+
+ 'as its install command by default, and `node-gyp` evaluates the file to decide what that build does. The '
|
|
617
|
+
+ 'declaration is in none of the entry points a reader checks: not `main`, not `bin`, not `exports`, and not '
|
|
618
|
+
+ '`scripts`. It runs under the same gate as A1 — pnpm and npm block a dependency\'s build until the package is '
|
|
619
|
+
+ 'named in `allowBuilds` — but reaching that gate takes no key in `package.json` at all, which is why an '
|
|
620
|
+
+ 'ecosystem where install hooks are off by default is one where this path is worth reading.'
|
|
621
|
+
+ (signals.length === 0
|
|
622
|
+
? ' This file declares no `actions`, `rules` or `postbuilds` step whose command line does anything a compile '
|
|
623
|
+
+ 'does not, so what it describes is a build.'
|
|
624
|
+
: ` It declares a build step whose command ${signals.map(signal => signal.meaning).join(', and ')}.`)
|
|
625
|
+
+ empty
|
|
626
|
+
+ ' The file was read as text, never parsed and never evaluated — GYP is Python-ish syntax whose conditions are '
|
|
627
|
+
+ 'Python expressions. Reading it that way is enough to decide that a build runs, which is this finding. It is '
|
|
628
|
+
+ 'not enough to decide what the build does, so the grade above reads the command line the way A1 reads a '
|
|
629
|
+
+ 'lifecycle script\'s.',
|
|
630
|
+
evidence: {
|
|
631
|
+
file: NATIVE_BUILD_FILE,
|
|
632
|
+
path: lineColumn(text, at),
|
|
633
|
+
snippet: snippet(text.slice(at, at + 400)),
|
|
634
|
+
},
|
|
635
|
+
})];
|
|
636
|
+
}
|
|
551
637
|
/** A12 — shipped markdown that reaches the model when it is discovered. */
|
|
552
638
|
function checkModelVisibleText(input) {
|
|
553
639
|
if (input.modelVisibleFiles.length === 0)
|
|
@@ -561,7 +647,7 @@ function checkModelVisibleText(input) {
|
|
|
561
647
|
detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
|
|
562
648
|
+ 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
|
|
563
649
|
+ 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
|
|
564
|
-
+ '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.',
|
|
565
651
|
/* v8 ignore next -- the caller returns early on an empty list. */
|
|
566
652
|
evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
|
|
567
653
|
})];
|
|
@@ -652,6 +738,7 @@ function checkInjectionText(input) {
|
|
|
652
738
|
export function runTierA(input) {
|
|
653
739
|
const findings = [
|
|
654
740
|
...checkManifest(input),
|
|
741
|
+
...checkNativeBuild(input),
|
|
655
742
|
...checkDisabledRows(input),
|
|
656
743
|
...checkOverriddenRows(input),
|
|
657
744
|
...checkExpressions(input),
|
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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import ts from 'typescript';
|
|
18
18
|
import { lineColumn, snippet } from "../files.js";
|
|
19
|
+
import { MAX_EXAMPLES } from "../model.js";
|
|
19
20
|
/** A line longer than this is not written by hand. */
|
|
20
21
|
const MINIFIED_LINE_LENGTH = 500;
|
|
21
22
|
/** Below this many bytes, a low line count says nothing. */
|
|
@@ -35,14 +36,26 @@ const NAMED_TARGET_CALLEES = new Set([
|
|
|
35
36
|
function tierC(finding) {
|
|
36
37
|
return { ...finding, tier: 'C', confidence: 'moderate', examples: [finding.evidence], occurrences: 1 };
|
|
37
38
|
}
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Parse every shipped source file once.
|
|
41
|
+
* @param input - the decoded package.
|
|
42
|
+
* @returns one entry per source file, in `sourceFiles` order.
|
|
43
|
+
*/
|
|
44
|
+
function parseSources(input) {
|
|
45
|
+
const files = [];
|
|
41
46
|
for (const path of input.sourceFiles) {
|
|
42
47
|
const text = input.source.files.get(path);
|
|
43
48
|
/* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
|
|
44
49
|
if (text === undefined)
|
|
45
50
|
continue;
|
|
51
|
+
files.push({ path, text, node: ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS) });
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
/** C1 — source that is not written to be read. */
|
|
56
|
+
function checkMinification(files) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const { path, text } of files) {
|
|
46
59
|
const lines = text.split('\n');
|
|
47
60
|
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
48
61
|
const dense = text.length >= MINIFICATION_SIZE_FLOOR && lines.length < 5;
|
|
@@ -73,14 +86,9 @@ function checkMinification(input) {
|
|
|
73
86
|
return findings;
|
|
74
87
|
}
|
|
75
88
|
/** C2 — names the analyzer cannot resolve without running the code. */
|
|
76
|
-
function checkDynamicDispatch(
|
|
89
|
+
function checkDynamicDispatch(files) {
|
|
77
90
|
const findings = [];
|
|
78
|
-
for (const path of
|
|
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. */
|
|
81
|
-
if (text === undefined)
|
|
82
|
-
continue;
|
|
83
|
-
const source = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
|
91
|
+
for (const { path, text, node: source } of files) {
|
|
84
92
|
const report = (node, what) => {
|
|
85
93
|
findings.push(tierC({
|
|
86
94
|
checkId: 'C2',
|
|
@@ -194,18 +202,80 @@ function literalOf(node) {
|
|
|
194
202
|
return node.text;
|
|
195
203
|
return null;
|
|
196
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* C8 — identifiers spelled with Unicode escapes.
|
|
207
|
+
*
|
|
208
|
+
* An escaped spelling and a plain one are the same identifier: the escape is
|
|
209
|
+
* resolved in the scanner, before any binding, so the two are the same program
|
|
210
|
+
* and only a reader sees a difference. That is the whole technique — the file
|
|
211
|
+
* says one thing to a person and another to the engine.
|
|
212
|
+
*
|
|
213
|
+
* It says the same thing to this tool as to the engine. `ts.createSourceFile`
|
|
214
|
+
* hands back `node.text === 'fetch'` for the escaped form, so every Tier B
|
|
215
|
+
* check that matches a name matches the escaped spelling too. That is measured
|
|
216
|
+
* rather than assumed — `tests/unit/detection.spec.ts` runs escaped spellings
|
|
217
|
+
* through B6, B7, B9 and B12 — which is why this finding sits with C3 in
|
|
218
|
+
* {@link NON_DEGRADING_CHECKS} rather than making every Tier B negative
|
|
219
|
+
* unreliable.
|
|
220
|
+
* @param files - the parsed source files.
|
|
221
|
+
* @returns one finding for the package, or none.
|
|
222
|
+
*/
|
|
223
|
+
function checkEscapedIdentifiers(files) {
|
|
224
|
+
const sites = [];
|
|
225
|
+
for (const { path, text, node: source } of files) {
|
|
226
|
+
const visit = (node) => {
|
|
227
|
+
if (ts.isIdentifier(node)) {
|
|
228
|
+
const start = node.getStart(source);
|
|
229
|
+
const raw = text.slice(start, node.end);
|
|
230
|
+
// An identifier token holds a backslash only as part of a `\uXXXX` or
|
|
231
|
+
// `\u{X}` escape; nothing else in the grammar puts one there.
|
|
232
|
+
if (raw.includes('\\')) {
|
|
233
|
+
sites.push({ name: node.text, evidence: { file: path, path: lineColumn(text, start), snippet: snippet(raw) } });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
ts.forEachChild(node, visit);
|
|
237
|
+
};
|
|
238
|
+
ts.forEachChild(source, visit);
|
|
239
|
+
}
|
|
240
|
+
const first = sites[0];
|
|
241
|
+
if (first === undefined)
|
|
242
|
+
return [];
|
|
243
|
+
const names = [...new Set(sites.map(site => site.name))].sort();
|
|
244
|
+
return [{
|
|
245
|
+
...tierC({
|
|
246
|
+
checkId: 'C8',
|
|
247
|
+
name: 'escaped-identifier',
|
|
248
|
+
subject: 'escaped-identifier',
|
|
249
|
+
severity: 'medium',
|
|
250
|
+
title: 'Writes identifier names as Unicode escapes',
|
|
251
|
+
detail: `The escapes resolve to ${names.map(name => `\`${name}\``).join(', ')}. JavaScript resolves an `
|
|
252
|
+
+ 'identifier escape in the scanner, so the escaped and the plain spelling are the same program and no '
|
|
253
|
+
+ 'behavior distinguishes them — the difference is only visible to whoever reads the file. Nothing writes a '
|
|
254
|
+
+ 'name this way by accident, and a published package has no build reason to.'
|
|
255
|
+
+ ' This does not weaken the rest of the report: the parser resolves the escape before any check sees the '
|
|
256
|
+
+ 'name, so a `\\u`-escaped `fetch` is still reported as network egress and an escaped `process.env` read is '
|
|
257
|
+
+ 'still reported as a credential read. What the escape defeats is the reading, not the detection.',
|
|
258
|
+
evidence: first.evidence,
|
|
259
|
+
bypass: 'concealing the name a way this check is not about — a computed member or a string assembled at '
|
|
260
|
+
+ 'runtime, which is C2',
|
|
261
|
+
}),
|
|
262
|
+
examples: sites.slice(0, MAX_EXAMPLES).map(site => site.evidence),
|
|
263
|
+
occurrences: sites.length,
|
|
264
|
+
}];
|
|
265
|
+
}
|
|
197
266
|
/**
|
|
198
267
|
* Tier C checks that do **not** make a Tier B negative unreliable.
|
|
199
268
|
*
|
|
200
|
-
* Every other check here says the analyzer could not read something. C3
|
|
201
|
-
* the opposite: the bytes were read exactly as written and exactly as
|
|
202
|
-
* run — what cannot be checked is whether they match the repository
|
|
203
|
-
* claims to have produced them.
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
269
|
+
* Every other check here says the analyzer could not read something. C3 and C8
|
|
270
|
+
* say the opposite. C3: the bytes were read exactly as written and exactly as
|
|
271
|
+
* they will run — what cannot be checked is whether they match the repository
|
|
272
|
+
* that claims to have produced them. Treating that as an unreadable package
|
|
273
|
+
* marks every ordinary published tarball `degraded`, because shipping built
|
|
274
|
+
* output and no source is what publishing a package *is*. C8: the escape is
|
|
275
|
+
* resolved by the parser before any check reads the name, so the analysis of an
|
|
276
|
+
* escaped identifier is exactly as good as the analysis of a plain one.
|
|
207
277
|
*/
|
|
208
|
-
export const NON_DEGRADING_CHECKS = new Set(['C3']);
|
|
278
|
+
export const NON_DEGRADING_CHECKS = new Set(['C3', 'C8']);
|
|
209
279
|
/** C3, C6 — shipped build output with nothing to compare it against. */
|
|
210
280
|
function checkSourcelessBuild(input) {
|
|
211
281
|
const built = input.sourceFiles.filter(path => /^(?:lib|dist|build|out)\//.test(path));
|
|
@@ -260,7 +330,9 @@ function checkUnreadableFiles(input) {
|
|
|
260
330
|
detail: reason === 'binary'
|
|
261
331
|
? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
|
|
262
332
|
+ 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
|
|
263
|
-
: '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.',
|
|
264
336
|
/* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
|
|
265
337
|
evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
|
|
266
338
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
@@ -308,9 +380,11 @@ function checkPatchAliases(input) {
|
|
|
308
380
|
* @returns findings, unordered.
|
|
309
381
|
*/
|
|
310
382
|
export function runTierC(input) {
|
|
383
|
+
const files = parseSources(input);
|
|
311
384
|
return [
|
|
312
|
-
...checkMinification(
|
|
313
|
-
...checkDynamicDispatch(
|
|
385
|
+
...checkMinification(files),
|
|
386
|
+
...checkDynamicDispatch(files),
|
|
387
|
+
...checkEscapedIdentifiers(files),
|
|
314
388
|
...checkSourcelessBuild(input),
|
|
315
389
|
...checkUnreadableFiles(input),
|
|
316
390
|
...checkPatchWalkLimit(input),
|
package/lib/files.js
CHANGED
|
@@ -20,6 +20,23 @@ export function isSourceFile(path) {
|
|
|
20
20
|
return false;
|
|
21
21
|
return SOURCE_EXTENSIONS.some(extension => path.endsWith(extension));
|
|
22
22
|
}
|
|
23
|
+
/** Extensions a `binding.gyp` target compiles. */
|
|
24
|
+
const NATIVE_SOURCE_EXTENSIONS = [
|
|
25
|
+
'.c', '.cc', '.cpp', '.cxx', '.h', '.hh', '.hpp', '.hxx', '.m', '.mm', '.s', '.asm',
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Whether a path is C-family source a native build would compile.
|
|
29
|
+
*
|
|
30
|
+
* Used to answer one question about a package that ships a `binding.gyp`: is
|
|
31
|
+
* there anything in it to build. A gyp with no compilable source is a build
|
|
32
|
+
* declaration whose only effect is that a build runs.
|
|
33
|
+
* @param path - package-relative POSIX path.
|
|
34
|
+
* @returns true when the file is C-family source or a header.
|
|
35
|
+
*/
|
|
36
|
+
export function isNativeSource(path) {
|
|
37
|
+
const lower = path.toLowerCase();
|
|
38
|
+
return NATIVE_SOURCE_EXTENSIONS.some(extension => lower.endsWith(extension));
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
41
|
* Whether a path is markdown that can reach the model verbatim.
|
|
25
42
|
*
|
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/knowledge.js
CHANGED
|
@@ -329,6 +329,38 @@ export const LIFECYCLE_SIGNALS = [
|
|
|
329
329
|
meaning: 'decodes an encoded payload, which is how a command hides what it runs',
|
|
330
330
|
},
|
|
331
331
|
];
|
|
332
|
+
/**
|
|
333
|
+
* Every lifecycle signal a command line matches.
|
|
334
|
+
*
|
|
335
|
+
* One entry point rather than the filter written out twice, because the table
|
|
336
|
+
* now grades two different things — a `package.json` lifecycle command (A1) and
|
|
337
|
+
* a `binding.gyp` build step (A24) — and a rule added to it has to reach both.
|
|
338
|
+
* @param command - the command line, or the text that holds one.
|
|
339
|
+
* @returns the matching signals, in table order.
|
|
340
|
+
*/
|
|
341
|
+
export function matchingLifecycleSignals(command) {
|
|
342
|
+
return LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The file `node-gyp` reads, at the package root and nowhere else.
|
|
346
|
+
*
|
|
347
|
+
* npm and pnpm treat its presence as a declaration: a package that ships one
|
|
348
|
+
* and declares no `install` or `preinstall` script gets `node-gyp rebuild` as
|
|
349
|
+
* its install command. That default appears in no field of `package.json`.
|
|
350
|
+
*/
|
|
351
|
+
export const NATIVE_BUILD_FILE = 'binding.gyp';
|
|
352
|
+
/**
|
|
353
|
+
* GYP keys that carry a command line rather than a list of sources to compile.
|
|
354
|
+
*
|
|
355
|
+
* `actions` and `rules` run a program during the build; `postbuilds` runs one
|
|
356
|
+
* after it. A target that only lists `sources`, `include_dirs` and `libraries`
|
|
357
|
+
* compiles code the package shipped and runs nothing else.
|
|
358
|
+
*
|
|
359
|
+
* Matched against the whole file, so a block nested inside a `conditions` arm
|
|
360
|
+
* counts the same as a top-level one — which is the point, because a condition
|
|
361
|
+
* is where a build step goes to be read past.
|
|
362
|
+
*/
|
|
363
|
+
export const GYP_COMMAND_KEYS = /['"](?:actions?|rules?|postbuilds)['"]\s*:/;
|
|
332
364
|
/** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
|
|
333
365
|
export const STATIC_ENTRY_FIELDS = [
|
|
334
366
|
'id', 'name', 'group', 'inject', 'intercept', 'isolate',
|
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;
|
|
@@ -14,18 +14,19 @@
|
|
|
14
14
|
* from claiming nothing was found.
|
|
15
15
|
* @module dsh-plugin-inspector/checks/tier-c
|
|
16
16
|
*/
|
|
17
|
-
import type
|
|
17
|
+
import { type Finding } from '../model.ts';
|
|
18
18
|
import type { CheckInput } from './input.ts';
|
|
19
19
|
/**
|
|
20
20
|
* Tier C checks that do **not** make a Tier B negative unreliable.
|
|
21
21
|
*
|
|
22
|
-
* Every other check here says the analyzer could not read something. C3
|
|
23
|
-
* the opposite: the bytes were read exactly as written and exactly as
|
|
24
|
-
* run — what cannot be checked is whether they match the repository
|
|
25
|
-
* claims to have produced them.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* Every other check here says the analyzer could not read something. C3 and C8
|
|
23
|
+
* say the opposite. C3: the bytes were read exactly as written and exactly as
|
|
24
|
+
* they will run — what cannot be checked is whether they match the repository
|
|
25
|
+
* that claims to have produced them. Treating that as an unreadable package
|
|
26
|
+
* marks every ordinary published tarball `degraded`, because shipping built
|
|
27
|
+
* output and no source is what publishing a package *is*. C8: the escape is
|
|
28
|
+
* resolved by the parser before any check reads the name, so the analysis of an
|
|
29
|
+
* escaped identifier is exactly as good as the analysis of a plain one.
|
|
29
30
|
*/
|
|
30
31
|
export declare const NON_DEGRADING_CHECKS: ReadonlySet<string>;
|
|
31
32
|
/**
|
package/lib/types/files.d.ts
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* @returns true when the file should be parsed.
|
|
11
11
|
*/
|
|
12
12
|
export declare function isSourceFile(path: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a path is C-family source a native build would compile.
|
|
15
|
+
*
|
|
16
|
+
* Used to answer one question about a package that ships a `binding.gyp`: is
|
|
17
|
+
* there anything in it to build. A gyp with no compilable source is a build
|
|
18
|
+
* declaration whose only effect is that a build runs.
|
|
19
|
+
* @param path - package-relative POSIX path.
|
|
20
|
+
* @returns true when the file is C-family source or a header.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isNativeSource(path: string): boolean;
|
|
13
23
|
/**
|
|
14
24
|
* Whether a path is markdown that can reach the model verbatim.
|
|
15
25
|
*
|
package/lib/types/knowledge.d.ts
CHANGED
|
@@ -134,6 +134,36 @@ export interface LifecycleSignal {
|
|
|
134
134
|
* that shape is deliberately not a signal here.
|
|
135
135
|
*/
|
|
136
136
|
export declare const LIFECYCLE_SIGNALS: readonly LifecycleSignal[];
|
|
137
|
+
/**
|
|
138
|
+
* Every lifecycle signal a command line matches.
|
|
139
|
+
*
|
|
140
|
+
* One entry point rather than the filter written out twice, because the table
|
|
141
|
+
* now grades two different things — a `package.json` lifecycle command (A1) and
|
|
142
|
+
* a `binding.gyp` build step (A24) — and a rule added to it has to reach both.
|
|
143
|
+
* @param command - the command line, or the text that holds one.
|
|
144
|
+
* @returns the matching signals, in table order.
|
|
145
|
+
*/
|
|
146
|
+
export declare function matchingLifecycleSignals(command: string): LifecycleSignal[];
|
|
147
|
+
/**
|
|
148
|
+
* The file `node-gyp` reads, at the package root and nowhere else.
|
|
149
|
+
*
|
|
150
|
+
* npm and pnpm treat its presence as a declaration: a package that ships one
|
|
151
|
+
* and declares no `install` or `preinstall` script gets `node-gyp rebuild` as
|
|
152
|
+
* its install command. That default appears in no field of `package.json`.
|
|
153
|
+
*/
|
|
154
|
+
export declare const NATIVE_BUILD_FILE = "binding.gyp";
|
|
155
|
+
/**
|
|
156
|
+
* GYP keys that carry a command line rather than a list of sources to compile.
|
|
157
|
+
*
|
|
158
|
+
* `actions` and `rules` run a program during the build; `postbuilds` runs one
|
|
159
|
+
* after it. A target that only lists `sources`, `include_dirs` and `libraries`
|
|
160
|
+
* compiles code the package shipped and runs nothing else.
|
|
161
|
+
*
|
|
162
|
+
* Matched against the whole file, so a block nested inside a `conditions` arm
|
|
163
|
+
* counts the same as a top-level one — which is the point, because a condition
|
|
164
|
+
* is where a build step goes to be read past.
|
|
165
|
+
*/
|
|
166
|
+
export declare const GYP_COMMAND_KEYS: RegExp;
|
|
137
167
|
/** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
|
|
138
168
|
export declare const STATIC_ENTRY_FIELDS: readonly string[];
|
|
139
169
|
/**
|
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",
|