dsh-plugin-inspector 0.1.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/LICENSE +21 -0
- package/README.md +271 -0
- package/lib/checks/input.js +10 -0
- package/lib/checks/tier-a.js +632 -0
- package/lib/checks/tier-b.js +411 -0
- package/lib/checks/tier-c.js +288 -0
- package/lib/cli.js +154 -0
- package/lib/cordis-yaml.js +393 -0
- package/lib/files.js +131 -0
- package/lib/index.js +22 -0
- package/lib/injection.js +90 -0
- package/lib/inspect.js +168 -0
- package/lib/knowledge.js +321 -0
- package/lib/manifest.js +143 -0
- package/lib/model.js +55 -0
- package/lib/publish.js +208 -0
- package/lib/report.js +182 -0
- package/lib/source.js +410 -0
- package/lib/types/checks/input.d.ts +42 -0
- package/lib/types/checks/tier-a.d.ts +24 -0
- package/lib/types/checks/tier-b.d.ts +23 -0
- package/lib/types/checks/tier-c.d.ts +37 -0
- package/lib/types/cli.d.ts +56 -0
- package/lib/types/cordis-yaml.d.ts +133 -0
- package/lib/types/files.d.ts +71 -0
- package/lib/types/index.d.ts +23 -0
- package/lib/types/injection.d.ts +41 -0
- package/lib/types/inspect.d.ts +31 -0
- package/lib/types/knowledge.d.ts +137 -0
- package/lib/types/manifest.d.ts +62 -0
- package/lib/types/model.d.ts +160 -0
- package/lib/types/publish.d.ts +55 -0
- package/lib/types/report.d.ts +27 -0
- package/lib/types/source.d.ts +71 -0
- package/package.json +61 -0
package/lib/source.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the package under analysis without installing, building, or running
|
|
3
|
+
* any part of it.
|
|
4
|
+
*
|
|
5
|
+
* A directory is walked directly. A tarball is decoded **entirely in memory** —
|
|
6
|
+
* nothing is ever written to disk. That is a safety property, not an
|
|
7
|
+
* optimisation: it makes tar path traversal (`../../.ssh/authorized_keys`)
|
|
8
|
+
* structurally impossible rather than something a filter has to catch, and it
|
|
9
|
+
* lets a test assert that analysing a hostile tarball touched no file.
|
|
10
|
+
*
|
|
11
|
+
* Every cap is enforced *while* bytes arrive, not after. A tar entry is
|
|
12
|
+
* abandoned the moment its running total passes {@link MAX_FILE_BYTES}, so a
|
|
13
|
+
* 27 MB tarball holding a 6 GB member costs 27 MB of decompression and no
|
|
14
|
+
* memory at all. Enforcing a cap on an already-materialised buffer is not a
|
|
15
|
+
* cap.
|
|
16
|
+
*
|
|
17
|
+
* Symbolic links are recorded and never followed, for the same reason: a link
|
|
18
|
+
* pointing outside the package is not part of the package.
|
|
19
|
+
* @module dsh-plugin-inspector/source
|
|
20
|
+
*/
|
|
21
|
+
import { createReadStream, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
|
|
22
|
+
import { join, posix, relative, resolve, sep } from 'node:path';
|
|
23
|
+
import { PassThrough, Transform } from 'node:stream';
|
|
24
|
+
import { pipeline } from 'node:stream/promises';
|
|
25
|
+
import { createGunzip } from 'node:zlib';
|
|
26
|
+
import { Parser } from 'tar';
|
|
27
|
+
import { publishSet, TARBALL_PUBLISH_SET } from "./publish.js";
|
|
28
|
+
/** Largest single file the analyzer will hold in memory. */
|
|
29
|
+
export const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
/** Largest total payload the analyzer will hold in memory. */
|
|
31
|
+
export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
32
|
+
/** Largest number of files the analyzer will consider. */
|
|
33
|
+
export const MAX_ENTRIES = 10_000;
|
|
34
|
+
/** The shipping ceilings. Tests substitute smaller ones to exercise each cap. */
|
|
35
|
+
export const DEFAULT_LIMITS = {
|
|
36
|
+
maxFileBytes: MAX_FILE_BYTES,
|
|
37
|
+
maxTotalBytes: MAX_TOTAL_BYTES,
|
|
38
|
+
maxEntries: MAX_ENTRIES,
|
|
39
|
+
};
|
|
40
|
+
/** Directories never descended into: not shipped, and not the package's own code. */
|
|
41
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
42
|
+
'node_modules', '.git', '.pnpm-store', '.yarn', 'coverage', '.nyc_output',
|
|
43
|
+
]);
|
|
44
|
+
/** Thrown when the target cannot be read at all, which is exit code 2, not a finding. */
|
|
45
|
+
export class SourceError extends Error {
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether a buffer looks like binary content. A NUL byte in the first 8 KiB is
|
|
49
|
+
* the same cheap test `grep` and `git` use, and it is enough here: the point is
|
|
50
|
+
* only to avoid parsing bytes that are not source.
|
|
51
|
+
* @param buffer - the file content.
|
|
52
|
+
* @returns true when the file should be treated as opaque.
|
|
53
|
+
*/
|
|
54
|
+
function isBinary(buffer) {
|
|
55
|
+
return buffer.subarray(0, 8192).includes(0);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Charge one file against the entry count.
|
|
59
|
+
* @param collector - the shared accumulator.
|
|
60
|
+
* @param path - package-relative POSIX path.
|
|
61
|
+
* @returns true when the analyzer may go on to read it.
|
|
62
|
+
*/
|
|
63
|
+
function countEntry(collector, path) {
|
|
64
|
+
if (collector.entries >= collector.limits.maxEntries) {
|
|
65
|
+
collector.skipped.push({ path, reason: 'entry-cap' });
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
collector.entries += 1;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Record one already-read file against the size caps, storing its text when it
|
|
73
|
+
* is readable.
|
|
74
|
+
* @param collector - the shared accumulator.
|
|
75
|
+
* @param path - package-relative POSIX path.
|
|
76
|
+
* @param buffer - the file content.
|
|
77
|
+
*/
|
|
78
|
+
function store(collector, path, buffer) {
|
|
79
|
+
if (buffer.byteLength > collector.limits.maxFileBytes) {
|
|
80
|
+
collector.skipped.push({ path, reason: 'size-cap' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (collector.bytes + buffer.byteLength > collector.limits.maxTotalBytes) {
|
|
84
|
+
collector.skipped.push({ path, reason: 'total-cap' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (isBinary(buffer)) {
|
|
88
|
+
collector.skipped.push({ path, reason: 'binary' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
collector.bytes += buffer.byteLength;
|
|
92
|
+
collector.files.set(path, buffer.toString('utf8'));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Walk a directory tree, never following symlinks, never descending into build
|
|
96
|
+
* or dependency directories, and reading only what the package would publish.
|
|
97
|
+
* @param root - absolute package root.
|
|
98
|
+
* @param directory - absolute directory currently being read.
|
|
99
|
+
* @param collector - the shared accumulator.
|
|
100
|
+
* @param published - the publish-set membership test.
|
|
101
|
+
*/
|
|
102
|
+
function walkDirectory(root, directory, collector, published) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
collector.skipped.push({ path: toPackagePath(root, directory), reason: 'unreadable' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
112
|
+
const absolute = join(directory, entry.name);
|
|
113
|
+
const path = toPackagePath(root, absolute);
|
|
114
|
+
if (entry.isSymbolicLink()) {
|
|
115
|
+
if (published.includes(path))
|
|
116
|
+
collector.skipped.push({ path, reason: 'unreadable' });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
if (SKIPPED_DIRECTORIES.has(entry.name))
|
|
121
|
+
continue;
|
|
122
|
+
walkDirectory(root, absolute, collector, published);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!entry.isFile())
|
|
126
|
+
continue;
|
|
127
|
+
if (!published.includes(path)) {
|
|
128
|
+
collector.unpublished += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!countEntry(collector, path))
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
// Size first, then read. `readFileSync` on an oversized file materialises
|
|
135
|
+
// it before any cap can reject it, which is the same mistake on the
|
|
136
|
+
// directory path that the tar reader had on the tarball path.
|
|
137
|
+
if (statSync(absolute).size > collector.limits.maxFileBytes) {
|
|
138
|
+
collector.skipped.push({ path, reason: 'size-cap' });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
store(collector, path, readFileSync(absolute));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
collector.skipped.push({ path, reason: 'unreadable' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Convert an absolute path inside the package to the POSIX package-relative
|
|
150
|
+
* form every report and finding uses.
|
|
151
|
+
* @param root - absolute package root.
|
|
152
|
+
* @param absolute - absolute path inside it.
|
|
153
|
+
* @returns the package-relative POSIX path.
|
|
154
|
+
*/
|
|
155
|
+
function toPackagePath(root, absolute) {
|
|
156
|
+
return relative(root, absolute).split(sep).join(posix.sep);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Drop the tarball's single leading directory, which npm always sets to
|
|
160
|
+
* `package/`, so tarball paths and directory paths are directly comparable, and
|
|
161
|
+
* reject anything that still climbs out afterwards. Nothing is written from a
|
|
162
|
+
* tar entry, but a finding whose `file` reads `../../../../etc/passwd` claims a
|
|
163
|
+
* location the package does not have.
|
|
164
|
+
* @param entryPath - the raw tar entry path.
|
|
165
|
+
* @returns the package-relative path, or `undefined` when there is none.
|
|
166
|
+
*/
|
|
167
|
+
function stripRoot(entryPath) {
|
|
168
|
+
const normalized = entryPath.replace(/^\.\//, '');
|
|
169
|
+
const slash = normalized.indexOf('/');
|
|
170
|
+
if (slash < 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
const remainder = normalized.slice(slash + 1);
|
|
173
|
+
if (remainder === '')
|
|
174
|
+
return undefined;
|
|
175
|
+
const segments = [];
|
|
176
|
+
for (const segment of remainder.split('/')) {
|
|
177
|
+
if (segment === '' || segment === '.')
|
|
178
|
+
continue;
|
|
179
|
+
if (segment === '..') {
|
|
180
|
+
if (segments.length === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
segments.pop();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
segments.push(segment);
|
|
186
|
+
}
|
|
187
|
+
return segments.length === 0 ? undefined : segments.join('/');
|
|
188
|
+
}
|
|
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
|
+
/**
|
|
197
|
+
* A stage that fails the pipeline once the decompressed stream passes a
|
|
198
|
+
* ceiling. Nothing downstream keeps the bytes, but *producing* eight gigabytes
|
|
199
|
+
* from a 28 MB file still costs the time to inflate them, and a CI job that
|
|
200
|
+
* hangs for a minute on a hostile input is a denial of service with extra
|
|
201
|
+
* steps.
|
|
202
|
+
* @param limit - the ceiling in bytes.
|
|
203
|
+
* @returns the counting stage.
|
|
204
|
+
*/
|
|
205
|
+
function byteCeiling(limit) {
|
|
206
|
+
let total = 0;
|
|
207
|
+
return new Transform({
|
|
208
|
+
transform(chunk, _encoding, callback) {
|
|
209
|
+
total += chunk.byteLength;
|
|
210
|
+
if (total > limit) {
|
|
211
|
+
callback(new SourceError(`decompresses to more than ${limit} bytes`));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
callback(null, chunk);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Whether a file begins with the gzip magic number, so a plain `.tar` is read
|
|
220
|
+
* as one rather than failing in the inflater.
|
|
221
|
+
* @param file - absolute path.
|
|
222
|
+
* @returns true when the file is gzipped.
|
|
223
|
+
*/
|
|
224
|
+
function isGzip(file) {
|
|
225
|
+
const handle = openSync(file, 'r');
|
|
226
|
+
try {
|
|
227
|
+
const header = Buffer.alloc(2);
|
|
228
|
+
readSync(handle, header, 0, 2, 0);
|
|
229
|
+
return header[0] === 0x1f && header[1] === 0x8b;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
closeSync(handle);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Decode an npm tarball into memory. The tar reader is only ever a `Parser`;
|
|
237
|
+
* no extraction call exists in this module, so there is no code path that can
|
|
238
|
+
* write a file.
|
|
239
|
+
*
|
|
240
|
+
* The pipeline is assembled by hand rather than through `tar.list({ file })`
|
|
241
|
+
* because that convenience path applies no backpressure between the inflater
|
|
242
|
+
* and the parser: the inflater runs ahead, and a tarball holding one very large
|
|
243
|
+
* member materialises gigabytes of it whatever the entry consumer does. A
|
|
244
|
+
* `stream.pipeline` of Node streams paces the inflater against the parser, and
|
|
245
|
+
* measured on the same 28 MB probe it holds the process at 96 MB instead of
|
|
246
|
+
* 4 GB.
|
|
247
|
+
*
|
|
248
|
+
* Errors thrown from the entry callbacks reach an EventEmitter, not the
|
|
249
|
+
* `await`, so every one of them is captured here and rethrown on the awaited
|
|
250
|
+
* path — an emitter-thrown `RangeError` that escapes becomes an uncaught
|
|
251
|
+
* exception and a raw stack trace on a user's terminal.
|
|
252
|
+
* @param file - absolute path to the `.tgz`.
|
|
253
|
+
* @param collector - the shared accumulator.
|
|
254
|
+
*/
|
|
255
|
+
async function readTarball(file, collector) {
|
|
256
|
+
const pending = [];
|
|
257
|
+
let failure = null;
|
|
258
|
+
const parser = new Parser({
|
|
259
|
+
onReadEntry: (entry) => {
|
|
260
|
+
if (entry.type !== 'File') {
|
|
261
|
+
entry.resume();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const path = stripRoot(String(entry.path));
|
|
265
|
+
if (path === undefined) {
|
|
266
|
+
collector.skipped.push({ path: String(entry.path), reason: 'unreadable' });
|
|
267
|
+
entry.resume();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!countEntry(collector, path)) {
|
|
271
|
+
entry.resume();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
pending.push(new Promise((done) => {
|
|
275
|
+
let chunks = [];
|
|
276
|
+
let size = 0;
|
|
277
|
+
let abandoned = null;
|
|
278
|
+
const abandon = (reason) => {
|
|
279
|
+
abandoned = reason;
|
|
280
|
+
chunks = [];
|
|
281
|
+
entry.resume();
|
|
282
|
+
};
|
|
283
|
+
entry.on('data', (chunk) => {
|
|
284
|
+
if (abandoned !== null)
|
|
285
|
+
return;
|
|
286
|
+
size += chunk.byteLength;
|
|
287
|
+
if (size > collector.limits.maxFileBytes)
|
|
288
|
+
return abandon('size-cap');
|
|
289
|
+
if (collector.bytes + size > collector.limits.maxTotalBytes)
|
|
290
|
+
return abandon('total-cap');
|
|
291
|
+
chunks.push(chunk);
|
|
292
|
+
});
|
|
293
|
+
entry.on('end', () => {
|
|
294
|
+
try {
|
|
295
|
+
if (abandoned === null)
|
|
296
|
+
store(collector, path, Buffer.concat(chunks));
|
|
297
|
+
else
|
|
298
|
+
collector.skipped.push({ path, reason: abandoned });
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
failure ??= error;
|
|
302
|
+
}
|
|
303
|
+
chunks = [];
|
|
304
|
+
done();
|
|
305
|
+
});
|
|
306
|
+
}));
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const inflate = isGzip(file) ? createGunzip() : new PassThrough();
|
|
310
|
+
await pipeline(createReadStream(file), inflate, byteCeiling(MAX_STREAM_BYTES), parser);
|
|
311
|
+
await Promise.all(pending);
|
|
312
|
+
if (failure !== null)
|
|
313
|
+
throw failure;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Read the `files`, `main`, and ignore rules a working tree publishes under,
|
|
317
|
+
* without trusting the manifest to be well formed — a hostile `package.json`
|
|
318
|
+
* whose `files` is a number must not stop the analysis.
|
|
319
|
+
*
|
|
320
|
+
* A manifest that is missing or is not JSON produces no rules rather than an
|
|
321
|
+
* error. Diagnosing that is `parseManifest`'s job and it says something more
|
|
322
|
+
* useful than this function could; failing here would replace "package.json is
|
|
323
|
+
* not valid JSON, at position 4" with "this is not an npm package".
|
|
324
|
+
* @param root - absolute package root.
|
|
325
|
+
* @returns the publish-set membership test.
|
|
326
|
+
*/
|
|
327
|
+
function directoryPublishSet(root) {
|
|
328
|
+
let manifest;
|
|
329
|
+
try {
|
|
330
|
+
manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
manifest = {};
|
|
334
|
+
}
|
|
335
|
+
const record = typeof manifest === 'object' && manifest !== null && !Array.isArray(manifest)
|
|
336
|
+
? manifest
|
|
337
|
+
: {};
|
|
338
|
+
const files = record.files;
|
|
339
|
+
const main = record.main;
|
|
340
|
+
return publishSet({
|
|
341
|
+
files: Array.isArray(files) ? files.filter((entry) => typeof entry === 'string') : null,
|
|
342
|
+
main: typeof main === 'string' ? main : null,
|
|
343
|
+
npmignore: readOptional(join(root, '.npmignore')),
|
|
344
|
+
gitignore: readOptional(join(root, '.gitignore')),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Read a file that may not exist.
|
|
349
|
+
* @param path - absolute path.
|
|
350
|
+
* @returns the text, or `null`.
|
|
351
|
+
*/
|
|
352
|
+
function readOptional(path) {
|
|
353
|
+
try {
|
|
354
|
+
return readFileSync(path, 'utf8');
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Absent, a directory, or unreadable: all three mean "no rules from here".
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Read the package under analysis.
|
|
363
|
+
* @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
|
|
364
|
+
* @param limits - resource ceilings; defaults to {@link DEFAULT_LIMITS}.
|
|
365
|
+
* @returns the decoded package.
|
|
366
|
+
* @throws SourceError when the target does not exist, is not a readable
|
|
367
|
+
* tarball, or holds no `package.json`.
|
|
368
|
+
*/
|
|
369
|
+
export async function loadSource(target, limits = DEFAULT_LIMITS) {
|
|
370
|
+
const path = resolve(target);
|
|
371
|
+
let stats;
|
|
372
|
+
try {
|
|
373
|
+
stats = statSync(path);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
throw new SourceError(`cannot read target: ${path}`);
|
|
377
|
+
}
|
|
378
|
+
const collector = { files: new Map(), skipped: [], limits, bytes: 0, entries: 0, unpublished: 0 };
|
|
379
|
+
const kind = stats.isDirectory() ? 'directory' : 'tarball';
|
|
380
|
+
let published;
|
|
381
|
+
if (kind === 'directory') {
|
|
382
|
+
published = directoryPublishSet(path);
|
|
383
|
+
walkDirectory(path, path, collector, published);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
published = TARBALL_PUBLISH_SET;
|
|
387
|
+
try {
|
|
388
|
+
await readTarball(path, collector);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
392
|
+
throw new SourceError(`cannot read tarball ${path}: ${detail}`);
|
|
393
|
+
}
|
|
394
|
+
if (collector.entries === 0) {
|
|
395
|
+
throw new SourceError(`${path} holds no tar entries — this is not a readable npm tarball`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!collector.files.has('package.json')) {
|
|
399
|
+
throw new SourceError(`no readable package.json in ${path} — this is not an npm package`);
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
kind,
|
|
403
|
+
path,
|
|
404
|
+
files: collector.files,
|
|
405
|
+
skipped: collector.skipped,
|
|
406
|
+
bytesRead: collector.bytes,
|
|
407
|
+
publishBasis: published.basis,
|
|
408
|
+
unpublishedFiles: collector.unpublished,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decoded package every check tier reads from.
|
|
3
|
+
*
|
|
4
|
+
* Parsing happens once, in `inspect.ts`, and each tier receives the result.
|
|
5
|
+
* That keeps the tiers pure functions of already-parsed data — which is what
|
|
6
|
+
* makes "no analysed code was executed" a property of one small module rather
|
|
7
|
+
* than something every check has to be trusted about.
|
|
8
|
+
* @module dsh-plugin-inspector/checks/input
|
|
9
|
+
*/
|
|
10
|
+
import type { PatchDocument, PatchParseError } from '../cordis-yaml.ts';
|
|
11
|
+
import type { PackageManifest } from '../manifest.ts';
|
|
12
|
+
import type { PluginSource } from '../source.ts';
|
|
13
|
+
/** A patch layer that could not be parsed. */
|
|
14
|
+
export interface PatchFailure {
|
|
15
|
+
/** Package-relative path of the YAML file. */
|
|
16
|
+
readonly file: string;
|
|
17
|
+
readonly error: PatchParseError;
|
|
18
|
+
}
|
|
19
|
+
/** Everything the checks see. */
|
|
20
|
+
export interface CheckInput {
|
|
21
|
+
readonly source: PluginSource;
|
|
22
|
+
readonly manifest: PackageManifest;
|
|
23
|
+
/**
|
|
24
|
+
* True when `package.json` declares `dsh.bundle.patch`. False means nothing
|
|
25
|
+
* in this package composes into a profile, which forbids every Tier A
|
|
26
|
+
* patch-row verdict outright — see `runTierA`.
|
|
27
|
+
*/
|
|
28
|
+
readonly mountsAsBundle: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The mounted patch layer, parsed. At most one: `dsh.bundle.patch` names
|
|
31
|
+
* exactly one file and the launcher reads no other.
|
|
32
|
+
*/
|
|
33
|
+
readonly patches: readonly PatchDocument[];
|
|
34
|
+
readonly patchFailures: readonly PatchFailure[];
|
|
35
|
+
/** Cordis YAML the package ships but no manifest key mounts. */
|
|
36
|
+
readonly unmountedPatchFiles: readonly string[];
|
|
37
|
+
/** Package-relative paths of shipped JavaScript and TypeScript source. */
|
|
38
|
+
readonly sourceFiles: readonly string[];
|
|
39
|
+
/** Package-relative paths of shipped skill and agent-instruction markdown. */
|
|
40
|
+
readonly modelVisibleFiles: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=input.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier A — decidable checks over structured declarations.
|
|
3
|
+
*
|
|
4
|
+
* Everything here reads a field the harness itself must read literally in order
|
|
5
|
+
* to act on it: a `package.json` key, or a Cordis patch row. That is why Tier A
|
|
6
|
+
* findings carry `certain` confidence and why they are the only findings this
|
|
7
|
+
* tool treats as verdicts. `disabled: true` cannot be obfuscated and still
|
|
8
|
+
* disable anything.
|
|
9
|
+
* @module dsh-plugin-inspector/checks/tier-a
|
|
10
|
+
*/
|
|
11
|
+
import type { Finding } from '../model.ts';
|
|
12
|
+
import type { CheckInput } from './input.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Run every Tier A check.
|
|
15
|
+
*
|
|
16
|
+
* The filter is the guard: a package that declares no `dsh.bundle.patch`
|
|
17
|
+
* composes into no profile, so no reading of a Cordis row in it can be a
|
|
18
|
+
* verdict about anything. `patches` is already empty in that case; this makes
|
|
19
|
+
* the property structural rather than a consequence of how the input was built.
|
|
20
|
+
* @param input - the decoded package.
|
|
21
|
+
* @returns findings, unordered.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runTierA(input: CheckInput): Finding[];
|
|
24
|
+
//# sourceMappingURL=tier-a.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier B — capability detection over shipped source.
|
|
3
|
+
*
|
|
4
|
+
* Everything here answers "this plugin CAN do X", never "this plugin DOES X".
|
|
5
|
+
* The distinction is load-bearing for B8: finding a credential read and a
|
|
6
|
+
* network call in the same package is not evidence that the credential reaches
|
|
7
|
+
* the socket, and the finding says so.
|
|
8
|
+
*
|
|
9
|
+
* Parsing is `ts.createSourceFile` — syntax only. No program is created, no
|
|
10
|
+
* type checker is instantiated, no module is resolved, nothing is transpiled,
|
|
11
|
+
* and nothing is executed. Every check is a shape match on one AST node, which
|
|
12
|
+
* is also why every check has a one-line bypass, carried in the finding.
|
|
13
|
+
* @module dsh-plugin-inspector/checks/tier-b
|
|
14
|
+
*/
|
|
15
|
+
import type { Finding } from '../model.ts';
|
|
16
|
+
import type { CheckInput } from './input.ts';
|
|
17
|
+
/**
|
|
18
|
+
* Run every Tier B check.
|
|
19
|
+
* @param input - the decoded package.
|
|
20
|
+
* @returns findings, unordered.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runTierB(input: CheckInput): Finding[];
|
|
23
|
+
//# sourceMappingURL=tier-b.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier C — how much of the package the analyzer could actually read.
|
|
3
|
+
*
|
|
4
|
+
* These checks do not describe the plugin's behavior. They describe the limits
|
|
5
|
+
* of the analysis, and that is why a Tier C hit is a finding rather than a
|
|
6
|
+
* silent internal flag: "we cannot read this" is a legitimate result and the
|
|
7
|
+
* user is entitled to see it.
|
|
8
|
+
*
|
|
9
|
+
* A Tier C hit also has a mechanical consequence. Tier B recognises a whitelist
|
|
10
|
+
* of syntactic shapes, so when code is minified, when identifiers are computed,
|
|
11
|
+
* or when the shipped artifact has no readable source, a Tier B *positive* is
|
|
12
|
+
* still true but a Tier B *negative* means nothing. `inspect.ts` reads the
|
|
13
|
+
* output of this module to lower Tier B confidence and to forbid the report
|
|
14
|
+
* from claiming nothing was found.
|
|
15
|
+
* @module dsh-plugin-inspector/checks/tier-c
|
|
16
|
+
*/
|
|
17
|
+
import type { Finding } from '../model.ts';
|
|
18
|
+
import type { CheckInput } from './input.ts';
|
|
19
|
+
/**
|
|
20
|
+
* Tier C checks that do **not** make a Tier B negative unreliable.
|
|
21
|
+
*
|
|
22
|
+
* Every other check here says the analyzer could not read something. C3 says
|
|
23
|
+
* the opposite: the bytes were read exactly as written and exactly as they will
|
|
24
|
+
* run — what cannot be checked is whether they match the repository that
|
|
25
|
+
* claims to have produced them. That is worth reporting and it is not a reason
|
|
26
|
+
* to distrust the parse, and treating it as one marks every ordinary published
|
|
27
|
+
* tarball `degraded`, because shipping built output and no source is what
|
|
28
|
+
* publishing a package *is*.
|
|
29
|
+
*/
|
|
30
|
+
export declare const NON_DEGRADING_CHECKS: ReadonlySet<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Run every Tier C check.
|
|
33
|
+
* @param input - the decoded package.
|
|
34
|
+
* @returns findings, unordered.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runTierC(input: CheckInput): Finding[];
|
|
37
|
+
//# sourceMappingURL=tier-c.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-inspect` — the command line face of the inspector.
|
|
4
|
+
*
|
|
5
|
+
* Exit codes are the CI contract and are deliberately three-valued:
|
|
6
|
+
* `0` clean, `1` findings at or above the threshold, `2` the analysis could not
|
|
7
|
+
* be performed. A job that cannot tell "the analyzer broke" from "the plugin is
|
|
8
|
+
* clean" is the failure mode that split exists to prevent.
|
|
9
|
+
* @module dsh-plugin-inspector/cli
|
|
10
|
+
*/
|
|
11
|
+
import { type Severity } from './model.ts';
|
|
12
|
+
/** Exit codes this tool uses. */
|
|
13
|
+
export declare const EXIT: {
|
|
14
|
+
readonly clean: 0;
|
|
15
|
+
readonly findings: 1;
|
|
16
|
+
readonly unanalysable: 2;
|
|
17
|
+
};
|
|
18
|
+
/** One parsed command line. */
|
|
19
|
+
interface Options {
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly json: boolean;
|
|
22
|
+
readonly failOn: Severity | 'none';
|
|
23
|
+
readonly color: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Raised for a malformed command line; the message is printed and the tool exits 2. */
|
|
26
|
+
export declare class UsageError extends Error {
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse argv.
|
|
30
|
+
* @param argv - arguments after the node binary and script path.
|
|
31
|
+
* @returns the parsed options, or `null` when usage or version was requested.
|
|
32
|
+
* @throws UsageError on an unrecognised or incomplete argument.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseArgs(argv: readonly string[]): Options | null;
|
|
35
|
+
/**
|
|
36
|
+
* Run one invocation.
|
|
37
|
+
* @param argv - arguments after the node binary and script path.
|
|
38
|
+
* @returns the process exit code.
|
|
39
|
+
*/
|
|
40
|
+
export declare function main(argv: readonly string[]): Promise<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Report a failure that reached no `try`, and say which exit code it is.
|
|
43
|
+
*
|
|
44
|
+
* Not every failure can be caught where it happens. A `RangeError` raised
|
|
45
|
+
* inside a stream's `'end'` handler is thrown at an EventEmitter, not at the
|
|
46
|
+
* `await`, so it walks past every `catch` in this program and kills the process
|
|
47
|
+
* with Node's default handler — which exits **1**, the code that means "the
|
|
48
|
+
* analysis completed and found something at or above --fail-on". A CI job then
|
|
49
|
+
* reads a crash as a verdict. The whole point of a separate code 2 is that this
|
|
50
|
+
* cannot happen, so the last resort has to be covered too.
|
|
51
|
+
* @param error - whatever was thrown.
|
|
52
|
+
* @returns the exit code to leave with.
|
|
53
|
+
*/
|
|
54
|
+
export declare function reportFatal(error: unknown): number;
|
|
55
|
+
export {};
|
|
56
|
+
//# sourceMappingURL=cli.d.ts.map
|