dsh-plugin-inspector 0.6.0 → 0.7.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/report.js CHANGED
@@ -19,6 +19,16 @@ const COLOR = {
19
19
  bold: '\u001b[1m',
20
20
  reset: '\u001b[0m',
21
21
  };
22
+ /** Indent that lines a wrapped fact value up under the first one. */
23
+ const CONTINUATION = `\n${' '.repeat(18)}`;
24
+ /** What each unchecked claim means, spelled out rather than left as a key. */
25
+ const GAP_MEANING = {
26
+ 'certificate-chain': 'that the signing certificate is Sigstore\'s — this tool carries no trust root, so a '
27
+ + 'registry serving a doctored bundle passes every check above',
28
+ 'transparency-log': 'the Rekor transparency-log inclusion proof in the bundle, which needs the same trust root',
29
+ 'builder-identity': 'the signer identity, because the statement names no GitHub Actions workflow to compare the '
30
+ + 'certificate against',
31
+ };
22
32
  /** How the human report labels each severity. */
23
33
  const LABEL = {
24
34
  critical: 'CRITICAL',
@@ -123,6 +133,46 @@ function describeFileSet(facts) {
123
133
  return `working tree narrowed to what npm would publish, by ${basis}`
124
134
  + ` (${facts.unpublishedFiles} unpublished file(s) not read)`;
125
135
  }
136
+ /**
137
+ * Say what the registry attested about this tarball's build origin, and — with
138
+ * equal prominence — what of that was checked here and what was not.
139
+ *
140
+ * The report is not allowed to let a reader mistake one for the other. "The
141
+ * registry told me a claim exists" and "I checked the claim" are different
142
+ * statements, and the second is only partly true: the digest, the package
143
+ * name, the DSSE signature and the certificate's own identity are checked, and
144
+ * whether the certificate belongs to Sigstore is not. So the `not checked` row
145
+ * is printed whenever the `checked` row is, never as a footnote and never
146
+ * conditionally.
147
+ * @param provenance - the fact.
148
+ * @returns the rows to print.
149
+ */
150
+ function renderProvenance(provenance) {
151
+ if (provenance.state === 'unavailable') {
152
+ return [['provenance', `not readable here — ${provenance.reason}`]];
153
+ }
154
+ if (provenance.state === 'absent') {
155
+ return [['provenance',
156
+ 'none — the registry published no build provenance for this version, which most published packages do not']];
157
+ }
158
+ if (provenance.state === 'unreadable') {
159
+ return [['provenance', `the registry says this version has one and it could not be read — ${provenance.reason}`]];
160
+ }
161
+ const origin = `${provenance.sourceRepository ?? 'an unnamed repository'}`
162
+ + `${provenance.sourceCommit === null ? '' : ` @ ${provenance.sourceCommit}`}`
163
+ + `${provenance.sourceRef === null ? '' : ` (${provenance.sourceRef})`}`;
164
+ return [
165
+ ['provenance', provenance.state === 'attested'
166
+ ? `attested to ${origin}`
167
+ : `ATTESTED TO ${origin}, AND THE ATTESTATION DOES NOT CHECK OUT`],
168
+ ['built by', `${provenance.workflow ?? 'an unnamed workflow'}`
169
+ + `${provenance.builder === null ? '' : ` on ${provenance.builder}`}`],
170
+ ['checked', provenance.checks
171
+ .map(check => `${check.passed ? 'ok ' : 'FAIL'} ${check.name} — ${check.detail}`)
172
+ .join(CONTINUATION)],
173
+ ['not checked', provenance.notChecked.map(gap => GAP_MEANING[gap]).join(CONTINUATION)],
174
+ ];
175
+ }
126
176
  /**
127
177
  * Render the "what does this plugin do" section, which is printed whether or
128
178
  * not there are findings.
@@ -132,23 +182,24 @@ function describeFileSet(facts) {
132
182
  */
133
183
  function renderFacts(report, paint) {
134
184
  const { facts } = report;
135
- const provenance = report.target.registry;
185
+ const registry = report.target.registry;
136
186
  const rows = [
137
187
  ['package', `${facts.packageName}@${facts.packageVersion}${facts.license === null ? '' : ` (${facts.license})`}`],
138
188
  ['read from', `${report.target.kind} ${report.target.path}`],
139
- ...provenance === undefined
189
+ ...registry === undefined
140
190
  ? []
141
191
  : [
142
- ['fetched from', `${provenance.tarball} (${provenance.tarballBytes} bytes, never written to disk)`],
192
+ ['fetched from', `${registry.tarball} (${registry.tarballBytes} bytes, never written to disk)`],
143
193
  // Which field matched is not cosmetic: `dist.shasum` is SHA-1 and is
144
194
  // only reached on packages published before npm 5, so naming
145
195
  // `dist.integrity` there would report a stronger check than ran.
146
- ['verified', `${provenance.digest} matched `
147
- + `${provenance.algorithm === 'sha1' ? 'dist.shasum' : 'dist.integrity'} before anything parsed it`],
148
- ['install script', provenance.hasInstallScript
196
+ ['verified', `${registry.digest} matched `
197
+ + `${registry.algorithm === 'sha1' ? 'dist.shasum' : 'dist.integrity'} before anything parsed it`],
198
+ ['install script', registry.hasInstallScript
149
199
  ? 'yes — the registry marks this package as running one at install time'
150
200
  : 'no — the registry does not mark this package as running one'],
151
201
  ],
202
+ ...renderProvenance(facts.provenance),
152
203
  /* v8 ignore start -- `mountsAsBundle` is true exactly when the manifest declared a path. */
153
204
  ['mounted layer', facts.mountsAsBundle
154
205
  ? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
@@ -157,7 +208,7 @@ function renderFacts(report, paint) {
157
208
  ['browser bundle', facts.shipsClientBundle ? 'yes — dsh.client with an ./client export, executed in the user\'s browser' : 'no'],
158
209
  ['rows inserted', facts.insertedRows.length === 0
159
210
  ? 'none'
160
- : facts.insertedRows.map(row => `${row.id}${row.name === undefined ? '' : ` → ${row.name}`}`).join('\n ')],
211
+ : facts.insertedRows.map(row => `${row.id}${row.name === undefined ? '' : ` → ${row.name}`}`).join(CONTINUATION)],
161
212
  ['rows modified', facts.targetedRows.length === 0 ? 'none' : facts.targetedRows.join(', ')],
162
213
  ['!!js in layer', describeExpressions(facts.jsExpressions)],
163
214
  ['other layers', facts.unmountedPatchFiles.length === 0
package/lib/syntax.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Readings of a syntax tree that both capability detection and readability
3
+ * detection need, and that must agree between them.
4
+ *
5
+ * Tier B matches a name; Tier C reports the names it could not match. If the
6
+ * two disagree about which expressions are constant, a package gets both a
7
+ * finding and a degrade for the same site, or neither. They agree because they
8
+ * ask the same function.
9
+ *
10
+ * Nothing here evaluates anything. {@link foldConstantString} reads literals
11
+ * out of an already-parsed tree and concatenates them; it never constructs a
12
+ * function, and it never touches an identifier's value.
13
+ * @module dsh-plugin-inspector/syntax
14
+ */
15
+ import ts from 'typescript';
16
+ /**
17
+ * How far {@link foldConstantString} descends before answering `null`.
18
+ *
19
+ * A bound rather than a promise: the folder recurses over attacker-supplied
20
+ * syntax, and a name worth hiding is not hidden eight levels of concatenation
21
+ * deep. Past the bound the answer is "this tool cannot resolve it", which
22
+ * degrades the report rather than dropping the site.
23
+ */
24
+ const MAX_FOLD_DEPTH = 8;
25
+ /**
26
+ * The separator `Array.prototype.join` uses when called with no argument.
27
+ */
28
+ const DEFAULT_JOIN_SEPARATOR = ',';
29
+ /**
30
+ * The `Array.prototype.join` case: `['node:child', '_process'].join('')`.
31
+ * @param node - the call expression.
32
+ * @param depth - the current recursion depth.
33
+ * @returns the joined text, or `null` when any part is not a constant.
34
+ */
35
+ function foldJoin(node, depth) {
36
+ const callee = node.expression;
37
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'join')
38
+ return null;
39
+ if (!ts.isArrayLiteralExpression(callee.expression))
40
+ return null;
41
+ const separator = node.arguments.length === 0
42
+ ? DEFAULT_JOIN_SEPARATOR
43
+ : fold(node.arguments[0], depth + 1);
44
+ if (separator === null)
45
+ return null;
46
+ const parts = [];
47
+ for (const element of callee.expression.elements) {
48
+ const part = fold(element, depth + 1);
49
+ if (part === null)
50
+ return null;
51
+ parts.push(part);
52
+ }
53
+ return parts.join(separator);
54
+ }
55
+ /**
56
+ * The template case: `` `node:${'fs'}` ``.
57
+ * @param node - the template expression.
58
+ * @param depth - the current recursion depth.
59
+ * @returns the assembled text, or `null` when any span is not a constant.
60
+ */
61
+ function foldTemplate(node, depth) {
62
+ let text = node.head.text;
63
+ for (const span of node.templateSpans) {
64
+ const value = fold(span.expression, depth + 1);
65
+ if (value === null)
66
+ return null;
67
+ text += value + span.literal.text;
68
+ }
69
+ return text;
70
+ }
71
+ /**
72
+ * The recursive half of {@link foldConstantString}.
73
+ * @param node - the expression to fold.
74
+ * @param depth - the current recursion depth.
75
+ * @returns the text, or `null`.
76
+ */
77
+ function fold(node, depth) {
78
+ if (node === undefined || depth > MAX_FOLD_DEPTH)
79
+ return null;
80
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
81
+ return node.text;
82
+ if (ts.isParenthesizedExpression(node))
83
+ return fold(node.expression, depth + 1);
84
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
85
+ const left = fold(node.left, depth + 1);
86
+ const right = fold(node.right, depth + 1);
87
+ return left === null || right === null ? null : left + right;
88
+ }
89
+ if (ts.isTemplateExpression(node))
90
+ return foldTemplate(node, depth);
91
+ if (ts.isCallExpression(node))
92
+ return foldJoin(node, depth);
93
+ return null;
94
+ }
95
+ /**
96
+ * The text a constant string expression holds, or `null` when the expression is
97
+ * not constant.
98
+ *
99
+ * Four forms, chosen because each one is a spelling of a name that a reader
100
+ * sees and a name-matching check does not: a literal, a `+` chain of them, a
101
+ * template whose every span is one, and `[…].join(…)` over an array of them.
102
+ * Anything reaching an identifier, a property, or any other call answers
103
+ * `null` — resolving those is value tracking, which this tool does not do and
104
+ * which Tier C exists to admit.
105
+ * @param node - the expression, or `undefined` for a missing argument.
106
+ * @returns the text, or `null`.
107
+ */
108
+ export function foldConstantString(node) {
109
+ return fold(node, 0);
110
+ }
111
+ /**
112
+ * The Node API that hands back a builtin module without `require` and without
113
+ * an `import` declaration, added in Node 22.3.
114
+ *
115
+ * It reaches the same modules the harness sandbox's `require` trap covers,
116
+ * from a call that sandbox never sees: the sandbox leaves
117
+ * `process` `undefined`, so inside it this expression throws, and a mounted
118
+ * bundle layer is not inside it.
119
+ * @see https://nodejs.org/api/process.html#processgetbuiltinmoduleid
120
+ */
121
+ export const BUILTIN_MODULE_GETTER = 'getBuiltinModule';
122
+ /**
123
+ * Whether a call is `process.getBuiltinModule(…)`.
124
+ *
125
+ * The receiver is required. `getBuiltinModule` pulled off `process` and bound
126
+ * to a bare name is not this — it is a detached member, which Tier C reports as
127
+ * dispatch it cannot follow.
128
+ * @param node - the call expression.
129
+ * @returns true when the call loads a builtin through `process`.
130
+ */
131
+ export function isBuiltinModuleGetter(node) {
132
+ const callee = node.expression;
133
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === BUILTIN_MODULE_GETTER
134
+ && ts.isIdentifier(callee.expression) && callee.expression.text === 'process';
135
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * npm provenance attestations: what the registry says about where a published
3
+ * tarball was built, and how much of that this tool can check for itself.
4
+ *
5
+ * A provenance attestation is a Sigstore bundle holding a DSSE envelope over an
6
+ * in-toto statement. The statement names the tarball by digest and, for a
7
+ * GitHub Actions build, the source repository, the commit, and the workflow
8
+ * file that produced it. npm publishes one when a package is released from a
9
+ * trusted CI environment.
10
+ *
11
+ * **This module is careful about the difference between reading a claim and
12
+ * checking one, because a reader who confuses them is worse off than one who
13
+ * has neither.** Everything it can check runs offline against bytes already in
14
+ * hand, and everything it cannot check is named in the same record:
15
+ *
16
+ * - `subject-digest` — the statement's subject digest is the SHA-512 of the
17
+ * tarball this run analysed. This is the binding that makes the rest mean
18
+ * anything: without it the attestation could be about another version.
19
+ * - `subject-package` — that subject is this package at this version, written
20
+ * as a package URL.
21
+ * - `dsse-signature` — the envelope's signature verifies under the public key
22
+ * of the certificate carried inside the bundle.
23
+ * - `certificate-identity` — the certificate's subject alternative name is the
24
+ * workflow the statement claims built the package, so payload and signer
25
+ * agree.
26
+ *
27
+ * What it does **not** do is establish that the certificate is Sigstore's. That
28
+ * needs the Fulcio root, which lives in the Sigstore trust root and not on any
29
+ * npm registry, and the same root is what a Rekor inclusion proof is checked
30
+ * against. Without it these four checks prove the bundle is internally
31
+ * consistent and is about these exact bytes — not that the identity in it is
32
+ * real. Those gaps are listed in {@link ProvenanceFact.notChecked} and printed
33
+ * in the report, so no reader has to infer them.
34
+ * @module dsh-plugin-inspector/attestation
35
+ */
36
+ /** The SLSA predicate type npm publishes a build provenance statement under. */
37
+ export declare const PROVENANCE_PREDICATE_TYPE = "https://slsa.dev/provenance/v1";
38
+ /**
39
+ * Largest attestation document the tool will read. Real bundles are tens of
40
+ * kilobytes; the transparency-log inclusion proof is most of that.
41
+ */
42
+ export declare const MAX_ATTESTATION_BYTES: number;
43
+ /**
44
+ * How much provenance this report has.
45
+ *
46
+ * `absent` is the common case and is not a defect: most published packages
47
+ * carry no attestation. `unavailable` is the honest answer for a directory or
48
+ * a local tarball, neither of which has a registry statement to read.
49
+ */
50
+ export type ProvenanceState = 'unavailable' | 'absent' | 'unreadable' | 'attested' | 'failed';
51
+ /** The name of one check this tool runs against a fetched attestation. */
52
+ export type ProvenanceCheckName = 'subject-digest' | 'subject-package' | 'dsse-signature' | 'certificate-identity';
53
+ /**
54
+ * Something a full verifier would establish and this tool does not.
55
+ *
56
+ * `certificate-chain` and `transparency-log` are unconditional: both need the
57
+ * Sigstore trust root. `builder-identity` appears when the statement does not
58
+ * name a GitHub Actions workflow, which is the only build type whose signer
59
+ * identity this tool knows how to reconstruct from the payload.
60
+ */
61
+ export type ProvenanceGap = 'certificate-chain' | 'transparency-log' | 'builder-identity';
62
+ /** The outcome of one check, with what it compared. */
63
+ export interface ProvenanceCheck {
64
+ readonly name: ProvenanceCheckName;
65
+ /** What was compared against what, in one line. */
66
+ readonly detail: string;
67
+ readonly passed: boolean;
68
+ }
69
+ /** Everything a fact says about the build, whatever state it is in. */
70
+ interface ProvenanceClaims {
71
+ /** The predicate type the statement carries. */
72
+ readonly predicateType: string | null;
73
+ /** The source repository the statement names, e.g. `https://github.com/owner/repo`. */
74
+ readonly sourceRepository: string | null;
75
+ /** The commit that repository was built from. */
76
+ readonly sourceCommit: string | null;
77
+ /** The git ref the build ran on, e.g. `refs/tags/v1.2.3`. */
78
+ readonly sourceRef: string | null;
79
+ /** Repository-relative path of the workflow that built it. */
80
+ readonly workflow: string | null;
81
+ /** The builder the statement names, e.g. a GitHub-hosted runner. */
82
+ readonly builder: string | null;
83
+ /** The identity in the signing certificate's subject alternative name. */
84
+ readonly signerIdentity: string | null;
85
+ /** Every check that ran, in a fixed order. */
86
+ readonly checks: readonly ProvenanceCheck[];
87
+ /** Every claim this tool did not establish. */
88
+ readonly notChecked: readonly ProvenanceGap[];
89
+ }
90
+ /**
91
+ * What the registry says about this package's build origin, and what of it was
92
+ * checked. Emitted for every report, including the two modes that have no
93
+ * registry statement to read at all.
94
+ *
95
+ * The state carries the two fields whose presence depends on it, so a consumer
96
+ * never has to ask whether a `reason` is meaningful in the state it found: the
97
+ * two states that have a reason always have one, and the two that report on a
98
+ * real attestation always name the endpoint it came from.
99
+ */
100
+ export type ProvenanceFact = (ProvenanceClaims & {
101
+ readonly state: 'unavailable';
102
+ /** Why this mode has no attestation to read. */
103
+ readonly reason: string;
104
+ readonly attestationUrl: null;
105
+ }) | (ProvenanceClaims & {
106
+ readonly state: 'absent';
107
+ readonly reason: null;
108
+ readonly attestationUrl: null;
109
+ }) | (ProvenanceClaims & {
110
+ readonly state: 'unreadable';
111
+ /** What went wrong reading the attestation the registry said it holds. */
112
+ readonly reason: string;
113
+ /** The endpoint that was asked, or `null` when none could be built. */
114
+ readonly attestationUrl: string | null;
115
+ }) | (ProvenanceClaims & {
116
+ readonly state: 'attested' | 'failed';
117
+ readonly reason: null;
118
+ /** The registry endpoint the bundle was read from. */
119
+ readonly attestationUrl: string;
120
+ });
121
+ /** What a fetched attestation is checked against. */
122
+ export interface ProvenanceSubject {
123
+ readonly name: string;
124
+ readonly version: string;
125
+ /** The tarball bytes, already checked against `dist.integrity`. */
126
+ readonly tarball: Buffer;
127
+ /** The endpoint the document came from, recorded in the fact. */
128
+ readonly url: string;
129
+ }
130
+ /**
131
+ * The fact for a target that has no registry statement: a directory, or a
132
+ * tarball on disk. Stated rather than reported as an absence, because "this
133
+ * package has no provenance" and "this mode cannot read provenance" are
134
+ * different answers and only one of them is about the package.
135
+ * @returns the fact.
136
+ */
137
+ export declare function provenanceUnavailable(): ProvenanceFact;
138
+ /**
139
+ * The fact for a published package whose version document declares no
140
+ * provenance attestation.
141
+ * @returns the fact.
142
+ */
143
+ export declare function provenanceAbsent(): ProvenanceFact;
144
+ /**
145
+ * The fact for an attestation the registry named but this run could not read.
146
+ * @param url - the endpoint that was asked, or `null` when none could be built.
147
+ * @param reason - what went wrong.
148
+ * @returns the fact.
149
+ */
150
+ export declare function provenanceUnreadable(url: string | null, reason: string): ProvenanceFact;
151
+ /**
152
+ * The package URL npm writes an attestation subject's name as. A scope's `@`
153
+ * is percent-encoded there and nowhere else in the document.
154
+ * @param name - the package name.
155
+ * @param version - the resolved version.
156
+ * @returns the package URL.
157
+ */
158
+ export declare function packageUrl(name: string, version: string): string;
159
+ /**
160
+ * Check a fetched attestation document against the tarball this run analysed.
161
+ *
162
+ * The digest comparison is the load-bearing one: it is what ties the statement
163
+ * to these bytes rather than to some other version of the same package. The
164
+ * bytes handed in are the ones `dist.integrity` already vouched for, so a pass
165
+ * here means one chain of digests runs from the version document through the
166
+ * download to the signed statement.
167
+ * @param body - the attestation document as served.
168
+ * @param subject - the package the document is supposed to be about.
169
+ * @returns the fact, in state `attested`, `failed`, or `unreadable`.
170
+ */
171
+ export declare function readProvenance(body: Buffer, subject: ProvenanceSubject): ProvenanceFact;
172
+ export {};
173
+ //# sourceMappingURL=attestation.d.ts.map
@@ -7,6 +7,7 @@
7
7
  * than something every check has to be trusted about.
8
8
  * @module dsh-plugin-inspector/checks/input
9
9
  */
10
+ import type { ProvenanceFact } from '../attestation.ts';
10
11
  import type { PatchDocument, PatchParseError } from '../cordis-yaml.ts';
11
12
  import type { PackageManifest } from '../manifest.ts';
12
13
  import type { PluginSource } from '../source.ts';
@@ -38,5 +39,11 @@ export interface CheckInput {
38
39
  readonly sourceFiles: readonly string[];
39
40
  /** Package-relative paths of shipped skill and agent-instruction markdown. */
40
41
  readonly modelVisibleFiles: readonly string[];
42
+ /**
43
+ * What the registry published about this tarball's build origin. Carries
44
+ * state `unavailable` on the two modes that read local bytes, which forbids
45
+ * every provenance verdict — see `runTierA`.
46
+ */
47
+ readonly provenance: ProvenanceFact;
41
48
  }
42
49
  //# sourceMappingURL=input.d.ts.map
@@ -7,9 +7,11 @@
7
7
  * user is entitled to see it.
8
8
  *
9
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
10
+ * of syntactic shapes a member, on a receiver, taking a name it can resolve —
11
+ * so when code is minified, when a name is assembled out of something this tool
12
+ * cannot fold, when a member is detached from the receiver the checks match it
13
+ * on, or when the shipped artifact has no readable source, a Tier B *positive*
14
+ * is still true but a Tier B *negative* means nothing. `inspect.ts` reads the
13
15
  * output of this module to lower Tier B confidence and to forbid the report
14
16
  * from claiming nothing was found.
15
17
  * @module dsh-plugin-inspector/checks/tier-c
@@ -12,8 +12,9 @@
12
12
  * @module dsh-plugin-inspector
13
13
  */
14
14
  export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from './inspect.ts';
15
+ export { MAX_ATTESTATION_BYTES, packageUrl, PROVENANCE_PREDICATE_TYPE, provenanceAbsent, provenanceUnavailable, provenanceUnreadable, readProvenance, type ProvenanceCheck, type ProvenanceCheckName, type ProvenanceFact, type ProvenanceGap, type ProvenanceState, type ProvenanceSubject, } from './attestation.ts';
15
16
  export { inspectFromNpm, precheck } from './npm.ts';
16
- export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, type PackageSpec, type RegistryOptions, type ResolvedPackage, type VerifiedTarball, } from './registry.ts';
17
+ export { attestationUrl, DEFAULT_REGISTRY, fetchAttestation, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, type PackageSpec, type RegistryOptions, type ResolvedPackage, type VerifiedTarball, } from './registry.ts';
17
18
  export { renderHuman, renderJson } from './report.ts';
18
19
  export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, type ExpressionClass, type ExpressionSite, type ExpressionSlot, type InsertedRow, type JsExprNode, type OverridePatch, type PatchDocument, } from './cordis-yaml.ts';
19
20
  export { declaredPackages, ManifestError, parseManifest, type PackageManifest } from './manifest.ts';
@@ -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 { type ProvenanceFact } from './attestation.ts';
12
13
  import { type RegistryProvenance, type Report, type Severity } from './model.ts';
13
14
  import { type PluginSource } from './source.ts';
14
15
  /**
@@ -39,11 +40,13 @@ export declare function inspect(target: string): Promise<Report>;
39
40
  /**
40
41
  * Run every check over an already-decoded package.
41
42
  * @param source - the decoded package.
42
- * @param registry - provenance, when the bytes were fetched from a registry.
43
+ * @param registry - where the bytes came from, when they were fetched from a registry.
44
+ * @param provenance - what the registry's attestation said and what was checked
45
+ * of it; defaults to the `unavailable` fact the two local modes carry.
43
46
  * @returns the complete report.
44
47
  * @throws ManifestError when the manifest cannot be read.
45
48
  */
46
- export declare function analyze(source: PluginSource, registry?: RegistryProvenance): Report;
49
+ export declare function analyze(source: PluginSource, registry?: RegistryProvenance, provenance?: ProvenanceFact): Report;
47
50
  /**
48
51
  * Whether a report should fail a CI gate.
49
52
  * @param report - the report.
@@ -11,9 +11,15 @@
11
11
  */
12
12
  /**
13
13
  * Harness version these tables were transcribed from — the version string in
14
- * the checkout's own `packages/bundle/*&#47;package.json`.
14
+ * the shipped bundles' own `package.json`, which is `dsh`'s own version.
15
+ *
16
+ * Re-verified against `0.1.1-rc.2`, the release npm tags `latest`, by
17
+ * extracting each table from the published packages and diffing it against the
18
+ * one here. What moved: six rows inserted by the web bundle, three seam keys,
19
+ * and two sandbox traps this table had never carried. What did not: every row
20
+ * name, every row's bundle membership, and the waterfall event set.
15
21
  */
16
- export declare const HARNESS_REFERENCE = "0.1.0-rc.5";
22
+ export declare const HARNESS_REFERENCE = "0.1.1-rc.2";
17
23
  /** The shipped bundles, each of which is one patch layer over the profile root. */
18
24
  export type BundleName = 'base' | 'headless' | 'web-app';
19
25
  /**
@@ -62,7 +68,17 @@ export declare const SECURITY_ROW_IDS: ReadonlyMap<string, string>;
62
68
  * replaces a core service for every consumer in its scope.
63
69
  */
64
70
  export declare const SEAM_KEYS: ReadonlySet<string>;
65
- /** The subset of {@link SEAM_KEYS} whose replacement removes a constraint. */
71
+ /**
72
+ * The subset of {@link SEAM_KEYS} whose replacement removes a constraint.
73
+ *
74
+ * `authorization` joined the catalogue in `0.1.1-rc.2`: it is the registry of
75
+ * flows that obtain a credential through a conversation with the user, so
76
+ * providing it means owning that conversation. That is the same class of
77
+ * substitution as `credentials`, which this set already holds. The other two
78
+ * keys the release added are not: `fileReferences` decides which paths are
79
+ * offered for completion and `agentTeams` is the team form of `subagents`,
80
+ * which is deliberately not here either.
81
+ */
66
82
  export declare const SECURITY_SEAM_KEYS: ReadonlySet<string>;
67
83
  /**
68
84
  * Waterfall events, from `EVENT_API` in the api-catalog. A listener on one of
@@ -70,6 +86,10 @@ export declare const SECURITY_SEAM_KEYS: ReadonlySet<string>;
70
86
  * without calling it short-circuits the chain including the built-in behavior.
71
87
  *
72
88
  * Note there is no `fs/read-intent` — the intent family is write and edit only.
89
+ *
90
+ * Unchanged in `0.1.1-rc.2`. The catalogue's event set grew by four and lost
91
+ * one, but every addition carries `mode: 'emit'`, and only `mode: 'waterfall'`
92
+ * hands a listener the trailing `next` this set is about.
73
93
  */
74
94
  export declare const WATERFALL_EVENTS: ReadonlySet<string>;
75
95
  /** Waterfall events whose short-circuit removes a decision the user would otherwise make. */
@@ -9,6 +9,7 @@
9
9
  * would fire on every legitimate plugin and train users to ignore the tool.
10
10
  * @module dsh-plugin-inspector/model
11
11
  */
12
+ import type { ProvenanceFact } from './attestation.ts';
12
13
  /** How much a finding should weigh on an install decision. */
13
14
  export type Severity = 'critical' | 'high' | 'medium' | 'low';
14
15
  /**
@@ -25,7 +26,11 @@ export declare const SEVERITY_RANK: Readonly<Record<Severity, number>>;
25
26
  export declare const SEVERITIES: readonly Severity[];
26
27
  /** Where in the analysed package a finding was observed. */
27
28
  export interface Evidence {
28
- /** Package-relative path of the file the finding came from. */
29
+ /**
30
+ * Package-relative path of the file the finding came from, or the name of the
31
+ * registry field it came from when the finding is about what the registry
32
+ * published rather than about the package contents.
33
+ */
29
34
  readonly file: string;
30
35
  /** A locator inside that file: a YAML path, a JSON pointer, or `line:column`. */
31
36
  readonly path?: string;
@@ -93,6 +98,13 @@ export interface Facts {
93
98
  readonly packageName: string;
94
99
  readonly packageVersion: string;
95
100
  readonly license: string | null;
101
+ /**
102
+ * What the registry says about where this tarball was built, and which of
103
+ * those claims this run checked. Always present: the two modes that read
104
+ * local bytes report `unavailable`, which is a different answer from a
105
+ * published package that has no attestation.
106
+ */
107
+ readonly provenance: ProvenanceFact;
96
108
  /** True when `package.json` declares `dsh.bundle.patch` — a mounted patch layer. */
97
109
  readonly mountsAsBundle: boolean;
98
110
  /** The declared patch path, verbatim and unresolved, or `null`. */
@@ -7,10 +7,20 @@
7
7
  * fixed and their order is the guarantee:
8
8
  *
9
9
  * 1. read the version document (~3 KB) — which already answers
10
- * `hasInstallScript`, the install lifecycle scripts, and `dsh.bundle`;
10
+ * `hasInstallScript`, the install lifecycle scripts, `dsh.bundle`, and
11
+ * whether the registry holds a provenance attestation at all;
11
12
  * 2. download the tarball into memory;
12
13
  * 3. verify `dist.integrity` **before** anything parses a byte of it;
13
- * 4. decode in memory and analyse, exactly as the tarball path does.
14
+ * 4. read the provenance attestation, when step 1 said there is one, and check
15
+ * it against the bytes step 3 vouched for;
16
+ * 5. decode in memory and analyse, exactly as the tarball path does.
17
+ *
18
+ * Step 4 is the only request this module makes that is not unconditional, and
19
+ * it is skipped for every package the version document says has no attestation
20
+ * — which on the measured corpus is 28 packages in 40. It never fails an
21
+ * analysis: an endpoint that is down or a bundle that does not decode leaves
22
+ * the provenance fact in state `unreadable`, which is a different answer from
23
+ * `absent` and is printed as one.
14
24
  *
15
25
  * No subprocess, no disk write, no lifecycle script, and no `npm pack`.
16
26
  * @module dsh-plugin-inspector/npm
@@ -54,6 +54,13 @@ export interface ResolvedPackage {
54
54
  readonly lifecycleScripts: readonly string[];
55
55
  /** The `dsh.bundle.patch` value, which is what makes a package a mounted layer. */
56
56
  readonly bundlePatch: string | null;
57
+ /**
58
+ * The predicate type of the provenance attestation the registry says it
59
+ * holds for this version, from `dist.attestations.provenance`, or `null` when
60
+ * it says it holds none. Reading it here is what keeps the attestation
61
+ * endpoint unasked for the majority of packages that have no attestation.
62
+ */
63
+ readonly provenancePredicateType: string | null;
57
64
  /** Bytes of metadata read to learn all of the above. */
58
65
  readonly metadataBytes: number;
59
66
  }
@@ -116,4 +123,37 @@ export declare function verifyIntegrity(bytes: Buffer, resolved: ResolvedPackage
116
123
  * @throws RegistryError on a transport failure, an oversized body, or a hash mismatch.
117
124
  */
118
125
  export declare function fetchVerifiedTarball(resolved: ResolvedPackage, options?: RegistryOptions): Promise<VerifiedTarball>;
126
+ /**
127
+ * The endpoint an npm-compatible registry serves a version's attestation
128
+ * bundle from.
129
+ *
130
+ * Built from the registry base URL rather than read out of
131
+ * `dist.attestations.url`, which is the opposite of how the tarball URL is
132
+ * handled and is deliberate: the tarball has to come from wherever the registry
133
+ * says because there is no other way to name it, so that URL is taken from the
134
+ * document and then refused unless it is same-origin. An attestation needs no
135
+ * such freedom. Constructing the path here means a doctored packument cannot
136
+ * redirect the request at all, not even to another path on the same host.
137
+ *
138
+ * The name and version are re-validated because both come out of the version
139
+ * document, which is registry-controlled: `name` is not necessarily the name
140
+ * that was asked for, and it is interpolated into a URL.
141
+ * @param registry - the registry base URL, without a trailing slash.
142
+ * @param name - the resolved package name.
143
+ * @param version - the resolved version.
144
+ * @returns the absolute URL.
145
+ * @throws RegistryError when the document's name or version would not address this endpoint.
146
+ */
147
+ export declare function attestationUrl(registry: string, name: string, version: string): string;
148
+ /**
149
+ * Download an attestation document.
150
+ *
151
+ * Only ever called when the version document said there is one, so a package
152
+ * without provenance costs no request at all.
153
+ * @param url - the endpoint, from {@link attestationUrl}.
154
+ * @param options - where to fetch from.
155
+ * @returns the document as served.
156
+ * @throws RegistryError on a transport failure, a non-2xx status, or an oversized body.
157
+ */
158
+ export declare function fetchAttestation(url: string, options?: RegistryOptions): Promise<Buffer>;
119
159
  //# sourceMappingURL=registry.d.ts.map