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.
- package/README.md +48 -392
- package/lib/checks/tier-a.js +18 -7
- package/lib/checks/tier-b.js +38 -6
- package/lib/checks/tier-c.js +25 -0
- package/lib/cli.js +5 -0
- package/lib/cordis-yaml.js +78 -42
- package/lib/files.js +2 -0
- package/lib/injection.js +16 -0
- package/lib/inspect.js +15 -2
- package/lib/knowledge.js +38 -0
- 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/cordis-yaml.d.ts +15 -2
- package/lib/types/inspect.d.ts +12 -2
- package/lib/types/knowledge.d.ts +24 -0
- package/lib/types/source.d.ts +9 -7
- package/package.json +1 -1
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
|
@@ -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 */
|
package/lib/cordis-yaml.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
57
|
+
/** Nesting one patch layer may reach before the reader gives up. */
|
|
50
58
|
export const MAX_WALK_DEPTH = 200;
|
|
51
59
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
406
|
+
let loaded;
|
|
369
407
|
try {
|
|
370
|
-
|
|
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(
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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:
|
|
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
|
-
/**
|
|
22
|
-
|
|
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(
|
|
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;
|