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/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
|
@@ -20,6 +20,23 @@ export function isSourceFile(path) {
|
|
|
20
20
|
return false;
|
|
21
21
|
return SOURCE_EXTENSIONS.some(extension => path.endsWith(extension));
|
|
22
22
|
}
|
|
23
|
+
/** Extensions a `binding.gyp` target compiles. */
|
|
24
|
+
const NATIVE_SOURCE_EXTENSIONS = [
|
|
25
|
+
'.c', '.cc', '.cpp', '.cxx', '.h', '.hh', '.hpp', '.hxx', '.m', '.mm', '.s', '.asm',
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Whether a path is C-family source a native build would compile.
|
|
29
|
+
*
|
|
30
|
+
* Used to answer one question about a package that ships a `binding.gyp`: is
|
|
31
|
+
* there anything in it to build. A gyp with no compilable source is a build
|
|
32
|
+
* declaration whose only effect is that a build runs.
|
|
33
|
+
* @param path - package-relative POSIX path.
|
|
34
|
+
* @returns true when the file is C-family source or a header.
|
|
35
|
+
*/
|
|
36
|
+
export function isNativeSource(path) {
|
|
37
|
+
const lower = path.toLowerCase();
|
|
38
|
+
return NATIVE_SOURCE_EXTENSIONS.some(extension => lower.endsWith(extension));
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
41
|
* Whether a path is markdown that can reach the model verbatim.
|
|
25
42
|
*
|
|
@@ -34,6 +51,7 @@ export function isSourceFile(path) {
|
|
|
34
51
|
*/
|
|
35
52
|
export function isModelVisibleText(path) {
|
|
36
53
|
const segments = path.split('/');
|
|
54
|
+
/* v8 ignore next -- `split` returns at least one element for any string. */
|
|
37
55
|
const base = segments.at(-1) ?? '';
|
|
38
56
|
if (base === 'SKILL.md' || base === 'AGENTS.md' || base === 'CLAUDE.md')
|
|
39
57
|
return true;
|
|
@@ -49,6 +67,7 @@ export function isModelVisibleText(path) {
|
|
|
49
67
|
* @returns true for a cordis YAML file.
|
|
50
68
|
*/
|
|
51
69
|
export function isCordisConfigFile(path) {
|
|
70
|
+
/* v8 ignore next -- `split` returns at least one element for any string. */
|
|
52
71
|
const base = path.split('/').at(-1) ?? '';
|
|
53
72
|
return /cordis/.test(base) && (base.endsWith('.yml') || base.endsWith('.yaml'));
|
|
54
73
|
}
|
package/lib/inspect.js
CHANGED
|
@@ -108,11 +108,13 @@ export function analyze(source, registry) {
|
|
|
108
108
|
const patches = [];
|
|
109
109
|
const patchFailures = [];
|
|
110
110
|
if (mounted !== null) {
|
|
111
|
+
/* v8 ignore next -- `mounted` is non-null only when `source.files` holds that key. */
|
|
111
112
|
const text = source.files.get(mounted) ?? '';
|
|
112
113
|
try {
|
|
113
114
|
patches.push(parsePatchDocument(mounted, text));
|
|
114
115
|
}
|
|
115
116
|
catch (error) {
|
|
117
|
+
/* v8 ignore next -- `parsePatchDocument` reports every refusal as a PatchParseError. */
|
|
116
118
|
if (!(error instanceof PatchParseError))
|
|
117
119
|
throw error;
|
|
118
120
|
patchFailures.push({ file: mounted, error });
|
package/lib/knowledge.js
CHANGED
|
@@ -295,12 +295,11 @@ export const INSTALL_LIFECYCLE_SCRIPTS = [
|
|
|
295
295
|
* Command shapes that make an install lifecycle script the attack rather than
|
|
296
296
|
* the build.
|
|
297
297
|
*
|
|
298
|
-
* The
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* command
|
|
303
|
-
* to read.
|
|
298
|
+
* The case this table is for is the one where the command line itself fetches,
|
|
299
|
+
* decodes, or evaluates: the whole attack sits in `package.json` and there is
|
|
300
|
+
* no shipped module to read. A lifecycle hook alone does not distinguish that
|
|
301
|
+
* from a build, which is why the hook is a category at `medium` and only the
|
|
302
|
+
* command raises it.
|
|
304
303
|
*
|
|
305
304
|
* Each pattern is chosen against the measured false-positive side rather than
|
|
306
305
|
* against the idea of a build script. The five packages in the pinned corpus
|
|
@@ -330,6 +329,38 @@ export const LIFECYCLE_SIGNALS = [
|
|
|
330
329
|
meaning: 'decodes an encoded payload, which is how a command hides what it runs',
|
|
331
330
|
},
|
|
332
331
|
];
|
|
332
|
+
/**
|
|
333
|
+
* Every lifecycle signal a command line matches.
|
|
334
|
+
*
|
|
335
|
+
* One entry point rather than the filter written out twice, because the table
|
|
336
|
+
* now grades two different things — a `package.json` lifecycle command (A1) and
|
|
337
|
+
* a `binding.gyp` build step (A24) — and a rule added to it has to reach both.
|
|
338
|
+
* @param command - the command line, or the text that holds one.
|
|
339
|
+
* @returns the matching signals, in table order.
|
|
340
|
+
*/
|
|
341
|
+
export function matchingLifecycleSignals(command) {
|
|
342
|
+
return LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The file `node-gyp` reads, at the package root and nowhere else.
|
|
346
|
+
*
|
|
347
|
+
* npm and pnpm treat its presence as a declaration: a package that ships one
|
|
348
|
+
* and declares no `install` or `preinstall` script gets `node-gyp rebuild` as
|
|
349
|
+
* its install command. That default appears in no field of `package.json`.
|
|
350
|
+
*/
|
|
351
|
+
export const NATIVE_BUILD_FILE = 'binding.gyp';
|
|
352
|
+
/**
|
|
353
|
+
* GYP keys that carry a command line rather than a list of sources to compile.
|
|
354
|
+
*
|
|
355
|
+
* `actions` and `rules` run a program during the build; `postbuilds` runs one
|
|
356
|
+
* after it. A target that only lists `sources`, `include_dirs` and `libraries`
|
|
357
|
+
* compiles code the package shipped and runs nothing else.
|
|
358
|
+
*
|
|
359
|
+
* Matched against the whole file, so a block nested inside a `conditions` arm
|
|
360
|
+
* counts the same as a top-level one — which is the point, because a condition
|
|
361
|
+
* is where a build step goes to be read past.
|
|
362
|
+
*/
|
|
363
|
+
export const GYP_COMMAND_KEYS = /['"](?:actions?|rules?|postbuilds)['"]\s*:/;
|
|
333
364
|
/** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
|
|
334
365
|
export const STATIC_ENTRY_FIELDS = [
|
|
335
366
|
'id', 'name', 'group', 'inject', 'intercept', 'isolate',
|
package/lib/manifest.js
CHANGED
|
@@ -103,6 +103,7 @@ export function parseManifest(text) {
|
|
|
103
103
|
parsed = JSON.parse(text);
|
|
104
104
|
}
|
|
105
105
|
catch (error) {
|
|
106
|
+
/* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
|
|
106
107
|
throw new ManifestError(`package.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
108
|
}
|
|
108
109
|
if (!isRecord(parsed))
|
package/lib/publish.js
CHANGED
|
@@ -54,6 +54,7 @@ function matchSegments(pattern, path) {
|
|
|
54
54
|
}
|
|
55
55
|
if (s === path.length)
|
|
56
56
|
return false;
|
|
57
|
+
/* v8 ignore next -- both indices are checked against their lengths above. */
|
|
57
58
|
if (!matchSegment(pattern[p] ?? '', path[s] ?? ''))
|
|
58
59
|
return false;
|
|
59
60
|
return step(p + 1, s + 1);
|
|
@@ -152,6 +153,7 @@ function ignoreMatches(rule, path) {
|
|
|
152
153
|
return true;
|
|
153
154
|
continue;
|
|
154
155
|
}
|
|
156
|
+
/* v8 ignore next -- `end` runs from 1 to `segments.length`, so the index is in range. */
|
|
155
157
|
if (globMatch(rule.pattern, segments[end - 1] ?? ''))
|
|
156
158
|
return true;
|
|
157
159
|
}
|
package/lib/registry.js
CHANGED
|
@@ -127,6 +127,7 @@ export async function resolvePackage(spec, options = {}) {
|
|
|
127
127
|
document = JSON.parse(body.toString('utf8'));
|
|
128
128
|
}
|
|
129
129
|
catch (error) {
|
|
130
|
+
/* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
|
|
130
131
|
throw new RegistryError(`${url} did not return JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
132
|
}
|
|
132
133
|
const record = asRecord(document);
|
package/lib/report.js
CHANGED
|
@@ -91,6 +91,7 @@ function wrap(text, width) {
|
|
|
91
91
|
current = word;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/* v8 ignore next -- every wrapped string is a finding's detail, and none is empty. */
|
|
94
95
|
if (current !== '')
|
|
95
96
|
lines.push(current);
|
|
96
97
|
return lines;
|
|
@@ -144,9 +145,11 @@ function renderFacts(report, paint) {
|
|
|
144
145
|
? 'yes — the registry marks this package as running one at install time'
|
|
145
146
|
: 'no — the registry does not mark this package as running one'],
|
|
146
147
|
],
|
|
148
|
+
/* v8 ignore start -- `mountsAsBundle` is true exactly when the manifest declared a path. */
|
|
147
149
|
['mounted layer', facts.mountsAsBundle
|
|
148
150
|
? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
|
|
149
151
|
: 'no — installs as a plain library, and dsh plugin add prints a warning saying so'],
|
|
152
|
+
/* v8 ignore stop */
|
|
150
153
|
['browser bundle', facts.shipsClientBundle ? 'yes — dsh.client with an ./client export, executed in the user\'s browser' : 'no'],
|
|
151
154
|
['rows inserted', facts.insertedRows.length === 0
|
|
152
155
|
? 'none'
|
|
@@ -176,7 +179,9 @@ function renderFacts(report, paint) {
|
|
|
176
179
|
* @returns the rendered text, ending in a newline.
|
|
177
180
|
*/
|
|
178
181
|
export function renderHuman(report, color) {
|
|
182
|
+
/* v8 ignore start -- every call site passes a literal key of COLOR. */
|
|
179
183
|
const paint = (code, text) => color ? `${COLOR[code] ?? ''}${text}${COLOR.reset}` : text;
|
|
184
|
+
/* v8 ignore stop */
|
|
180
185
|
const lines = ['', ...renderFacts(report, paint)];
|
|
181
186
|
if (report.findings.length > 0) {
|
|
182
187
|
const counts = SEVERITIES
|
package/lib/source.js
CHANGED
|
@@ -31,11 +31,19 @@ export const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
|
31
31
|
export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
32
32
|
/** Largest number of files the analyzer will consider. */
|
|
33
33
|
export const MAX_ENTRIES = 10_000;
|
|
34
|
+
/**
|
|
35
|
+
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
36
|
+
*
|
|
37
|
+
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
38
|
+
* one that is has already answered the only question worth asking about it.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_STREAM_BYTES = 8 * MAX_TOTAL_BYTES;
|
|
34
41
|
/** The shipping ceilings. Tests substitute smaller ones to exercise each cap. */
|
|
35
42
|
export const DEFAULT_LIMITS = {
|
|
36
43
|
maxFileBytes: MAX_FILE_BYTES,
|
|
37
44
|
maxTotalBytes: MAX_TOTAL_BYTES,
|
|
38
45
|
maxEntries: MAX_ENTRIES,
|
|
46
|
+
maxStreamBytes: MAX_STREAM_BYTES,
|
|
39
47
|
};
|
|
40
48
|
/** Directories never descended into: not shipped, and not the package's own code. */
|
|
41
49
|
const SKIPPED_DIRECTORIES = new Set([
|
|
@@ -76,6 +84,7 @@ function countEntry(collector, path) {
|
|
|
76
84
|
* @param buffer - the file content.
|
|
77
85
|
*/
|
|
78
86
|
function store(collector, path, buffer) {
|
|
87
|
+
/* v8 ignore next 4 -- both callers check the size before reading; this is the same ceiling held at the last point that could still allocate. */
|
|
79
88
|
if (buffer.byteLength > collector.limits.maxFileBytes) {
|
|
80
89
|
collector.skipped.push({ path, reason: 'size-cap' });
|
|
81
90
|
return;
|
|
@@ -170,6 +179,7 @@ function stripRoot(entryPath) {
|
|
|
170
179
|
if (slash < 0)
|
|
171
180
|
return undefined;
|
|
172
181
|
const remainder = normalized.slice(slash + 1);
|
|
182
|
+
/* v8 ignore next -- an entry name ending in `/` is a directory entry, which the parser never hands to this. */
|
|
173
183
|
if (remainder === '')
|
|
174
184
|
return undefined;
|
|
175
185
|
const segments = [];
|
|
@@ -186,13 +196,6 @@ function stripRoot(entryPath) {
|
|
|
186
196
|
}
|
|
187
197
|
return segments.length === 0 ? undefined : segments.join('/');
|
|
188
198
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
191
|
-
*
|
|
192
|
-
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
193
|
-
* one that is has already answered the only question worth asking about it.
|
|
194
|
-
*/
|
|
195
|
-
export const MAX_STREAM_BYTES = 8 * MAX_TOTAL_BYTES;
|
|
196
199
|
/**
|
|
197
200
|
* A stage that fails the pipeline once the decompressed stream passes a
|
|
198
201
|
* ceiling. Nothing downstream keeps the bytes, but *producing* eight gigabytes
|
|
@@ -308,7 +311,7 @@ async function readTarStream(bytes, gzipped, collector) {
|
|
|
308
311
|
},
|
|
309
312
|
});
|
|
310
313
|
const inflate = gzipped ? createGunzip() : new PassThrough();
|
|
311
|
-
await pipeline(bytes, inflate, byteCeiling(
|
|
314
|
+
await pipeline(bytes, inflate, byteCeiling(collector.limits.maxStreamBytes), parser);
|
|
312
315
|
await Promise.all(pending);
|
|
313
316
|
if (failure !== null)
|
|
314
317
|
throw failure;
|
|
@@ -14,6 +14,26 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Finding } from '../model.ts';
|
|
16
16
|
import type { CheckInput } from './input.ts';
|
|
17
|
+
/** One filesystem location that holds credentials, and how it is spelled. */
|
|
18
|
+
export interface CredentialPath {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** Pattern source, matched case-insensitively anywhere in a string literal. */
|
|
21
|
+
readonly pattern: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Filesystem locations that hold credentials.
|
|
25
|
+
*
|
|
26
|
+
* A table rather than one regular expression so each location can be pinned by
|
|
27
|
+
* name: `tests/unit/rule-tables.spec.ts` iterates this export, and a location
|
|
28
|
+
* added without a fixture fails there.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CREDENTIAL_PATHS: readonly CredentialPath[];
|
|
31
|
+
/**
|
|
32
|
+
* Whether a string names a location that holds credentials.
|
|
33
|
+
* @param text - the literal text of a string in shipped source.
|
|
34
|
+
* @returns true when it names one of {@link CREDENTIAL_PATHS}.
|
|
35
|
+
*/
|
|
36
|
+
export declare function matchesCredentialPath(text: string): boolean;
|
|
17
37
|
/**
|
|
18
38
|
* Run every Tier B check.
|
|
19
39
|
* @param input - the decoded package.
|
|
@@ -14,18 +14,19 @@
|
|
|
14
14
|
* from claiming nothing was found.
|
|
15
15
|
* @module dsh-plugin-inspector/checks/tier-c
|
|
16
16
|
*/
|
|
17
|
-
import type
|
|
17
|
+
import { type Finding } from '../model.ts';
|
|
18
18
|
import type { CheckInput } from './input.ts';
|
|
19
19
|
/**
|
|
20
20
|
* Tier C checks that do **not** make a Tier B negative unreliable.
|
|
21
21
|
*
|
|
22
|
-
* Every other check here says the analyzer could not read something. C3
|
|
23
|
-
* the opposite: the bytes were read exactly as written and exactly as
|
|
24
|
-
* run — what cannot be checked is whether they match the repository
|
|
25
|
-
* claims to have produced them.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* Every other check here says the analyzer could not read something. C3 and C8
|
|
23
|
+
* say the opposite. C3: the bytes were read exactly as written and exactly as
|
|
24
|
+
* they will run — what cannot be checked is whether they match the repository
|
|
25
|
+
* that claims to have produced them. Treating that as an unreadable package
|
|
26
|
+
* marks every ordinary published tarball `degraded`, because shipping built
|
|
27
|
+
* output and no source is what publishing a package *is*. C8: the escape is
|
|
28
|
+
* resolved by the parser before any check reads the name, so the analysis of an
|
|
29
|
+
* escaped identifier is exactly as good as the analysis of a plain one.
|
|
29
30
|
*/
|
|
30
31
|
export declare const NON_DEGRADING_CHECKS: ReadonlySet<string>;
|
|
31
32
|
/**
|
|
@@ -82,10 +82,23 @@ export interface PatchDocument {
|
|
|
82
82
|
* means the layer was read in part, which Tier C reports.
|
|
83
83
|
*/
|
|
84
84
|
readonly limit: WalkLimit;
|
|
85
|
+
/**
|
|
86
|
+
* True when the layer reached at least one node twice, which is what a YAML
|
|
87
|
+
* alias does and what nothing else does. Tier C reports it, because a reader
|
|
88
|
+
* of the file sees one row where the loader sees two.
|
|
89
|
+
*/
|
|
90
|
+
readonly aliased: boolean;
|
|
85
91
|
}
|
|
86
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* The two ceilings that make reading a patch layer terminate.
|
|
94
|
+
*
|
|
95
|
+
* They apply to {@link expandAliases}, and through it to everything downstream:
|
|
96
|
+
* the walk runs over the tree the expansion produced, which holds at most
|
|
97
|
+
* `MAX_WALK_NODES` nodes nested at most `MAX_WALK_DEPTH` deep, so the walk needs
|
|
98
|
+
* no ceiling of its own.
|
|
99
|
+
*/
|
|
87
100
|
export declare const MAX_WALK_NODES = 200000;
|
|
88
|
-
/** Nesting one patch layer may reach before the
|
|
101
|
+
/** Nesting one patch layer may reach before the reader gives up. */
|
|
89
102
|
export declare const MAX_WALK_DEPTH = 200;
|
|
90
103
|
/** Thrown when the patch file cannot be parsed as an entry list. */
|
|
91
104
|
export declare class PatchParseError extends Error {
|
package/lib/types/files.d.ts
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* @returns true when the file should be parsed.
|
|
11
11
|
*/
|
|
12
12
|
export declare function isSourceFile(path: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a path is C-family source a native build would compile.
|
|
15
|
+
*
|
|
16
|
+
* Used to answer one question about a package that ships a `binding.gyp`: is
|
|
17
|
+
* there anything in it to build. A gyp with no compilable source is a build
|
|
18
|
+
* declaration whose only effect is that a build runs.
|
|
19
|
+
* @param path - package-relative POSIX path.
|
|
20
|
+
* @returns true when the file is C-family source or a header.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isNativeSource(path: string): boolean;
|
|
13
23
|
/**
|
|
14
24
|
* Whether a path is markdown that can reach the model verbatim.
|
|
15
25
|
*
|
package/lib/types/knowledge.d.ts
CHANGED
|
@@ -121,12 +121,11 @@ export interface LifecycleSignal {
|
|
|
121
121
|
* Command shapes that make an install lifecycle script the attack rather than
|
|
122
122
|
* the build.
|
|
123
123
|
*
|
|
124
|
-
* The
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* command
|
|
129
|
-
* to read.
|
|
124
|
+
* The case this table is for is the one where the command line itself fetches,
|
|
125
|
+
* decodes, or evaluates: the whole attack sits in `package.json` and there is
|
|
126
|
+
* no shipped module to read. A lifecycle hook alone does not distinguish that
|
|
127
|
+
* from a build, which is why the hook is a category at `medium` and only the
|
|
128
|
+
* command raises it.
|
|
130
129
|
*
|
|
131
130
|
* Each pattern is chosen against the measured false-positive side rather than
|
|
132
131
|
* against the idea of a build script. The five packages in the pinned corpus
|
|
@@ -135,6 +134,36 @@ export interface LifecycleSignal {
|
|
|
135
134
|
* that shape is deliberately not a signal here.
|
|
136
135
|
*/
|
|
137
136
|
export declare const LIFECYCLE_SIGNALS: readonly LifecycleSignal[];
|
|
137
|
+
/**
|
|
138
|
+
* Every lifecycle signal a command line matches.
|
|
139
|
+
*
|
|
140
|
+
* One entry point rather than the filter written out twice, because the table
|
|
141
|
+
* now grades two different things — a `package.json` lifecycle command (A1) and
|
|
142
|
+
* a `binding.gyp` build step (A24) — and a rule added to it has to reach both.
|
|
143
|
+
* @param command - the command line, or the text that holds one.
|
|
144
|
+
* @returns the matching signals, in table order.
|
|
145
|
+
*/
|
|
146
|
+
export declare function matchingLifecycleSignals(command: string): LifecycleSignal[];
|
|
147
|
+
/**
|
|
148
|
+
* The file `node-gyp` reads, at the package root and nowhere else.
|
|
149
|
+
*
|
|
150
|
+
* npm and pnpm treat its presence as a declaration: a package that ships one
|
|
151
|
+
* and declares no `install` or `preinstall` script gets `node-gyp rebuild` as
|
|
152
|
+
* its install command. That default appears in no field of `package.json`.
|
|
153
|
+
*/
|
|
154
|
+
export declare const NATIVE_BUILD_FILE = "binding.gyp";
|
|
155
|
+
/**
|
|
156
|
+
* GYP keys that carry a command line rather than a list of sources to compile.
|
|
157
|
+
*
|
|
158
|
+
* `actions` and `rules` run a program during the build; `postbuilds` runs one
|
|
159
|
+
* after it. A target that only lists `sources`, `include_dirs` and `libraries`
|
|
160
|
+
* compiles code the package shipped and runs nothing else.
|
|
161
|
+
*
|
|
162
|
+
* Matched against the whole file, so a block nested inside a `conditions` arm
|
|
163
|
+
* counts the same as a top-level one — which is the point, because a condition
|
|
164
|
+
* is where a build step goes to be read past.
|
|
165
|
+
*/
|
|
166
|
+
export declare const GYP_COMMAND_KEYS: RegExp;
|
|
138
167
|
/** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
|
|
139
168
|
export declare const STATIC_ENTRY_FIELDS: readonly string[];
|
|
140
169
|
/**
|
package/lib/types/source.d.ts
CHANGED
|
@@ -26,11 +26,20 @@ export declare const MAX_FILE_BYTES: number;
|
|
|
26
26
|
export declare const MAX_TOTAL_BYTES: number;
|
|
27
27
|
/** Largest number of files the analyzer will consider. */
|
|
28
28
|
export declare const MAX_ENTRIES = 10000;
|
|
29
|
+
/**
|
|
30
|
+
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
31
|
+
*
|
|
32
|
+
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
33
|
+
* one that is has already answered the only question worth asking about it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const MAX_STREAM_BYTES: number;
|
|
29
36
|
/** The resource ceilings one read runs under. */
|
|
30
37
|
export interface ReadLimits {
|
|
31
38
|
readonly maxFileBytes: number;
|
|
32
39
|
readonly maxTotalBytes: number;
|
|
33
40
|
readonly maxEntries: number;
|
|
41
|
+
/** Decompressed tar bytes one tarball may produce before the read is abandoned. */
|
|
42
|
+
readonly maxStreamBytes: number;
|
|
34
43
|
}
|
|
35
44
|
/** The shipping ceilings. Tests substitute smaller ones to exercise each cap. */
|
|
36
45
|
export declare const DEFAULT_LIMITS: ReadLimits;
|
|
@@ -58,13 +67,6 @@ export interface PluginSource {
|
|
|
58
67
|
/** Working-tree files npm would not publish, and which were therefore not read. */
|
|
59
68
|
readonly unpublishedFiles: number;
|
|
60
69
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Decompressed tar bytes one tarball may produce before the read is abandoned.
|
|
63
|
-
*
|
|
64
|
-
* Eight times the in-memory ceiling. A plugin tarball is never this large, and
|
|
65
|
-
* one that is has already answered the only question worth asking about it.
|
|
66
|
-
*/
|
|
67
|
-
export declare const MAX_STREAM_BYTES: number;
|
|
68
70
|
/**
|
|
69
71
|
* Read the package under analysis.
|
|
70
72
|
* @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-inspector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Know what a DeepSeek Harness plugin does before you install it — static pre-install analysis of a plugin directory or tarball",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko",
|