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/lib/model.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The report vocabulary: what a finding is, how findings rank, and what the
3
+ * complete inspection document looks like.
4
+ *
5
+ * The document separates `facts` from `findings` deliberately. Facts carry no
6
+ * severity and answer "what does this plugin do"; findings carry severity and
7
+ * answer "what warrants a decision". A well-behaved plugin has a full facts
8
+ * section and an empty findings section — emitting `dsh.bundle` as a finding
9
+ * would fire on every legitimate plugin and train users to ignore the tool.
10
+ * @module dsh-plugin-inspector/model
11
+ */
12
+ /** Severity ordering, ascending. Used for `--fail-on` comparison and ranking. */
13
+ export const SEVERITY_RANK = {
14
+ low: 1,
15
+ medium: 2,
16
+ high: 3,
17
+ critical: 4,
18
+ };
19
+ /** Every severity, most severe first — the order the human report prints in. */
20
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
21
+ /**
22
+ * Order findings for display: most severe first, then by tier (A before B
23
+ * before C, since A carries verdicts), then by check id, then by evidence
24
+ * location. Total and deterministic, so two runs diff cleanly.
25
+ * @param a - left finding.
26
+ * @param b - right finding.
27
+ * @returns negative when `a` sorts first.
28
+ */
29
+ export function compareFindings(a, b) {
30
+ const bySeverity = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
31
+ if (bySeverity !== 0)
32
+ return bySeverity;
33
+ const byTier = a.tier.localeCompare(b.tier);
34
+ if (byTier !== 0)
35
+ return byTier;
36
+ const byCheck = a.checkId.localeCompare(b.checkId, 'en', { numeric: true });
37
+ if (byCheck !== 0)
38
+ return byCheck;
39
+ const byFile = a.evidence.file.localeCompare(b.evidence.file);
40
+ if (byFile !== 0)
41
+ return byFile;
42
+ return (a.evidence.path ?? '').localeCompare(b.evidence.path ?? '');
43
+ }
44
+ /**
45
+ * Count findings per severity, including zeroes, so the JSON summary has a
46
+ * fixed key set that consumers can rely on.
47
+ * @param findings - the findings to tally.
48
+ * @returns one count per severity.
49
+ */
50
+ export function summarize(findings) {
51
+ const summary = { critical: 0, high: 0, medium: 0, low: 0 };
52
+ for (const finding of findings)
53
+ summary[finding.severity] += 1;
54
+ return summary;
55
+ }
package/lib/publish.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Which files of a working tree npm would actually publish.
3
+ *
4
+ * A directory target is a repository checkout, and a repository holds far more
5
+ * than the package: tests, fixtures, CI config, build scratch. None of that is
6
+ * installed, none of it is mounted, and none of it can act on a user. Reporting
7
+ * on it produces findings nobody can act on and buries the ones they can, so
8
+ * the directory reader is scoped to the set npm would put in the tarball.
9
+ *
10
+ * The rules are npm's, transcribed from `npm-packlist`: the `files` allowlist
11
+ * when the manifest declares one, otherwise `.npmignore` — or `.gitignore` when
12
+ * there is no `.npmignore` — over everything else. A handful of files are
13
+ * always published whatever the manifest says, and a handful are never
14
+ * published whatever it says.
15
+ * @module dsh-plugin-inspector/publish
16
+ */
17
+ /** Files npm publishes at the package root regardless of `files` or ignore rules. */
18
+ const ALWAYS_PUBLISHED = /^(?:package\.json|npm-shrinkwrap\.json|(?:readme|licen[cs]e|changelog|notice)(?:\.[^/]*)?)$/i;
19
+ /**
20
+ * Patterns npm refuses to publish whatever the manifest says. Transcribed from
21
+ * `npm-packlist`'s default rule list; the lockfiles and dotfiles are the ones
22
+ * that actually show up in a plugin checkout.
23
+ */
24
+ const NEVER_PUBLISHED = [
25
+ '**/.git/**', '**/.git', '**/.svn/**', '**/.hg/**', '**/CVS/**',
26
+ '**/node_modules/**', '**/node_modules',
27
+ '**/.npmrc', '**/.DS_Store', '**/._*', '**/*.orig', '**/.*.swp',
28
+ 'npm-debug.log', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
29
+ '.lock-wscript', 'build/config.gypi', '.npmignore', '.gitignore',
30
+ ];
31
+ /**
32
+ * Match one glob segment list against one path segment list. Segment-wise with
33
+ * a memo rather than a compiled regular expression: `files` comes from an
34
+ * untrusted manifest, and a memoised walk cannot be made to backtrack.
35
+ * @param pattern - the pattern's segments.
36
+ * @param path - the path's segments.
37
+ * @returns true when the pattern matches the whole path.
38
+ */
39
+ function matchSegments(pattern, path) {
40
+ const seen = new Set();
41
+ const step = (p, s) => {
42
+ const key = p * (path.length + 1) + s;
43
+ if (seen.has(key))
44
+ return false;
45
+ seen.add(key);
46
+ if (p === pattern.length)
47
+ return s === path.length;
48
+ if (pattern[p] === '**') {
49
+ for (let index = s; index <= path.length; index += 1) {
50
+ if (step(p + 1, index))
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ if (s === path.length)
56
+ return false;
57
+ if (!matchSegment(pattern[p] ?? '', path[s] ?? ''))
58
+ return false;
59
+ return step(p + 1, s + 1);
60
+ };
61
+ return step(0, 0);
62
+ }
63
+ /**
64
+ * Match one glob segment, where `*` stops at a separator and `?` takes one
65
+ * character.
66
+ * @param pattern - the pattern segment.
67
+ * @param name - the path segment.
68
+ * @returns true when they match.
69
+ */
70
+ function matchSegment(pattern, name) {
71
+ const source = [...pattern].map((character) => {
72
+ if (character === '*')
73
+ return '[^/]*';
74
+ if (character === '?')
75
+ return '[^/]';
76
+ return character.replace(/[.*+?^${}()|[\]\\]/, '\\$&');
77
+ }).join('');
78
+ return new RegExp(`^${source}$`).test(name);
79
+ }
80
+ /**
81
+ * Whether a glob matches a path.
82
+ * @param pattern - the glob, package-relative and POSIX.
83
+ * @param path - the path, package-relative and POSIX.
84
+ * @returns true when the glob matches.
85
+ */
86
+ export function globMatch(pattern, path) {
87
+ return matchSegments(pattern.split('/'), path.split('/'));
88
+ }
89
+ /**
90
+ * Strip the leading `./` or `/` npm accepts on a `files` entry, both of which
91
+ * mean "from the package root".
92
+ * @param pattern - the raw manifest entry.
93
+ * @returns the root-relative pattern.
94
+ */
95
+ function normalizePattern(pattern) {
96
+ return pattern.replace(/^\.?\//, '').replace(/\/+$/, '');
97
+ }
98
+ /**
99
+ * Whether one `files` entry covers a path. npm treats a bare directory name as
100
+ * that whole subtree, so `"lib"` publishes everything under `lib/`.
101
+ * @param pattern - a normalised `files` entry.
102
+ * @param path - package-relative POSIX path.
103
+ * @returns true when the entry publishes the path.
104
+ */
105
+ function allowlistCovers(pattern, path) {
106
+ if (pattern === '')
107
+ return false;
108
+ if (globMatch(pattern, path))
109
+ return true;
110
+ if (path.startsWith(`${pattern}/`))
111
+ return true;
112
+ return globMatch(`${pattern}/**`, path);
113
+ }
114
+ /**
115
+ * Parse ignore-file text into rules, using git's syntax: `#` comments, `!`
116
+ * negation, a trailing `/` for directories only, and anchoring to the root as
117
+ * soon as the pattern contains an interior separator.
118
+ * @param text - the file's content, or `null` when there is no such file.
119
+ * @returns the rules in declaration order.
120
+ */
121
+ function parseIgnore(text) {
122
+ const rules = [];
123
+ for (const raw of text.split('\n')) {
124
+ const line = raw.replace(/\r$/, '').trim();
125
+ if (line === '' || line.startsWith('#'))
126
+ continue;
127
+ const negated = line.startsWith('!');
128
+ const body = negated ? line.slice(1) : line;
129
+ const directoryOnly = body.endsWith('/');
130
+ const trimmed = body.replace(/\/+$/, '');
131
+ const anchored = trimmed.startsWith('/') || trimmed.includes('/');
132
+ rules.push({ negated, directoryOnly, anchored, pattern: trimmed.replace(/^\//, '') });
133
+ }
134
+ return rules;
135
+ }
136
+ /**
137
+ * Whether an ignore rule matches a path or any directory above it, which is how
138
+ * git decides that `coverage/` hides `coverage/tmp/x.json`.
139
+ * @param rule - the rule.
140
+ * @param path - package-relative POSIX path.
141
+ * @returns true when the rule applies.
142
+ */
143
+ function ignoreMatches(rule, path) {
144
+ const segments = path.split('/');
145
+ for (let end = 1; end <= segments.length; end += 1) {
146
+ const prefix = segments.slice(0, end).join('/');
147
+ // A directory-only rule cannot match the file itself, only a parent of it.
148
+ if (rule.directoryOnly && end === segments.length)
149
+ continue;
150
+ if (rule.anchored) {
151
+ if (globMatch(rule.pattern, prefix))
152
+ return true;
153
+ continue;
154
+ }
155
+ if (globMatch(rule.pattern, segments[end - 1] ?? ''))
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+ /**
161
+ * Build the publish set for a working tree.
162
+ * @param inputs - the manifest fields and ignore files the decision needs.
163
+ * @returns the membership test and the basis it used.
164
+ */
165
+ export function publishSet(inputs) {
166
+ const never = NEVER_PUBLISHED;
167
+ const main = inputs.main === null ? null : normalizePattern(inputs.main);
168
+ if (inputs.files !== null) {
169
+ const allow = inputs.files.filter(entry => !entry.startsWith('!')).map(normalizePattern);
170
+ const deny = inputs.files.filter(entry => entry.startsWith('!')).map(entry => normalizePattern(entry.slice(1)));
171
+ return {
172
+ basis: 'files-allowlist',
173
+ includes: (path) => {
174
+ if (never.some(pattern => globMatch(pattern, path)))
175
+ return false;
176
+ if (ALWAYS_PUBLISHED.test(path))
177
+ return true;
178
+ if (main !== null && path === main)
179
+ return true;
180
+ if (deny.some(pattern => allowlistCovers(pattern, path)))
181
+ return false;
182
+ return allow.some(pattern => allowlistCovers(pattern, path));
183
+ },
184
+ };
185
+ }
186
+ const text = inputs.npmignore ?? inputs.gitignore;
187
+ const rules = text === null ? [] : parseIgnore(text);
188
+ return {
189
+ basis: 'ignore-rules',
190
+ includes: (path) => {
191
+ if (never.some(pattern => globMatch(pattern, path)))
192
+ return false;
193
+ if (ALWAYS_PUBLISHED.test(path))
194
+ return true;
195
+ if (main !== null && path === main)
196
+ return true;
197
+ let ignored = false;
198
+ for (const rule of rules) {
199
+ if (!ignoreMatches(rule, path))
200
+ continue;
201
+ ignored = !rule.negated;
202
+ }
203
+ return !ignored;
204
+ },
205
+ };
206
+ }
207
+ /** Every file is published: a tarball is already the publish set. */
208
+ export const TARBALL_PUBLISH_SET = { basis: 'tarball', includes: () => true };
package/lib/report.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Rendering a report for a person and for a machine.
3
+ *
4
+ * The human renderer has one rule it is not allowed to break: when
5
+ * `analysis.negativesReliable` is false it must not print a clean bill. Saying
6
+ * "no findings" about a package the tool could not read would be worse than
7
+ * printing nothing at all, so the degraded banner replaces that line rather
8
+ * than accompanying it.
9
+ * @module dsh-plugin-inspector/report
10
+ */
11
+ import { SEVERITIES } from "./model.js";
12
+ /** ANSI colour per severity, and the reset. */
13
+ const COLOR = {
14
+ critical: '\u001b[1;31m',
15
+ high: '\u001b[31m',
16
+ medium: '\u001b[33m',
17
+ low: '\u001b[36m',
18
+ dim: '\u001b[2m',
19
+ bold: '\u001b[1m',
20
+ reset: '\u001b[0m',
21
+ };
22
+ /** How the human report labels each severity. */
23
+ const LABEL = {
24
+ critical: 'CRITICAL',
25
+ high: 'HIGH ',
26
+ medium: 'MEDIUM ',
27
+ low: 'LOW ',
28
+ };
29
+ /**
30
+ * Serialise the report as stable JSON. Key order is fixed by the object
31
+ * literals in `inspect.ts` and findings are pre-sorted, so two runs over the
32
+ * same input produce byte-identical output.
33
+ * @param report - the report.
34
+ * @returns pretty-printed JSON with a trailing newline.
35
+ */
36
+ export function renderJson(report) {
37
+ return `${JSON.stringify(report, null, 2)}\n`;
38
+ }
39
+ /**
40
+ * Render one finding as an indented block.
41
+ * @param finding - the finding.
42
+ * @param paint - the colouring function.
43
+ * @returns the rendered lines.
44
+ */
45
+ function renderFinding(finding, paint) {
46
+ const where = finding.evidence.path === undefined
47
+ ? finding.evidence.file
48
+ : `${finding.evidence.file}:${finding.evidence.path}`;
49
+ const lines = [
50
+ `${paint(finding.severity, LABEL[finding.severity])} ${paint('bold', finding.title)}`,
51
+ ` ${paint('dim', `${finding.checkId} ${finding.name} · tier ${finding.tier} · confidence ${finding.confidence}`)}`,
52
+ ` ${paint('dim', where)}`,
53
+ ];
54
+ if (finding.evidence.snippet !== undefined)
55
+ lines.push(` ${paint('dim', `> ${finding.evidence.snippet}`)}`);
56
+ for (const line of wrap(finding.detail, 88))
57
+ lines.push(` ${line}`);
58
+ if (finding.bypass !== null)
59
+ lines.push(` ${paint('dim', `bypass: ${finding.bypass}`)}`);
60
+ lines.push('');
61
+ return lines;
62
+ }
63
+ /**
64
+ * Wrap prose to a column without breaking words.
65
+ * @param text - the prose.
66
+ * @param width - the maximum line length.
67
+ * @returns the wrapped lines.
68
+ */
69
+ function wrap(text, width) {
70
+ const lines = [];
71
+ let current = '';
72
+ for (const word of text.split(/\s+/)) {
73
+ if (current === '') {
74
+ current = word;
75
+ }
76
+ else if (current.length + 1 + word.length <= width) {
77
+ current = `${current} ${word}`;
78
+ }
79
+ else {
80
+ lines.push(current);
81
+ current = word;
82
+ }
83
+ }
84
+ if (current !== '')
85
+ lines.push(current);
86
+ return lines;
87
+ }
88
+ /**
89
+ * Summarise the mounted layer's `!!js` inventory, dropping the classes that
90
+ * scored nothing so the line stays readable.
91
+ * @param tally - one count per classification.
92
+ * @returns the summary line.
93
+ */
94
+ function describeExpressions(tally) {
95
+ const present = Object.entries(tally).filter(([, count]) => count > 0);
96
+ if (present.length === 0)
97
+ return 'none';
98
+ return present.map(([name, count]) => `${count} ${name}`).join(', ');
99
+ }
100
+ /**
101
+ * Say which files were analysed and, in directory mode, how many were left out
102
+ * because npm would not publish them.
103
+ * @param facts - the report's facts.
104
+ * @returns the description.
105
+ */
106
+ function describeFileSet(facts) {
107
+ if (facts.publishBasis === 'tarball')
108
+ return 'the tarball as published';
109
+ const basis = facts.publishBasis === 'files-allowlist'
110
+ ? 'the package.json `files` allowlist'
111
+ : 'npm defaults over .npmignore/.gitignore';
112
+ return `working tree narrowed to what npm would publish, by ${basis}`
113
+ + ` (${facts.unpublishedFiles} unpublished file(s) not read)`;
114
+ }
115
+ /**
116
+ * Render the "what does this plugin do" section, which is printed whether or
117
+ * not there are findings.
118
+ * @param report - the report.
119
+ * @param paint - the colouring function.
120
+ * @returns the rendered lines.
121
+ */
122
+ function renderFacts(report, paint) {
123
+ const { facts } = report;
124
+ const rows = [
125
+ ['package', `${facts.packageName}@${facts.packageVersion}${facts.license === null ? '' : ` (${facts.license})`}`],
126
+ ['read from', `${report.target.kind} ${report.target.path}`],
127
+ ['mounted layer', facts.mountsAsBundle
128
+ ? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
129
+ : 'no — installs as a plain library, and dsh plugin add prints a warning saying so'],
130
+ ['browser bundle', facts.shipsClientBundle ? 'yes — dsh.client with an ./client export, executed in the user\'s browser' : 'no'],
131
+ ['rows inserted', facts.insertedRows.length === 0
132
+ ? 'none'
133
+ : facts.insertedRows.map(row => `${row.id}${row.name === undefined ? '' : ` → ${row.name}`}`).join('\n ')],
134
+ ['rows modified', facts.targetedRows.length === 0 ? 'none' : facts.targetedRows.join(', ')],
135
+ ['!!js in layer', describeExpressions(facts.jsExpressions)],
136
+ ['other layers', facts.unmountedPatchFiles.length === 0
137
+ ? 'none'
138
+ : `${facts.unmountedPatchFiles.join(', ')} (shipped, mounted by no manifest key)`],
139
+ ['installs', facts.binNames.length === 0 ? 'no commands' : facts.binNames.join(', ')],
140
+ ['dependencies', facts.dependencies.length === 0 ? 'none' : facts.dependencies.join(', ')],
141
+ ['model-visible', facts.modelVisibleFiles.length === 0 ? 'none' : facts.modelVisibleFiles.join(', ')],
142
+ ['analysed', `${facts.filesRead} files, ${facts.sourceFilesParsed} parsed as source, ${facts.bytesRead} bytes`],
143
+ ['file set', describeFileSet(facts)],
144
+ ['checked against', `DeepSeek Harness ${report.tool.harnessReference}`],
145
+ ];
146
+ const lines = [paint('bold', 'What this plugin declares'), ''];
147
+ for (const [key, value] of rows)
148
+ lines.push(` ${paint('dim', key.padEnd(16))}${value}`);
149
+ lines.push('');
150
+ return lines;
151
+ }
152
+ /**
153
+ * Render the human report.
154
+ * @param report - the report.
155
+ * @param color - whether to emit ANSI colour.
156
+ * @returns the rendered text, ending in a newline.
157
+ */
158
+ export function renderHuman(report, color) {
159
+ const paint = (code, text) => color ? `${COLOR[code] ?? ''}${text}${COLOR.reset}` : text;
160
+ const lines = ['', ...renderFacts(report, paint)];
161
+ if (report.findings.length > 0) {
162
+ const counts = SEVERITIES
163
+ .filter(severity => report.summary[severity] > 0)
164
+ .map(severity => paint(severity, `${report.summary[severity]} ${severity}`))
165
+ .join(', ');
166
+ lines.push(paint('bold', `Findings (${counts})`), '');
167
+ for (const finding of report.findings)
168
+ lines.push(...renderFinding(finding, paint));
169
+ }
170
+ if (!report.analysis.negativesReliable) {
171
+ lines.push(paint('medium', 'Analysis is degraded.'), ...wrap(`Tier C fired (${report.analysis.degradedBy.join(', ')}), so parts of this package could not be read the `
172
+ + 'way capability detection needs to read them. The findings above are real — the tool saw what it saw — '
173
+ + 'but the ABSENCE of a capability finding means nothing here. This report does not say the package is clean.', 88).map(line => ` ${line}`), '');
174
+ }
175
+ else if (report.findings.length === 0) {
176
+ lines.push(paint('bold', 'No findings.'), ...wrap('Nothing was found at any severity in the parts that could be read. That is not a statement that the '
177
+ + 'package is safe: this tool reads one version of one package. It does not read transitive dependencies, '
178
+ + 'it cannot see code fetched at runtime, and a later version that gains a dsh.bundle declaration is '
179
+ + 'mounted automatically by the next `dsh plugin update` with no notice.', 88).map(line => ` ${line}`), '');
180
+ }
181
+ return `${lines.join('\n')}\n`;
182
+ }