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.
@@ -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
@@ -103,6 +103,7 @@ function coreRowSeverity(id) {
103
103
  */
104
104
  function coreRowOrigin(id) {
105
105
  const row = CORE_ROWS.get(id);
106
+ /* v8 ignore next -- only called once `coreRowSeverity` has found the id in the same map. */
106
107
  if (row === undefined)
107
108
  return 'a shipped bundle';
108
109
  return row.bundles.map(bundle => `@deepseek-ai/dsh-${bundle}`).join(' and ');
@@ -205,16 +206,16 @@ function checkOverriddenRows(input) {
205
206
  const rewritten = override.overriddenKeys.filter(key => key !== 'disabled');
206
207
  if (rewritten.length === 0)
207
208
  continue;
208
- const isSecurity = SECURITY_ROW_IDS.has(override.id);
209
+ const provides = SECURITY_ROW_IDS.get(override.id);
209
210
  findings.push(tierA({
210
211
  checkId: 'A5',
211
212
  name: 'core-row-overridden',
212
213
  subject: `${override.id}:${rewritten.join(',')}`,
213
- severity: isSecurity ? 'high' : 'medium',
214
+ severity: provides === undefined ? 'medium' : 'high',
214
215
  title: `Patch layer rewrites ${rewritten.map(key => `\`${key}\``).join(', ')} on the core row "${override.id}"`,
215
216
  detail: `The row is ${coreName}. Patch overrides are shallow whole-value replacements, not merges, so `
216
217
  + `overriding \`config\` discards that row's entire shipped configuration rather than adding to it.`
217
- + (isSecurity ? ` This row provides ${SECURITY_ROW_IDS.get(override.id) ?? 'a core constraint'}.` : ''),
218
+ + (provides === undefined ? '' : ` This row provides ${provides}.`),
218
219
  evidence: { file: patch.file, path: override.path, snippet: snippet(rewritten.join(', ')) },
219
220
  }));
220
221
  }
@@ -421,8 +422,9 @@ function checkManifest(input) {
421
422
  const { manifest, source } = input;
422
423
  const lifecycle = INSTALL_LIFECYCLE_SCRIPTS.filter(name => name in manifest.scripts);
423
424
  for (const name of lifecycle) {
425
+ /* v8 ignore next -- `name` came from filtering the same object's own keys. */
424
426
  const command = manifest.scripts[name] ?? '';
425
- const signals = LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
427
+ const signals = matchingLifecycleSignals(command);
426
428
  findings.push(tierA({
427
429
  checkId: 'A1',
428
430
  name: 'install-lifecycle-script',
@@ -438,8 +440,8 @@ function checkManifest(input) {
438
440
  + (signals.length === 0
439
441
  ? ''
440
442
  : ` The command ${signals.map(signal => signal.meaning).join(', and ')}. A build hook runs something `
441
- + 'this package shipped and this one does not, which is the shape 21.2 % of malicious npm packages '
442
- + 'take: the whole attack inside `package.json`, with no module to read.'),
443
+ + 'this package shipped; this one does not. The whole of it is in `package.json`, with no module to '
444
+ + 'read.'),
443
445
  evidence: { file: 'package.json', path: `scripts.${name}`, snippet: snippet(command) },
444
446
  }));
445
447
  }
@@ -546,6 +548,88 @@ function checkManifest(input) {
546
548
  }
547
549
  return findings;
548
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
+ }
549
633
  /** A12 — shipped markdown that reaches the model when it is discovered. */
550
634
  function checkModelVisibleText(input) {
551
635
  if (input.modelVisibleFiles.length === 0)
@@ -560,6 +644,7 @@ function checkModelVisibleText(input) {
560
644
  + 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
561
645
  + 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
562
646
  + 'something copies it into the user\'s workspace. The text itself is scored separately by B10.',
647
+ /* v8 ignore next -- the caller returns early on an empty list. */
563
648
  evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
564
649
  })];
565
650
  }
@@ -609,6 +694,7 @@ function checkInjectionText(input) {
609
694
  const findings = [];
610
695
  for (const path of input.modelVisibleFiles) {
611
696
  const text = input.source.files.get(path);
697
+ /* v8 ignore next -- `modelVisibleFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
612
698
  if (text === undefined)
613
699
  continue;
614
700
  for (const match of scanInjection(text)) {
@@ -648,6 +734,7 @@ function checkInjectionText(input) {
648
734
  export function runTierA(input) {
649
735
  const findings = [
650
736
  ...checkManifest(input),
737
+ ...checkNativeBuild(input),
651
738
  ...checkDisabledRows(input),
652
739
  ...checkOverriddenRows(input),
653
740
  ...checkExpressions(input),
@@ -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.',
@@ -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,13 +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);
48
+ /* v8 ignore next -- `sourceFiles` is filtered from `source.files`'s own keys, so the lookup always hits. */
43
49
  if (text === undefined)
44
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) {
45
59
  const lines = text.split('\n');
46
60
  const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
47
61
  const dense = text.length >= MINIFICATION_SIZE_FLOOR && lines.length < 5;
@@ -64,6 +78,7 @@ function checkMinification(input) {
64
78
  + `that long are ${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability `
65
79
  + 'detection reads syntax, and it reads minified syntax no better than a person does. Every Tier B '
66
80
  + 'negative for this package is unreliable while a file like this is in it.',
81
+ /* v8 ignore next -- `split` returns at least one element for any string, so the fallback is unreachable. */
67
82
  evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
68
83
  bypass: 'none — this finding is about the analysis, not about the plugin',
69
84
  }));
@@ -71,13 +86,9 @@ function checkMinification(input) {
71
86
  return findings;
72
87
  }
73
88
  /** C2 — names the analyzer cannot resolve without running the code. */
74
- function checkDynamicDispatch(input) {
89
+ function checkDynamicDispatch(files) {
75
90
  const findings = [];
76
- for (const path of input.sourceFiles) {
77
- const text = input.source.files.get(path);
78
- if (text === undefined)
79
- continue;
80
- const source = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
91
+ for (const { path, text, node: source } of files) {
81
92
  const report = (node, what) => {
82
93
  findings.push(tierC({
83
94
  checkId: 'C2',
@@ -156,9 +167,11 @@ function isDispatchReceiver(node) {
156
167
  function receiverName(node) {
157
168
  if (ts.isIdentifier(node))
158
169
  return node.text;
170
+ /* v8 ignore start -- only called after `isDispatchReceiver`, which accepts these two forms and no other. */
159
171
  if (ts.isPropertyAccessExpression(node))
160
172
  return node.name.text;
161
173
  return '?';
174
+ /* v8 ignore stop */
162
175
  }
163
176
  /**
164
177
  * Whether a node builds a string at runtime rather than naming one. A plain
@@ -189,18 +202,80 @@ function literalOf(node) {
189
202
  return node.text;
190
203
  return null;
191
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
+ }
192
266
  /**
193
267
  * Tier C checks that do **not** make a Tier B negative unreliable.
194
268
  *
195
- * Every other check here says the analyzer could not read something. C3 says
196
- * the opposite: the bytes were read exactly as written and exactly as they will
197
- * run — what cannot be checked is whether they match the repository that
198
- * claims to have produced them. That is worth reporting and it is not a reason
199
- * to distrust the parse, and treating it as one marks every ordinary published
200
- * tarball `degraded`, because shipping built output and no source is what
201
- * 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.
202
277
  */
203
- export const NON_DEGRADING_CHECKS = new Set(['C3']);
278
+ export const NON_DEGRADING_CHECKS = new Set(['C3', 'C8']);
204
279
  /** C3, C6 — shipped build output with nothing to compare it against. */
205
280
  function checkSourcelessBuild(input) {
206
281
  const built = input.sourceFiles.filter(path => /^(?:lib|dist|build|out)\//.test(path));
@@ -217,6 +292,7 @@ function checkSourcelessBuild(input) {
217
292
  detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
218
293
  + 'package to check the build against. Whether the source that produced it matches the repository is not '
219
294
  + 'decidable from here.',
295
+ /* v8 ignore next -- guarded by `built.length > 0` two lines above. */
220
296
  evidence: { file: built[0] ?? '', snippet: snippet(built.slice(0, 5).join(', ')) },
221
297
  bypass: 'none — this finding is about the analysis, not about the plugin',
222
298
  }));
@@ -255,6 +331,7 @@ function checkUnreadableFiles(input) {
255
331
  ? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
256
332
  + 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
257
333
  : 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
334
+ /* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
258
335
  evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
259
336
  bypass: 'none — this finding is about the analysis, not about the plugin',
260
337
  }));
@@ -278,17 +355,37 @@ function checkPatchWalkLimit(input) {
278
355
  bypass: 'none — this finding is about the analysis, not about the plugin',
279
356
  }));
280
357
  }
358
+ /** C7 — a patch layer whose rows are assembled out of YAML anchors and aliases. */
359
+ function checkPatchAliases(input) {
360
+ return input.patches.filter(patch => patch.aliased).map(patch => tierC({
361
+ checkId: 'C7',
362
+ name: 'patch-uses-aliases',
363
+ subject: patch.file,
364
+ severity: 'medium',
365
+ title: `\`${patch.file}\` builds rows out of YAML anchors and aliases`,
366
+ detail: 'An alias is not a copy: `*a` hands the loader the same node again, so one row in the file can be two '
367
+ + 'rows in the composed profile, and the row a reader sees under an inert key can be the row that lands in a '
368
+ + 'live one. This tool expands every alias to its own node before reading the layer, which is what makes the '
369
+ + 'reading match the loader — but the layer a person reviews and the layer that mounts are no longer the same '
370
+ + 'document, and no Tier B negative about this package is claimed while that is true.',
371
+ evidence: { file: patch.file },
372
+ bypass: 'none — this finding is about the analysis, not about the plugin',
373
+ }));
374
+ }
281
375
  /**
282
376
  * Run every Tier C check.
283
377
  * @param input - the decoded package.
284
378
  * @returns findings, unordered.
285
379
  */
286
380
  export function runTierC(input) {
381
+ const files = parseSources(input);
287
382
  return [
288
- ...checkMinification(input),
289
- ...checkDynamicDispatch(input),
383
+ ...checkMinification(files),
384
+ ...checkDynamicDispatch(files),
385
+ ...checkEscapedIdentifiers(files),
290
386
  ...checkSourcelessBuild(input),
291
387
  ...checkUnreadableFiles(input),
292
388
  ...checkPatchWalkLimit(input),
389
+ ...checkPatchAliases(input),
293
390
  ];
294
391
  }
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 */