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/README.md +10 -8
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +41 -0
- package/lib/checks/tier-b.js +51 -27
- package/lib/checks/tier-c.js +146 -18
- package/lib/index.js +2 -1
- package/lib/inspect.js +7 -2
- package/lib/knowledge.js +38 -9
- package/lib/npm.js +58 -5
- package/lib/registry.js +54 -0
- package/lib/report.js +58 -7
- package/lib/syntax.js +135 -0
- package/lib/types/attestation.d.ts +173 -0
- package/lib/types/checks/input.d.ts +7 -0
- package/lib/types/checks/tier-c.d.ts +5 -3
- package/lib/types/index.d.ts +2 -1
- package/lib/types/inspect.d.ts +5 -2
- package/lib/types/knowledge.d.ts +23 -3
- package/lib/types/model.d.ts +13 -1
- package/lib/types/npm.d.ts +12 -2
- package/lib/types/registry.d.ts +40 -0
- package/lib/types/syntax.d.ts +51 -0
- package/package.json +2 -1
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
|
|
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
|
-
...
|
|
189
|
+
...registry === undefined
|
|
140
190
|
? []
|
|
141
191
|
: [
|
|
142
|
-
['fetched from', `${
|
|
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', `${
|
|
147
|
-
+ `${
|
|
148
|
-
['install script',
|
|
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(
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/types/inspect.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* `cordis-yaml.ts`, and its result is discarded without being called.
|
|
10
10
|
* @module dsh-plugin-inspector/inspect
|
|
11
11
|
*/
|
|
12
|
+
import { 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 -
|
|
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.
|
package/lib/types/knowledge.d.ts
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* Harness version these tables were transcribed from — the version string in
|
|
14
|
-
* the
|
|
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.
|
|
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
|
-
/**
|
|
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. */
|
package/lib/types/model.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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`. */
|
package/lib/types/npm.d.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
package/lib/types/registry.d.ts
CHANGED
|
@@ -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
|