dsh-plugin-inspector 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,35 @@ import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES,
20
20
  const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
21
21
  /** `process.env` keys whose names say they hold a secret. */
22
22
  const SECRET_ENV_KEY = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|APIKEY|SESSION)(?:_|$)|API_?KEY|ACCESS_?TOKEN/i;
23
- /** Filesystem locations that hold credentials. */
24
- const CREDENTIAL_PATH = /(?:\.npmrc|\.netrc|\.ssh\/|id_rsa|id_ed25519|\.aws\/|\.docker\/config\.json|\.git-credentials|credentials\.json|\.dsh\/credentials|\.env(?:\.[a-z]+)?$)/i;
23
+ /**
24
+ * Filesystem locations that hold credentials.
25
+ *
26
+ * A table rather than one regular expression so each location can be pinned by
27
+ * name: `tests/unit/rule-tables.spec.ts` iterates this export, and a location
28
+ * added without a fixture fails there.
29
+ */
30
+ export const CREDENTIAL_PATHS = [
31
+ { id: 'npmrc', pattern: String.raw `\.npmrc` },
32
+ { id: 'netrc', pattern: String.raw `\.netrc` },
33
+ { id: 'ssh-directory', pattern: String.raw `\.ssh\/` },
34
+ { id: 'ssh-key-rsa', pattern: 'id_rsa' },
35
+ { id: 'ssh-key-ed25519', pattern: 'id_ed25519' },
36
+ { id: 'aws-directory', pattern: String.raw `\.aws\/` },
37
+ { id: 'docker-config', pattern: String.raw `\.docker\/config\.json` },
38
+ { id: 'git-credentials', pattern: String.raw `\.git-credentials` },
39
+ { id: 'service-account-json', pattern: String.raw `credentials\.json` },
40
+ { id: 'dsh-credentials', pattern: String.raw `\.dsh\/credentials` },
41
+ { id: 'dotenv', pattern: String.raw `\.env(?:\.[a-z]+)?$` },
42
+ ];
43
+ const CREDENTIAL_PATH = new RegExp(`(?:${CREDENTIAL_PATHS.map(path => path.pattern).join('|')})`, 'i');
44
+ /**
45
+ * Whether a string names a location that holds credentials.
46
+ * @param text - the literal text of a string in shipped source.
47
+ * @returns true when it names one of {@link CREDENTIAL_PATHS}.
48
+ */
49
+ export function matchesCredentialPath(text) {
50
+ return CREDENTIAL_PATH.test(text);
51
+ }
25
52
  /** Members of `ctx` that construct or evaluate code, or mount further plugins. */
26
53
  const DYNAMIC_CODE_CALLEES = new Set([
27
54
  'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
@@ -71,6 +98,7 @@ function moduleSpecifiers(file) {
71
98
  const visit = (node) => {
72
99
  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined) {
73
100
  const text = literalText(node.moduleSpecifier);
101
+ /* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
74
102
  if (text !== null)
75
103
  found.push({ specifier: text, node });
76
104
  }
@@ -298,7 +326,7 @@ function checkCredentialRead(file, node, accumulator) {
298
326
  subject = `env:${key}`;
299
327
  }
300
328
  }
301
- if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && CREDENTIAL_PATH.test(node.text)) {
329
+ if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && matchesCredentialPath(node.text)) {
302
330
  title = `References the credential location \`${node.text}\``;
303
331
  subject = `path:${node.text}`;
304
332
  }
@@ -375,6 +403,7 @@ export function runTierB(input) {
375
403
  const accumulator = { findings: [], credentialRead: null, networkCall: null };
376
404
  for (const path of input.sourceFiles) {
377
405
  const text = input.source.files.get(path);
406
+ /* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
378
407
  if (text === undefined)
379
408
  continue;
380
409
  const file = {
@@ -450,15 +479,18 @@ function pairFinding(accumulator) {
450
479
  // finding's own text says it is not a verdict. A severity that says "do not
451
480
  // treat this as a verdict" cannot be the top one.
452
481
  const severity = 'high';
482
+ /* v8 ignore start -- `at()` records a line and column for every finding these two come from. */
483
+ const credentialSite = `${credential.evidence.file}:${credential.evidence.path ?? '?'}`;
484
+ const networkSite = `${network.evidence.file}:${network.evidence.path ?? '?'}`;
485
+ /* v8 ignore stop */
453
486
  return tierB({
454
487
  checkId: 'B8',
455
488
  name: 'exfiltration-capability',
456
489
  subject: 'credential-and-egress',
457
490
  severity,
458
491
  title: 'This package can read a credential and can make a network call',
459
- detail: 'This is a capability, not a dataflow. The tool found a credential read at '
460
- + `${credential.evidence.file}:${credential.evidence.path ?? '?'} and a network call at `
461
- + `${network.evidence.file}:${network.evidence.path ?? '?'}. It has NOT shown that the credential value `
492
+ detail: `This is a capability, not a dataflow. The tool found a credential read at ${credentialSite} `
493
+ + `and a network call at ${networkSite}. It has NOT shown that the credential value `
462
494
  + 'reaches the request, and it cannot: proving that needs value tracking this tool does not do. Many '
463
495
  + 'legitimate packages — any telemetry or authenticated API client — trip this pair for good reasons. Treat '
464
496
  + 'it as a prompt to read those two sites, not as a verdict.',
@@ -40,6 +40,7 @@ function checkMinification(input) {
40
40
  const findings = [];
41
41
  for (const path of input.sourceFiles) {
42
42
  const text = input.source.files.get(path);
43
+ /* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
43
44
  if (text === undefined)
44
45
  continue;
45
46
  const lines = text.split('\n');
@@ -64,6 +65,7 @@ function checkMinification(input) {
64
65
  + `that long are ${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability `
65
66
  + 'detection reads syntax, and it reads minified syntax no better than a person does. Every Tier B '
66
67
  + 'negative for this package is unreliable while a file like this is in it.',
68
+ /* v8 ignore next -- `split` returns at least one element for any string, so the fallback is unreachable. */
67
69
  evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
68
70
  bypass: 'none — this finding is about the analysis, not about the plugin',
69
71
  }));
@@ -75,6 +77,7 @@ function checkDynamicDispatch(input) {
75
77
  const findings = [];
76
78
  for (const path of input.sourceFiles) {
77
79
  const text = input.source.files.get(path);
80
+ /* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
78
81
  if (text === undefined)
79
82
  continue;
80
83
  const source = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
@@ -156,9 +159,11 @@ function isDispatchReceiver(node) {
156
159
  function receiverName(node) {
157
160
  if (ts.isIdentifier(node))
158
161
  return node.text;
162
+ /* v8 ignore start -- only called after `isDispatchReceiver`, which accepts these two forms and no other. */
159
163
  if (ts.isPropertyAccessExpression(node))
160
164
  return node.name.text;
161
165
  return '?';
166
+ /* v8 ignore stop */
162
167
  }
163
168
  /**
164
169
  * Whether a node builds a string at runtime rather than naming one. A plain
@@ -217,6 +222,7 @@ function checkSourcelessBuild(input) {
217
222
  detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
218
223
  + 'package to check the build against. Whether the source that produced it matches the repository is not '
219
224
  + 'decidable from here.',
225
+ /* v8 ignore next -- guarded by `built.length > 0` two lines above. */
220
226
  evidence: { file: built[0] ?? '', snippet: snippet(built.slice(0, 5).join(', ')) },
221
227
  bypass: 'none — this finding is about the analysis, not about the plugin',
222
228
  }));
@@ -255,6 +261,7 @@ function checkUnreadableFiles(input) {
255
261
  ? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
256
262
  + 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
257
263
  : 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
264
+ /* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
258
265
  evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
259
266
  bypass: 'none — this finding is about the analysis, not about the plugin',
260
267
  }));
@@ -278,6 +285,23 @@ function checkPatchWalkLimit(input) {
278
285
  bypass: 'none — this finding is about the analysis, not about the plugin',
279
286
  }));
280
287
  }
288
+ /** C7 — a patch layer whose rows are assembled out of YAML anchors and aliases. */
289
+ function checkPatchAliases(input) {
290
+ return input.patches.filter(patch => patch.aliased).map(patch => tierC({
291
+ checkId: 'C7',
292
+ name: 'patch-uses-aliases',
293
+ subject: patch.file,
294
+ severity: 'medium',
295
+ title: `\`${patch.file}\` builds rows out of YAML anchors and aliases`,
296
+ detail: 'An alias is not a copy: `*a` hands the loader the same node again, so one row in the file can be two '
297
+ + 'rows in the composed profile, and the row a reader sees under an inert key can be the row that lands in a '
298
+ + 'live one. This tool expands every alias to its own node before reading the layer, which is what makes the '
299
+ + 'reading match the loader — but the layer a person reviews and the layer that mounts are no longer the same '
300
+ + 'document, and no Tier B negative about this package is claimed while that is true.',
301
+ evidence: { file: patch.file },
302
+ bypass: 'none — this finding is about the analysis, not about the plugin',
303
+ }));
304
+ }
281
305
  /**
282
306
  * Run every Tier C check.
283
307
  * @param input - the decoded package.
@@ -290,5 +314,6 @@ export function runTierC(input) {
290
314
  ...checkSourcelessBuild(input),
291
315
  ...checkUnreadableFiles(input),
292
316
  ...checkPatchWalkLimit(input),
317
+ ...checkPatchAliases(input),
293
318
  ];
294
319
  }
package/lib/cli.js CHANGED
@@ -80,6 +80,7 @@ export function parseArgs(argv) {
80
80
  return next;
81
81
  };
82
82
  for (let index = 0; index < argv.length; index += 1) {
83
+ /* v8 ignore next -- `index` is bounded by the loop condition. */
83
84
  const argument = argv[index] ?? '';
84
85
  if (argument === '--help' || argument === '-h') {
85
86
  process.stdout.write(USAGE);
@@ -152,6 +153,7 @@ export async function main(argv) {
152
153
  options = parseArgs(argv);
153
154
  }
154
155
  catch (error) {
156
+ /* v8 ignore next -- `parseArgs` refuses a command line only with a UsageError. */
155
157
  process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
156
158
  return EXIT.unanalysable;
157
159
  }
@@ -165,6 +167,7 @@ export async function main(argv) {
165
167
  return exceedsThreshold(report, options.failOn) ? EXIT.findings : EXIT.clean;
166
168
  }
167
169
  catch (error) {
170
+ /* v8 ignore next -- every refusal on the read path is a SourceError, ManifestError or RegistryError. */
168
171
  process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n`);
169
172
  return EXIT.unanalysable;
170
173
  }
@@ -187,6 +190,7 @@ export function reportFatal(error) {
187
190
  process.stderr.write(`dsh-inspect: the analysis could not be completed: ${message}\n`);
188
191
  return EXIT.unanalysable;
189
192
  }
193
+ /* v8 ignore start -- the process entry, exercised by tests/e2e/cli.e2e.ts against the built CLI rather than by the instrumented unit run. */
190
194
  if (import.meta.main) {
191
195
  process.on('uncaughtException', (error) => {
192
196
  process.exit(reportFatal(error));
@@ -196,3 +200,4 @@ if (import.meta.main) {
196
200
  });
197
201
  process.exitCode = await main(process.argv.slice(2));
198
202
  }
203
+ /* v8 ignore stop */
@@ -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
@@ -34,6 +34,7 @@ export function isSourceFile(path) {
34
34
  */
35
35
  export function isModelVisibleText(path) {
36
36
  const segments = path.split('/');
37
+ /* v8 ignore next -- `split` returns at least one element for any string. */
37
38
  const base = segments.at(-1) ?? '';
38
39
  if (base === 'SKILL.md' || base === 'AGENTS.md' || base === 'CLAUDE.md')
39
40
  return true;
@@ -49,6 +50,7 @@ export function isModelVisibleText(path) {
49
50
  * @returns true for a cordis YAML file.
50
51
  */
51
52
  export function isCordisConfigFile(path) {
53
+ /* v8 ignore next -- `split` returns at least one element for any string. */
52
54
  const base = path.split('/').at(-1) ?? '';
53
55
  return /cordis/.test(base) && (base.endsWith('.yml') || base.endsWith('.yaml'));
54
56
  }
package/lib/injection.js CHANGED
@@ -62,6 +62,22 @@ export const INJECTION_RULES = [
62
62
  pattern: /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]|[\u{E0000}-\u{E007F}]/u,
63
63
  meaning: 'contains zero-width or bidirectional-control characters, which change what a human reader sees but not what the model reads',
64
64
  },
65
+ {
66
+ // Keyed on a run of four, not on a single selector. One selector is
67
+ // ordinary: U+FE0F and U+FE0E pick the emoji or text presentation of the
68
+ // character before them, and the U+E01xx plane carries the Ideographic
69
+ // Variation Sequences that CJK text uses, so firing on one would fire on
70
+ // every document with an emoji in it. Nothing standardised puts four in a
71
+ // row: a variation selector modifies the single character it follows, so a
72
+ // second one has nothing to modify. GlassWorm's five waves encoded
73
+ // executable JavaScript one byte per selector, which makes any real payload
74
+ // an unbroken run of tens to thousands. Four is far below that and above
75
+ // the doubled selectors that copy-paste through an editor produces.
76
+ id: 'variation-selector-payload',
77
+ pattern: /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]{4,}/u,
78
+ meaning: 'contains a run of variation selectors, which occupy no width in any editor, terminal or diff '
79
+ + 'view and can carry an arbitrary encoded payload one byte per selector',
80
+ },
65
81
  {
66
82
  id: 'hidden-html-instruction',
67
83
  pattern: /<!--[^]{0,400}?\b(?:you (?:must|should|are)|instruction|assistant|ignore)\b[^]{0,400}?-->/i,
package/lib/inspect.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * `cordis-yaml.ts`, and its result is discarded without being called.
10
10
  * @module dsh-plugin-inspector/inspect
11
11
  */
12
+ import { readFileSync } from 'node:fs';
12
13
  import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cordis-yaml.js";
13
14
  import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
14
15
  import { HARNESS_REFERENCE } from "./knowledge.js";
@@ -18,8 +19,18 @@ import { loadSource } from "./source.js";
18
19
  import { runTierA } from "./checks/tier-a.js";
19
20
  import { runTierB } from "./checks/tier-b.js";
20
21
  import { NON_DEGRADING_CHECKS, runTierC } from "./checks/tier-c.js";
21
- /** This tool's own version, reported in the JSON document. */
22
- export const TOOL_VERSION = '0.1.0';
22
+ /**
23
+ * This tool's own version, reported in the JSON document, by `--version`, and
24
+ * in the recorded ecosystem measurement.
25
+ *
26
+ * Read from this package's own `package.json` rather than written down a second
27
+ * time. A constant is a copy that only a release checklist keeps honest, and it
28
+ * stopped being honest for two releases: every report claimed `0.1.0` while the
29
+ * published package was `0.2.1`. The manifest sits one directory above this
30
+ * module in the source tree, in `lib/` after a build, and in the published
31
+ * tarball, so the same relative path resolves in all three.
32
+ */
33
+ export const TOOL_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
34
  /** This tool's package name, reported in the JSON document. */
24
35
  export const TOOL_NAME = 'dsh-plugin-inspector';
25
36
  /**
@@ -97,11 +108,13 @@ export function analyze(source, registry) {
97
108
  const patches = [];
98
109
  const patchFailures = [];
99
110
  if (mounted !== null) {
111
+ /* v8 ignore next -- `mounted` is non-null only when `source.files` holds that key. */
100
112
  const text = source.files.get(mounted) ?? '';
101
113
  try {
102
114
  patches.push(parsePatchDocument(mounted, text));
103
115
  }
104
116
  catch (error) {
117
+ /* v8 ignore next -- `parsePatchDocument` reports every refusal as a PatchParseError. */
105
118
  if (!(error instanceof PatchParseError))
106
119
  throw error;
107
120
  patchFailures.push({ file: mounted, error });
package/lib/knowledge.js CHANGED
@@ -291,6 +291,44 @@ export const SKILL_ROOT_CONFIG_KEYS = ['customSkillDirs', 'bundledSkillDir'];
291
291
  export const INSTALL_LIFECYCLE_SCRIPTS = [
292
292
  'preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'preprepare', 'postprepare',
293
293
  ];
294
+ /**
295
+ * Command shapes that make an install lifecycle script the attack rather than
296
+ * the build.
297
+ *
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.
303
+ *
304
+ * Each pattern is chosen against the measured false-positive side rather than
305
+ * against the idea of a build script. The five packages in the pinned corpus
306
+ * that declare a hook run `tsdown`, `npm run build`, `husky`, and
307
+ * `node scripts/prepare.mjs`; running a shipped file is what a build hook is, so
308
+ * that shape is deliberately not a signal here.
309
+ */
310
+ export const LIFECYCLE_SIGNALS = [
311
+ {
312
+ id: 'fetches-remote',
313
+ pattern: /\b(?:curl|wget|Invoke-WebRequest|iwr)\b/i,
314
+ meaning: 'fetches a remote resource at install time, so what runs is not what was published',
315
+ },
316
+ {
317
+ id: 'pipes-to-shell',
318
+ pattern: /\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/,
319
+ meaning: 'pipes its input straight into a shell',
320
+ },
321
+ {
322
+ id: 'evaluates-inline-code',
323
+ pattern: /\b(?:node|deno|bun|ruby|perl)\s+(?:-\S+\s+)*--?e(?:val)?\b|\bpython3?\s+(?:-\S+\s+)*-c\b/,
324
+ meaning: 'evaluates code written on the command line, which no published file records',
325
+ },
326
+ {
327
+ id: 'decodes-payload',
328
+ pattern: /\bbase64\s+(?:-d|-D|--decode)\b|\batob\s*\(|\bBuffer\.from\([^)]*base64/,
329
+ meaning: 'decodes an encoded payload, which is how a command hides what it runs',
330
+ },
331
+ ];
294
332
  /** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
295
333
  export const STATIC_ENTRY_FIELDS = [
296
334
  '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;