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.
- package/README.md +47 -388
- package/lib/checks/tier-a.js +95 -8
- package/lib/checks/tier-b.js +38 -6
- package/lib/checks/tier-c.js +116 -19
- package/lib/cli.js +5 -0
- package/lib/cordis-yaml.js +78 -42
- package/lib/files.js +19 -0
- package/lib/inspect.js +2 -0
- package/lib/knowledge.js +37 -6
- package/lib/manifest.js +1 -0
- package/lib/publish.js +2 -0
- package/lib/registry.js +1 -0
- package/lib/report.js +5 -0
- package/lib/source.js +11 -8
- package/lib/types/checks/tier-b.d.ts +20 -0
- package/lib/types/checks/tier-c.d.ts +9 -8
- package/lib/types/cordis-yaml.d.ts +15 -2
- package/lib/types/files.d.ts +10 -0
- package/lib/types/knowledge.d.ts +35 -6
- package/lib/types/source.d.ts +9 -7
- package/package.json +1 -1
package/lib/checks/tier-a.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* @module dsh-plugin-inspector/checks/tier-a
|
|
10
10
|
*/
|
|
11
11
|
import { isJsExpr } from "../cordis-yaml.js";
|
|
12
|
-
import { boundedJson, lineColumn, normalizePackagePath, snippet } from "../files.js";
|
|
12
|
+
import { boundedJson, isNativeSource, lineColumn, normalizePackagePath, snippet } from "../files.js";
|
|
13
13
|
import { scanInjection } from "../injection.js";
|
|
14
|
-
import { CORE_ROWS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS,
|
|
14
|
+
import { CORE_ROWS, GYP_COMMAND_KEYS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS, MCP_CLIENT_PACKAGE, NATIVE_BUILD_FILE, SECURITY_ROW_IDS, SECURITY_SEAM_KEYS, SEAM_KEYS, SKILL_FILESYSTEM_ROW, SKILL_ROOT_CONFIG_KEYS, matchingLifecycleSignals, } from "../knowledge.js";
|
|
15
15
|
import { declaredPackages } from "../manifest.js";
|
|
16
16
|
/**
|
|
17
17
|
* Checks that read a Cordis patch row. None of them may produce a finding
|
|
@@ -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
|
|
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:
|
|
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
|
-
+ (
|
|
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 =
|
|
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
|
|
442
|
-
+ '
|
|
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),
|
package/lib/checks/tier-b.js
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
|
|
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)) &&
|
|
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:
|
|
460
|
-
+
|
|
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.',
|
package/lib/checks/tier-c.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import ts from 'typescript';
|
|
18
18
|
import { lineColumn, snippet } from "../files.js";
|
|
19
|
+
import { MAX_EXAMPLES } from "../model.js";
|
|
19
20
|
/** A line longer than this is not written by hand. */
|
|
20
21
|
const MINIFIED_LINE_LENGTH = 500;
|
|
21
22
|
/** Below this many bytes, a low line count says nothing. */
|
|
@@ -35,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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Parse every shipped source file once.
|
|
41
|
+
* @param input - the decoded package.
|
|
42
|
+
* @returns one entry per source file, in `sourceFiles` order.
|
|
43
|
+
*/
|
|
44
|
+
function parseSources(input) {
|
|
45
|
+
const files = [];
|
|
41
46
|
for (const path of input.sourceFiles) {
|
|
42
47
|
const text = input.source.files.get(path);
|
|
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(
|
|
89
|
+
function checkDynamicDispatch(files) {
|
|
75
90
|
const findings = [];
|
|
76
|
-
for (const path of
|
|
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
|
|
196
|
-
* the opposite: the bytes were read exactly as written and exactly as
|
|
197
|
-
* run — what cannot be checked is whether they match the repository
|
|
198
|
-
* claims to have produced them.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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(
|
|
289
|
-
...checkDynamicDispatch(
|
|
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 */
|