dsh-plugin-inspector 0.3.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.
@@ -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
- /** Nodes one patch layer may be walked through before the walk gives up. */
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 walk gives up. */
57
+ /** Nesting one patch layer may reach before the reader gives up. */
50
58
  export const MAX_WALK_DEPTH = 200;
51
59
  /**
52
- * Charge one node against the budget, and refuse a node already walked.
53
- * @param budget - the shared budget.
54
- * @param value - the node about to be walked.
55
- * @param depth - the current nesting depth.
56
- * @returns true when the walk may descend into this node.
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 admit(budget, value, depth) {
59
- if (budget.limit !== null)
60
- return false;
61
- if (depth > MAX_WALK_DEPTH) {
62
- budget.limit = 'depth';
63
- return false;
64
- }
65
- budget.nodes += 1;
66
- if (budget.nodes > MAX_WALK_NODES) {
67
- budget.limit = 'nodes';
68
- return false;
69
- }
70
- if (typeof value !== 'object' || value === null)
71
- return true;
72
- if (budget.visited.has(value))
73
- return false;
74
- budget.visited.add(value);
75
- return true;
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 document;
406
+ let loaded;
369
407
  try {
370
- document = yaml.load(text, { schema: patchSchema });
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(document)) {
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 sink = {
380
- overrides: [],
381
- inserts: [],
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: sink.budget.limit,
426
+ limit: expansion.limit,
427
+ aliased: expansion.aliased,
392
428
  };
393
429
  }
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
  *
@@ -34,6 +51,7 @@ export function isSourceFile(path) {
34
51
  */
35
52
  export function isModelVisibleText(path) {
36
53
  const segments = path.split('/');
54
+ /* v8 ignore next -- `split` returns at least one element for any string. */
37
55
  const base = segments.at(-1) ?? '';
38
56
  if (base === 'SKILL.md' || base === 'AGENTS.md' || base === 'CLAUDE.md')
39
57
  return true;
@@ -49,6 +67,7 @@ export function isModelVisibleText(path) {
49
67
  * @returns true for a cordis YAML file.
50
68
  */
51
69
  export function isCordisConfigFile(path) {
70
+ /* v8 ignore next -- `split` returns at least one element for any string. */
52
71
  const base = path.split('/').at(-1) ?? '';
53
72
  return /cordis/.test(base) && (base.endsWith('.yml') || base.endsWith('.yaml'));
54
73
  }
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 head-to-head measurement on 6,420 malicious and 7,288 benign npm packages
299
- * (ASE 2026) puts 72.21 % of malicious packages on a lifecycle hook and 21.2 %
300
- * with the whole attack inside `package.json` scripts no shipped module at
301
- * all. That second number is what this table is for: it is the case where the
302
- * command line itself fetches, decodes, or evaluates, and there is nothing else
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
@@ -330,6 +329,38 @@ export const LIFECYCLE_SIGNALS = [
330
329
  meaning: 'decodes an encoded payload, which is how a command hides what it runs',
331
330
  },
332
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*:/;
333
364
  /** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
334
365
  export const STATIC_ENTRY_FIELDS = [
335
366
  'id', 'name', 'group', 'inject', 'intercept', 'isolate',
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(MAX_STREAM_BYTES), parser);
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.
@@ -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
  /**
@@ -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
- /** Nodes one patch layer may be walked through before the walk gives up. */
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 walk gives up. */
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 {
@@ -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
  *
@@ -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 head-to-head measurement on 6,420 malicious and 7,288 benign npm packages
125
- * (ASE 2026) puts 72.21 % of malicious packages on a lifecycle hook and 21.2 %
126
- * with the whole attack inside `package.json` scripts no shipped module at
127
- * all. That second number is what this table is for: it is the case where the
128
- * command line itself fetches, decodes, or evaluates, and there is nothing else
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
@@ -135,6 +134,36 @@ export interface LifecycleSignal {
135
134
  * that shape is deliberately not a signal here.
136
135
  */
137
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;
138
167
  /** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
139
168
  export declare const STATIC_ENTRY_FIELDS: readonly string[];
140
169
  /**
@@ -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.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",