dsh-plugin-inspector 0.4.0 → 0.5.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.
@@ -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, LIFECYCLE_SIGNALS, MCP_CLIENT_PACKAGE, SECURITY_ROW_IDS, SECURITY_SEAM_KEYS, SEAM_KEYS, SKILL_FILESYSTEM_ROW, SKILL_ROOT_CONFIG_KEYS, } from "../knowledge.js";
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
@@ -424,7 +424,7 @@ function checkManifest(input) {
424
424
  for (const name of lifecycle) {
425
425
  /* v8 ignore next -- `name` came from filtering the same object's own keys. */
426
426
  const command = manifest.scripts[name] ?? '';
427
- const signals = LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
427
+ const signals = matchingLifecycleSignals(command);
428
428
  findings.push(tierA({
429
429
  checkId: 'A1',
430
430
  name: 'install-lifecycle-script',
@@ -548,6 +548,88 @@ function checkManifest(input) {
548
548
  }
549
549
  return findings;
550
550
  }
551
+ /**
552
+ * Whether the package ships anything a native build would compile.
553
+ *
554
+ * Skipped files count: a `.cc` the reader passed over for its size is still a
555
+ * source in the tarball, and claiming a package has none because the analyzer
556
+ * declined to read one would be wrong in the direction that raises a finding.
557
+ * @param input - the decoded package.
558
+ * @returns true when C-family source is present.
559
+ */
560
+ function shipsNativeSource(input) {
561
+ const paths = [...input.source.files.keys(), ...input.source.skipped.map(entry => entry.path)];
562
+ return paths.some(isNativeSource);
563
+ }
564
+ /**
565
+ * A24 — a native build declaration, which is an install-time execution point
566
+ * that appears in no entry the manifest declares.
567
+ *
568
+ * Tier A because the decidable half is the whole finding: the file is at the
569
+ * package root or it is not, and npm's default install command for a package
570
+ * that ships one and declares no `install` or `preinstall` script is
571
+ * `node-gyp rebuild`. Nothing has to be inferred about the code to know that a
572
+ * build runs, which is the same standard A1 and A22 are read at — a field npm
573
+ * itself must read literally in order to act on it.
574
+ *
575
+ * **The file is not parsed and never evaluated.** GYP is Python-ish, not JSON:
576
+ * single-quoted strings, `#` comments, trailing commas, and `conditions` whose
577
+ * first element is a Python expression written as a string. There is no
578
+ * maintained JavaScript parser for it — `node-gyp` shells out to Python — so
579
+ * parsing it here would mean hand-rolling one for an attacker-controlled file,
580
+ * and evaluating a condition is the one thing this tool may never do. It also
581
+ * would not change the verdict: what distinguishes a build declaration from a
582
+ * build step is the presence of an `actions`, `rules` or `postbuilds` key and
583
+ * the shape of the command line under it, and both are literal text in the file
584
+ * either way. The severity is therefore keyed on a key match plus the same
585
+ * command signals A1 grades a lifecycle script by.
586
+ * @param input - the decoded package.
587
+ * @returns the finding, or none when the package ships no `binding.gyp`.
588
+ */
589
+ function checkNativeBuild(input) {
590
+ const text = input.source.files.get(NATIVE_BUILD_FILE);
591
+ if (text === undefined)
592
+ return [];
593
+ const runsCommands = GYP_COMMAND_KEYS.test(text);
594
+ const signals = runsCommands ? matchingLifecycleSignals(text) : [];
595
+ const empty = shipsNativeSource(input) ? '' : ' The package ships no C or C++ source, so there is nothing here for '
596
+ + 'a compiler to build and the build step is the only effect the file has.';
597
+ const first = signals[0];
598
+ let at = 0;
599
+ if (first !== undefined) {
600
+ /* v8 ignore next -- `first` is in the list because it matched this same text, so `exec` finds it again. */
601
+ at = first.pattern.exec(text)?.index ?? 0;
602
+ }
603
+ return [tierA({
604
+ checkId: 'A24',
605
+ name: 'native-build-declaration',
606
+ subject: NATIVE_BUILD_FILE,
607
+ severity: signals.length === 0 ? 'medium' : 'high',
608
+ title: signals.length === 0
609
+ ? 'Ships `binding.gyp`, which npm turns into an install-time build'
610
+ : 'Ships a `binding.gyp` whose build steps run commands rather than a compiler',
611
+ detail: 'A package that ships this file and declares no `install` or `preinstall` script gets `node-gyp rebuild` '
612
+ + 'as its install command by default, and `node-gyp` evaluates the file to decide what that build does. The '
613
+ + 'declaration is in none of the entry points a reader checks: not `main`, not `bin`, not `exports`, and not '
614
+ + '`scripts`. It runs under the same gate as A1 — pnpm and npm block a dependency\'s build until the package is '
615
+ + 'named in `allowBuilds` — but reaching that gate takes no key in `package.json` at all, which is why an '
616
+ + 'ecosystem where install hooks are off by default is one where this path is worth reading.'
617
+ + (signals.length === 0
618
+ ? ' This file declares no `actions`, `rules` or `postbuilds` step whose command line does anything a compile '
619
+ + 'does not, so what it describes is a build.'
620
+ : ` It declares a build step whose command ${signals.map(signal => signal.meaning).join(', and ')}.`)
621
+ + empty
622
+ + ' The file was read as text, never parsed and never evaluated — GYP is Python-ish syntax whose conditions are '
623
+ + 'Python expressions. Reading it that way is enough to decide that a build runs, which is this finding. It is '
624
+ + 'not enough to decide what the build does, so the grade above reads the command line the way A1 reads a '
625
+ + 'lifecycle script\'s.',
626
+ evidence: {
627
+ file: NATIVE_BUILD_FILE,
628
+ path: lineColumn(text, at),
629
+ snippet: snippet(text.slice(at, at + 400)),
630
+ },
631
+ })];
632
+ }
551
633
  /** A12 — shipped markdown that reaches the model when it is discovered. */
552
634
  function checkModelVisibleText(input) {
553
635
  if (input.modelVisibleFiles.length === 0)
@@ -652,6 +734,7 @@ function checkInjectionText(input) {
652
734
  export function runTierA(input) {
653
735
  const findings = [
654
736
  ...checkManifest(input),
737
+ ...checkNativeBuild(input),
655
738
  ...checkDisabledRows(input),
656
739
  ...checkOverriddenRows(input),
657
740
  ...checkExpressions(input),
@@ -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
- /** C1 — source that is not written to be read. */
39
- function checkMinification(input) {
40
- const findings = [];
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(input) {
89
+ function checkDynamicDispatch(files) {
77
90
  const findings = [];
78
- for (const path of input.sourceFiles) {
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 says
201
- * the opposite: the bytes were read exactly as written and exactly as they will
202
- * run — what cannot be checked is whether they match the repository that
203
- * claims to have produced them. That is worth reporting and it is not a reason
204
- * to distrust the parse, and treating it as one marks every ordinary published
205
- * tarball `degraded`, because shipping built output and no source is what
206
- * publishing a package *is*.
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));
@@ -308,9 +378,11 @@ function checkPatchAliases(input) {
308
378
  * @returns findings, unordered.
309
379
  */
310
380
  export function runTierC(input) {
381
+ const files = parseSources(input);
311
382
  return [
312
- ...checkMinification(input),
313
- ...checkDynamicDispatch(input),
383
+ ...checkMinification(files),
384
+ ...checkDynamicDispatch(files),
385
+ ...checkEscapedIdentifiers(files),
314
386
  ...checkSourcelessBuild(input),
315
387
  ...checkUnreadableFiles(input),
316
388
  ...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/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',
@@ -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 { Finding } from '../model.ts';
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 says
23
- * the opposite: the bytes were read exactly as written and exactly as they will
24
- * run — what cannot be checked is whether they match the repository that
25
- * claims to have produced them. That is worth reporting and it is not a reason
26
- * to distrust the parse, and treating it as one marks every ordinary published
27
- * tarball `degraded`, because shipping built output and no source is what
28
- * publishing a package *is*.
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
  /**
@@ -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
  *
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-inspector",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",