@effected/github-actions 0.24.0 → 0.25.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/ActionEnvironment.js +6 -8
- package/ActionInput.js +14 -17
- package/ActionsIdentityToken.js +5 -5
- package/ActionsProvenance.js +17 -15
- package/Artifact.js +3 -2
- package/BlobStore.js +58 -28
- package/OidcTokenIssuer.js +23 -52
- package/PackageManagerInstaller.js +19 -22
- package/README.md +3 -3
- package/Secret.js +10 -6
- package/ToolInstaller.js +3 -2
- package/index.d.ts +51 -71
- package/index.js +2 -2
- package/internal/actionsResults.js +5 -4
- package/internal/json.js +11 -0
- package/internal/runner.js +21 -1
- package/package.json +7 -4
- package/internal/jwt.js +0 -41
- package/internal/sigv4.js +0 -114
package/ActionEnvironment.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeJson } from "./internal/json.js";
|
|
1
2
|
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
|
|
2
3
|
|
|
3
4
|
// src/ActionEnvironment.ts
|
|
@@ -176,14 +177,11 @@ const make = (base) => Effect.gen(function* () {
|
|
|
176
177
|
name: "GITHUB_EVENT_PATH",
|
|
177
178
|
detail: `could not read "${path}": ${String(cause)}`
|
|
178
179
|
})));
|
|
179
|
-
return yield* Effect.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
detail: `not valid JSON: ${String(cause)}`
|
|
185
|
-
})
|
|
186
|
-
});
|
|
180
|
+
return yield* decodeJson(raw).pipe(Effect.mapError((cause) => new ActionEnvironmentError({
|
|
181
|
+
reason: "malformed",
|
|
182
|
+
name: "GITHUB_EVENT_PATH",
|
|
183
|
+
detail: `not valid JSON: ${cause.message}`
|
|
184
|
+
})));
|
|
187
185
|
}),
|
|
188
186
|
withEnv: (overrides, effect) => Effect.flatMap(EnvOverrides, (current) => Effect.provideService(effect, EnvOverrides, {
|
|
189
187
|
...current,
|
package/ActionInput.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeJson } from "./internal/json.js";
|
|
1
2
|
import { Config, ConfigProvider, Context, Effect, Schema, SchemaIssue } from "effect";
|
|
2
3
|
|
|
3
4
|
// src/ActionInput.ts
|
|
@@ -29,7 +30,17 @@ const inputVariable = (name) => `INPUT_${name.replaceAll(" ", "_").toUpperCase()
|
|
|
29
30
|
* mutations the author meant to rehearse. `actual` carries the offending value
|
|
30
31
|
* so the rendered error names it.
|
|
31
32
|
*/
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* A `ConfigError` carrying `message` as its first issue and, when a decode
|
|
35
|
+
* failed, that decode's own issue beside it, so the cause is structured
|
|
36
|
+
* rather than flattened into the message.
|
|
37
|
+
*/
|
|
38
|
+
const configError = (message, actual, cause) => {
|
|
39
|
+
const stated = new SchemaIssue.InvalidValue({ message }, actual);
|
|
40
|
+
return new Config.ConfigError(new Schema.SchemaError(cause === void 0 ? stated : new SchemaIssue.Composite(Schema.Unknown.ast, [stated, cause.issue], actual)));
|
|
41
|
+
};
|
|
42
|
+
/** The JSON-array form of {@link ActionInput.list}. */
|
|
43
|
+
const decodeStringArray = Schema.decodeUnknownEffect(Schema.Array(Schema.String));
|
|
33
44
|
/** YAML 1.2 core-schema booleans, which is what the runner documents. */
|
|
34
45
|
const TRUE = /* @__PURE__ */ new Set([
|
|
35
46
|
"true",
|
|
@@ -267,13 +278,7 @@ var ActionInput = class ActionInput {
|
|
|
267
278
|
return Config.String(inputVariable(name)).pipe(Config.mapEffect((raw) => {
|
|
268
279
|
const trimmed = raw.trim();
|
|
269
280
|
if (trimmed === "") return Effect.succeed([]);
|
|
270
|
-
if (trimmed.startsWith("["))
|
|
271
|
-
const parsed = JSON.parse(trimmed);
|
|
272
|
-
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) return Effect.succeed(parsed);
|
|
273
|
-
return Effect.fail(configError(`Input "${name}" is a JSON array but not an array of strings`, raw));
|
|
274
|
-
} catch {
|
|
275
|
-
return Effect.fail(configError(`Input "${name}" looks like JSON but could not be parsed`, raw));
|
|
276
|
-
}
|
|
281
|
+
if (trimmed.startsWith("[")) return decodeJson(trimmed).pipe(Effect.mapError((cause) => configError(`Input "${name}" looks like JSON but could not be parsed`, raw, cause)), Effect.flatMap((parsed) => decodeStringArray(parsed).pipe(Effect.mapError((cause) => configError(`Input "${name}" is a JSON array but not an array of strings`, raw, cause)))));
|
|
277
282
|
const entries = trimmed.split(/[\n,]/).map((item) => item.trim()).filter((item) => !item.startsWith("#")).map((item) => item.startsWith("- ") || item.startsWith("* ") ? item.slice(2).trim() : item).filter((item) => item !== "");
|
|
278
283
|
return Effect.succeed(entries);
|
|
279
284
|
}));
|
|
@@ -332,15 +337,7 @@ var ActionInput = class ActionInput {
|
|
|
332
337
|
* which is exactly the missing-versus-malformed split above.
|
|
333
338
|
*/
|
|
334
339
|
static schema(name, schema) {
|
|
335
|
-
return Config.String(inputVariable(name)).pipe(Config.mapEffect((raw) => {
|
|
336
|
-
let parsed;
|
|
337
|
-
try {
|
|
338
|
-
parsed = JSON.parse(raw);
|
|
339
|
-
} catch {
|
|
340
|
-
return Effect.fail(configError(`Input "${name}" is not valid JSON`, raw));
|
|
341
|
-
}
|
|
342
|
-
return Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError(() => configError(`Input "${name}" did not satisfy its schema`, parsed)));
|
|
343
|
-
}));
|
|
340
|
+
return Config.String(inputVariable(name)).pipe(Config.mapEffect((raw) => decodeJson(raw).pipe(Effect.mapError((cause) => configError(`Input "${name}" is not valid JSON`, raw, cause)), Effect.flatMap((parsed) => Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError((cause) => configError(`Input "${name}" did not satisfy its schema`, parsed, cause)))))));
|
|
344
341
|
}
|
|
345
342
|
/**
|
|
346
343
|
* A `ConfigProvider` over a record of runner variables **or input names**.
|
package/ActionsIdentityToken.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
|
|
2
2
|
import { Effect, Layer } from "effect";
|
|
3
|
-
import { IdentityToken, IdentityTokenError } from "@effected/
|
|
3
|
+
import { IdentityToken, IdentityTokenError } from "@effected/oidc";
|
|
4
4
|
|
|
5
5
|
// src/ActionsIdentityToken.ts
|
|
6
6
|
/**
|
|
7
|
-
* Serves `@effected/
|
|
7
|
+
* Serves `@effected/oidc`'s `IdentityToken` contract from the runner's own OIDC
|
|
8
8
|
* token service, so signing an SBOM inside a workflow needs no further wiring.
|
|
9
9
|
*
|
|
10
10
|
* @remarks
|
|
11
|
-
* `@effected/
|
|
12
|
-
* one redacted token — precisely so that
|
|
13
|
-
*
|
|
11
|
+
* `@effected/oidc` declares `IdentityToken` — one method, one audience,
|
|
12
|
+
* one redacted token — precisely so that a consumer of a token, such as
|
|
13
|
+
* `@effected/sbom`'s signer, does not drag the Actions runtime in. This is the
|
|
14
14
|
* layer that closes the inversion from the other side: it serves that contract
|
|
15
15
|
* from the runner's own OIDC token service ({@link OidcTokenIssuer}), the same
|
|
16
16
|
* arrangement as `@effected/workspaces` implementing `@effected/commands`'
|
package/ActionsProvenance.js
CHANGED
|
@@ -20,12 +20,14 @@ const DEFAULT_SERVER_URL = "https://github.com";
|
|
|
20
20
|
*
|
|
21
21
|
* @remarks
|
|
22
22
|
* `SlsaProvenance.forGitHubWorkflow` is total and does the real work, but it
|
|
23
|
-
* takes
|
|
24
|
-
* actually holds is the runner's own OIDC claims,
|
|
25
|
-
*
|
|
26
|
-
* fields, so transposing `
|
|
27
|
-
* passes every type check, and produces a
|
|
28
|
-
* the **wrong** provenance. This module
|
|
23
|
+
* takes an all-string input record — and the only input anyone inside a
|
|
24
|
+
* workflow actually holds is the runner's own OIDC claims, decoded as
|
|
25
|
+
* `@effected/github`'s `ActionsOidcClaims` with integer ids. The projection
|
|
26
|
+
* between the two is eleven close-cousin fields, so transposing `repositoryId`
|
|
27
|
+
* and `repositoryOwnerId` compiles, passes every type check, and produces a
|
|
28
|
+
* validly signed attestation asserting the **wrong** provenance. This module
|
|
29
|
+
* owns that projection once, rendering the ids back to the decimal strings the
|
|
30
|
+
* token carried, on the same
|
|
29
31
|
* `github-actions` ↔ `sbom` seam as {@link ActionsIdentityToken}: `sbom` must
|
|
30
32
|
* not depend on the Actions runtime, so the adapter lives here, beside the
|
|
31
33
|
* services it reads.
|
|
@@ -55,7 +57,7 @@ var ActionsProvenance = class {
|
|
|
55
57
|
*
|
|
56
58
|
* @remarks
|
|
57
59
|
* Every field `GitHubWorkflowProvenance` declares is populated from
|
|
58
|
-
*
|
|
60
|
+
* the runner's `ActionsOidcClaims`, except `serverUrl`, which is read from
|
|
59
61
|
* `GITHUB_SERVER_URL` through {@link ActionEnvironment}'s `getOptional`
|
|
60
62
|
* with `https://github.com` as the default — **not** through the
|
|
61
63
|
* `GitHubContext` projection. The projection fails typed when a `GITHUB_*`
|
|
@@ -88,14 +90,14 @@ var ActionsProvenance = class {
|
|
|
88
90
|
repository: claims.repository,
|
|
89
91
|
ref: claims.ref,
|
|
90
92
|
sha: claims.sha,
|
|
91
|
-
eventName: claims.
|
|
92
|
-
workflowRef: claims.
|
|
93
|
-
jobWorkflowRef: claims.
|
|
94
|
-
repositoryId: claims.
|
|
95
|
-
repositoryOwnerId: claims.
|
|
96
|
-
runnerEnvironment: claims.
|
|
97
|
-
runId: claims.
|
|
98
|
-
runAttempt: claims.
|
|
93
|
+
eventName: claims.eventName,
|
|
94
|
+
workflowRef: claims.workflowRef,
|
|
95
|
+
jobWorkflowRef: claims.jobWorkflowRef,
|
|
96
|
+
repositoryId: String(claims.repositoryId),
|
|
97
|
+
repositoryOwnerId: String(claims.repositoryOwnerId),
|
|
98
|
+
runnerEnvironment: claims.runnerEnvironment,
|
|
99
|
+
runId: String(claims.runId),
|
|
100
|
+
runAttempt: String(claims.runAttempt)
|
|
99
101
|
});
|
|
100
102
|
});
|
|
101
103
|
};
|
package/Artifact.js
CHANGED
|
@@ -7,7 +7,7 @@ import { digestFileHex } from "./internal/digest.js";
|
|
|
7
7
|
import { isWindowsRunner } from "./internal/runner.js";
|
|
8
8
|
import { spawnOnce } from "./internal/spawn.js";
|
|
9
9
|
import { unzipCommand, zipCommand, zipManifest } from "./internal/archiveCommands.js";
|
|
10
|
-
import { Context, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
10
|
+
import { Context, DateTime, Duration, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
11
11
|
import { HttpClient } from "effect/http";
|
|
12
12
|
import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
|
|
13
13
|
import { ChildProcessSpawner } from "effect/process";
|
|
@@ -263,11 +263,12 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
263
263
|
cause
|
|
264
264
|
})));
|
|
265
265
|
const digest = yield* digestOf(packed, name);
|
|
266
|
+
const expiresAt = options?.retentionDays === void 0 ? void 0 : DateTime.formatIso(DateTime.addDuration(yield* DateTime.now, Duration.days(options.retentionDays)));
|
|
266
267
|
const finalized = yield* call("FinalizeArtifact", () => ({
|
|
267
268
|
name,
|
|
268
269
|
size: String(size.size),
|
|
269
270
|
hash: `sha256:${digest}`,
|
|
270
|
-
...
|
|
271
|
+
...expiresAt === void 0 ? {} : { expiresAt }
|
|
271
272
|
}), name);
|
|
272
273
|
if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new ArtifactError({
|
|
273
274
|
reason: "refused",
|
package/BlobStore.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
2
2
|
import { BlobEnvelope } from "./BlobEnvelope.js";
|
|
3
|
-
import { sign } from "./internal/sigv4.js";
|
|
4
3
|
import { Secret } from "./Secret.js";
|
|
5
|
-
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
import { Context, Crypto, DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
6
5
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
import { SigV4 } from "@effected/crypto";
|
|
7
7
|
|
|
8
8
|
// src/BlobStore.ts
|
|
9
9
|
/**
|
|
@@ -15,12 +15,17 @@ var BlobStoreError = class extends Schema.TaggedError()("BlobStoreError", {
|
|
|
15
15
|
/**
|
|
16
16
|
* `unreachable` — the store could not be contacted. `refused` — it answered,
|
|
17
17
|
* unhappily; `status` says how. `misconfigured` — the layer was built with
|
|
18
|
-
* settings the store cannot use
|
|
18
|
+
* settings the store cannot use, or a request could not be signed with them
|
|
19
|
+
* (no WebCrypto in the runtime, or key material it refuses). `invalidKey` —
|
|
20
|
+
* the caller's key cannot be stored at all, whatever the settings: on S3, a
|
|
21
|
+
* key with a `.` or `..` path segment, which no URL carries as signed.
|
|
22
|
+
* Nothing is sent.
|
|
19
23
|
*/
|
|
20
24
|
reason: Schema.Literals([
|
|
21
25
|
"unreachable",
|
|
22
26
|
"refused",
|
|
23
|
-
"misconfigured"
|
|
27
|
+
"misconfigured",
|
|
28
|
+
"invalidKey"
|
|
24
29
|
]),
|
|
25
30
|
/** The key involved. A stable identifier, never a value. */
|
|
26
31
|
key: Schema.optionalKey(Schema.String),
|
|
@@ -35,6 +40,7 @@ var BlobStoreError = class extends Schema.TaggedError()("BlobStoreError", {
|
|
|
35
40
|
switch (this.reason) {
|
|
36
41
|
case "unreachable": return `The blob store could not be reached${this.key === void 0 ? "" : ` for "${this.key}"`}`;
|
|
37
42
|
case "refused": return `The blob store refused "${this.key}"${this.status === void 0 ? "" : ` with status ${this.status}`}${this.detail === void 0 ? "" : `: ${this.detail}`}`;
|
|
43
|
+
case "invalidKey": return `The blob store cannot hold the key "${this.key}"${this.detail === void 0 ? "" : `: ${this.detail}`}`;
|
|
38
44
|
default: return `The blob store is misconfigured${this.detail === void 0 ? "" : `: ${this.detail}`}`;
|
|
39
45
|
}
|
|
40
46
|
}
|
|
@@ -75,10 +81,17 @@ var BlobStore = class BlobStore extends Context.Service()("@effected/github-acti
|
|
|
75
81
|
* An S3-compatible backend, signed with SigV4.
|
|
76
82
|
*
|
|
77
83
|
* @remarks
|
|
78
|
-
* No `@aws-sdk/*` dependency: signing is
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
84
|
+
* No `@aws-sdk/*` dependency: signing is `@effected/crypto`'s `SigV4`, a
|
|
85
|
+
* specified algorithm over strings and HMACs. The SDK's weight is in
|
|
86
|
+
* credential management, retries and a service catalogue none of which this
|
|
87
|
+
* needs.
|
|
88
|
+
*
|
|
89
|
+
* Requires core `Crypto` for the SHA-256 digests signing takes, resolved
|
|
90
|
+
* once when the layer is built; `NodeServices.layer` supplies it. A request
|
|
91
|
+
* that cannot be signed fails as {@link BlobStoreError} `misconfigured`. A
|
|
92
|
+
* key with a `.` or `..` path segment fails as `invalidKey` before anything
|
|
93
|
+
* is signed or sent: every URL parser collapses such a segment, so the store
|
|
94
|
+
* would receive another path than the one signed.
|
|
82
95
|
*
|
|
83
96
|
* A parameterized layer factory mints a fresh layer per call and layers
|
|
84
97
|
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
@@ -119,43 +132,60 @@ var BlobStore = class BlobStore extends Context.Service()("@effected/github-acti
|
|
|
119
132
|
};
|
|
120
133
|
const dies = unstubbed("BlobStore.makeTest");
|
|
121
134
|
const makeS3 = (config) => Effect.gen(function* () {
|
|
122
|
-
const http = yield* HttpClient.HttpClient;
|
|
135
|
+
const http = HttpClient.transform(yield* HttpClient.HttpClient, (effect) => Effect.provideService(effect, HttpClient.TracerPropagationEnabled, false));
|
|
136
|
+
const crypto = yield* Crypto.Crypto;
|
|
123
137
|
let endpoint = config.endpoint ?? `https://s3.${config.region}.amazonaws.com`;
|
|
124
138
|
while (endpoint.endsWith("/")) endpoint = endpoint.slice(0, -1);
|
|
125
139
|
const host = new URL(endpoint).host;
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
yield* Secret.mask(config.secretAccessKey);
|
|
141
|
+
if (config.sessionToken !== void 0) yield* Secret.mask(config.sessionToken);
|
|
142
|
+
const credentials = {
|
|
143
|
+
accessKeyId: config.accessKeyId,
|
|
144
|
+
secretAccessKey: config.secretAccessKey,
|
|
145
|
+
...config.sessionToken === void 0 ? {} : { sessionToken: config.sessionToken },
|
|
146
|
+
region: config.region,
|
|
147
|
+
service: "s3"
|
|
148
|
+
};
|
|
128
149
|
const objectPath = (key) => [
|
|
129
150
|
config.bucket,
|
|
130
151
|
...config.prefix === void 0 ? [] : [config.prefix],
|
|
131
152
|
key
|
|
132
153
|
].join("/").replaceAll(/\/{2,}/g, "/");
|
|
133
|
-
const
|
|
154
|
+
const unsignable = (key, cause) => new BlobStoreError({
|
|
155
|
+
reason: "misconfigured",
|
|
156
|
+
key,
|
|
157
|
+
detail: "the request could not be signed",
|
|
158
|
+
cause
|
|
159
|
+
});
|
|
160
|
+
const request = (method, key, body) => Effect.gen(function* () {
|
|
134
161
|
const path = objectPath(key);
|
|
135
|
-
const
|
|
162
|
+
const now = yield* DateTime.now;
|
|
163
|
+
const signed = yield* SigV4.sign({
|
|
136
164
|
method,
|
|
137
165
|
path,
|
|
138
166
|
host,
|
|
139
|
-
headers: {},
|
|
140
167
|
body,
|
|
141
|
-
now
|
|
142
|
-
}, {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
now
|
|
169
|
+
}, credentials).pipe(Effect.provideService(Crypto.Crypto, crypto), Effect.catchTags({
|
|
170
|
+
SigV4PathError: (cause) => Effect.fail(new BlobStoreError({
|
|
171
|
+
reason: "invalidKey",
|
|
172
|
+
key,
|
|
173
|
+
detail: `the path has a "${cause.segment}" segment, which a URL collapses, so it cannot be sent as signed`,
|
|
174
|
+
cause
|
|
175
|
+
})),
|
|
176
|
+
CryptoError: (cause) => Effect.fail(unsignable(key, cause)),
|
|
177
|
+
PlatformError: (cause) => Effect.fail(unsignable(key, cause))
|
|
178
|
+
}));
|
|
149
179
|
return {
|
|
150
|
-
url: `${endpoint}
|
|
151
|
-
headers
|
|
180
|
+
url: `${endpoint}${signed.path}${signed.query === "" ? "" : `?${signed.query}`}`,
|
|
181
|
+
headers: signed.headers
|
|
152
182
|
};
|
|
153
|
-
};
|
|
154
|
-
const send = (method, key, body) => Effect.
|
|
155
|
-
const { url, headers } = request(method, key, body);
|
|
183
|
+
});
|
|
184
|
+
const send = (method, key, body) => Effect.gen(function* () {
|
|
185
|
+
const { url, headers } = yield* request(method, key, body);
|
|
156
186
|
const base = HttpClientRequest.make(method)(url, { headers });
|
|
157
187
|
const built = body.length === 0 ? base : HttpClientRequest.bodyUint8Array(base, body);
|
|
158
|
-
return http.execute(built).pipe(Effect.mapError((cause) => new BlobStoreError({
|
|
188
|
+
return yield* http.execute(built).pipe(Effect.mapError((cause) => new BlobStoreError({
|
|
159
189
|
reason: "unreachable",
|
|
160
190
|
key,
|
|
161
191
|
cause
|
package/OidcTokenIssuer.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
2
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
|
-
import { payloadOf, unsignedJwt } from "./internal/jwt.js";
|
|
4
3
|
import { Context, Effect, Layer, Redacted, Schema } from "effect";
|
|
5
4
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
5
|
+
import { Oidc } from "@effected/oidc";
|
|
6
|
+
import { ActionsOidcClaimsFromWire } from "@effected/github";
|
|
7
|
+
import * as Base64Url from "effect/encoding/Base64Url";
|
|
6
8
|
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
7
9
|
|
|
8
10
|
// src/OidcTokenIssuer.ts
|
|
@@ -45,44 +47,6 @@ var OidcTokenError = class extends Schema.TaggedError()("OidcTokenError", {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
};
|
|
48
|
-
/**
|
|
49
|
-
* The claims a GitHub Actions OIDC token carries about the workflow that ran.
|
|
50
|
-
*
|
|
51
|
-
* @remarks
|
|
52
|
-
* The field names are GitHub's own JWT claim names, kept verbatim rather than
|
|
53
|
-
* translated to the kit's casing. They are an **external vocabulary** — the
|
|
54
|
-
* same strings that appear in a SLSA provenance predicate and in every
|
|
55
|
-
* published example of an Actions OIDC policy — and renaming them would put a
|
|
56
|
-
* translation layer between a consumer and the specification they are reading.
|
|
57
|
-
*
|
|
58
|
-
* @public
|
|
59
|
-
*/
|
|
60
|
-
var OidcClaims = class extends Schema.Class("OidcClaims")({
|
|
61
|
-
/** The issuer, e.g. `https://token.actions.githubusercontent.com`. */
|
|
62
|
-
iss: Schema.String,
|
|
63
|
-
/** The full ref the workflow ran on. */
|
|
64
|
-
ref: Schema.String,
|
|
65
|
-
/** The commit. */
|
|
66
|
-
sha: Schema.String,
|
|
67
|
-
/** `owner/repo`. */
|
|
68
|
-
repository: Schema.String,
|
|
69
|
-
/** The event that triggered the run. */
|
|
70
|
-
event_name: Schema.String,
|
|
71
|
-
/** The reusable-workflow ref, which is what a verifier pins against. */
|
|
72
|
-
job_workflow_ref: Schema.String,
|
|
73
|
-
/** The calling workflow's ref. */
|
|
74
|
-
workflow_ref: Schema.String,
|
|
75
|
-
/** The numeric repository id, as a string. */
|
|
76
|
-
repository_id: Schema.String,
|
|
77
|
-
/** The numeric owner id, as a string. */
|
|
78
|
-
repository_owner_id: Schema.String,
|
|
79
|
-
/** `github-hosted` or `self-hosted`. */
|
|
80
|
-
runner_environment: Schema.String,
|
|
81
|
-
/** The run id, as a string. */
|
|
82
|
-
run_id: Schema.String,
|
|
83
|
-
/** The run attempt, as a string. */
|
|
84
|
-
run_attempt: Schema.String
|
|
85
|
-
}) {};
|
|
86
50
|
/** The token envelope the runner's token service answers with. */
|
|
87
51
|
const TokenEnvelope = Schema.Struct({
|
|
88
52
|
value: Schema.String,
|
|
@@ -97,16 +61,23 @@ const REQUEST_URL = "ACTIONS_ID_TOKEN_REQUEST_URL";
|
|
|
97
61
|
* The absence of verification is deliberate and is documented on
|
|
98
62
|
* {@link OidcTokenIssuerShape.claims}; do not "fix" it here.
|
|
99
63
|
*/
|
|
100
|
-
const readClaims = (token) =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
64
|
+
const readClaims = (token) => Oidc.decodeUnverified(token, ActionsOidcClaimsFromWire).pipe(Effect.mapError((error) => error.reason === "claims" ? new OidcTokenError({
|
|
65
|
+
reason: "missingClaims",
|
|
66
|
+
cause: error.cause ?? error
|
|
67
|
+
}) : new OidcTokenError({
|
|
68
|
+
reason: "malformedToken",
|
|
69
|
+
detail: error.detail,
|
|
70
|
+
cause: error
|
|
71
|
+
})));
|
|
72
|
+
/**
|
|
73
|
+
* An **unsigned** JWT: `header.payload.unsigned`, each JSON segment
|
|
74
|
+
* base64url-encoded. For test doubles only; the signature segment is a
|
|
75
|
+
* placeholder, so the token fails any verifier.
|
|
76
|
+
*/
|
|
77
|
+
const unsignedJwt = (header, payload) => {
|
|
78
|
+
const segment = (value) => Base64Url.encode(JSON.stringify(value));
|
|
79
|
+
return `${segment(header)}.${segment(payload)}.unsigned`;
|
|
80
|
+
};
|
|
110
81
|
const make = Effect.gen(function* () {
|
|
111
82
|
const env = yield* ActionEnvironment;
|
|
112
83
|
const http = yield* HttpClient.HttpClient;
|
|
@@ -166,7 +137,7 @@ const dies = unstubbed("OidcTokenIssuer.makeTest");
|
|
|
166
137
|
* const program = Effect.gen(function* () {
|
|
167
138
|
* const issuer = yield* OidcTokenIssuer;
|
|
168
139
|
* const claims = yield* issuer.claims("sigstore");
|
|
169
|
-
* return claims.
|
|
140
|
+
* return claims.jobWorkflowRef;
|
|
170
141
|
* });
|
|
171
142
|
* ```
|
|
172
143
|
*
|
|
@@ -193,7 +164,7 @@ var OidcTokenIssuer = class OidcTokenIssuer extends Context.Service()("@effected
|
|
|
193
164
|
static unsignedTokenFor = (claims) => Redacted.make(unsignedJwt({
|
|
194
165
|
alg: "RS256",
|
|
195
166
|
typ: "JWT"
|
|
196
|
-
}, Schema.
|
|
167
|
+
}, Schema.encodeSync(ActionsOidcClaimsFromWire)(claims)));
|
|
197
168
|
/** A test double. Unstubbed members die rather than answering with a non-token. */
|
|
198
169
|
static makeTest = (overrides = {}) => ({
|
|
199
170
|
token: () => dies("token"),
|
|
@@ -218,4 +189,4 @@ var OidcTokenIssuer = class OidcTokenIssuer extends Context.Service()("@effected
|
|
|
218
189
|
});
|
|
219
190
|
};
|
|
220
191
|
|
|
221
|
-
export {
|
|
192
|
+
export { OidcTokenError, OidcTokenIssuer };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
2
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
3
|
import { digestFileHex } from "./internal/digest.js";
|
|
4
|
-
import { isWindowsRunner } from "./internal/runner.js";
|
|
4
|
+
import { isWindowsRunner, runnerArch } from "./internal/runner.js";
|
|
5
5
|
import { typeAt } from "./internal/fsProbe.js";
|
|
6
6
|
import { PNPM_EXE_PREFIX, detectMusl, isNodeScript, pnpmExeTarget, strongestSri } from "./internal/pnpmExe.js";
|
|
7
7
|
import { cmdShim, gitBashShim, posixShim } from "./internal/shim.js";
|
|
@@ -12,6 +12,17 @@ import { CorepackIntegrityHash, DEFAULT_REGISTRY, PackageManagerPin, PackageMana
|
|
|
12
12
|
|
|
13
13
|
// src/PackageManagerInstaller.ts
|
|
14
14
|
/**
|
|
15
|
+
* The two `package.json` members the installer reads, as JSON text decoded to
|
|
16
|
+
* an object. Both stay `unknown`: they are attacker-supplied, and their own
|
|
17
|
+
* normalizers decide what survives.
|
|
18
|
+
*/
|
|
19
|
+
const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({
|
|
20
|
+
bin: Schema.optionalKey(Schema.Unknown),
|
|
21
|
+
optionalDependencies: Schema.optionalKey(Schema.Unknown)
|
|
22
|
+
})));
|
|
23
|
+
/** A packument's `dist.integrity`, left `unknown` for `expectedFromSri` to judge. */
|
|
24
|
+
const decodePackument = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ dist: Schema.optionalKey(Schema.Struct({ integrity: Schema.optionalKey(Schema.Unknown) })) })));
|
|
25
|
+
/**
|
|
15
26
|
* Raised when a package manager cannot be provisioned on the runner.
|
|
16
27
|
*
|
|
17
28
|
* @public
|
|
@@ -202,11 +213,6 @@ const normalizeBins = (bin, fallbackName) => {
|
|
|
202
213
|
}
|
|
203
214
|
return Object.keys(entries).length === 0 ? Option.none() : Option.some(entries);
|
|
204
215
|
};
|
|
205
|
-
/** Map a `RUNNER_ARCH` value (`X64`, `ARM64`) onto the Node arch spelling. */
|
|
206
|
-
const archFromRunner = (runnerArch) => {
|
|
207
|
-
const lowered = runnerArch.toLowerCase();
|
|
208
|
-
return lowered === "x64" || lowered === "arm64" ? lowered : runnerArch;
|
|
209
|
-
};
|
|
210
216
|
const make = Effect.gen(function* () {
|
|
211
217
|
const env = yield* ActionEnvironment;
|
|
212
218
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -214,10 +220,7 @@ const make = Effect.gen(function* () {
|
|
|
214
220
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
215
221
|
const installer = yield* ToolInstaller;
|
|
216
222
|
const runnerOs = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.getOrElse(found, () => ""));
|
|
217
|
-
const arch = yield*
|
|
218
|
-
onNone: () => process.arch,
|
|
219
|
-
onSome: archFromRunner
|
|
220
|
-
}));
|
|
223
|
+
const arch = yield* runnerArch(env);
|
|
221
224
|
const windows = yield* isWindowsRunner(env);
|
|
222
225
|
const musl = detectMusl();
|
|
223
226
|
const bunBinaryName = windows ? "bun.exe" : "bun";
|
|
@@ -305,14 +308,11 @@ const make = Effect.gen(function* () {
|
|
|
305
308
|
subject: "no package.json",
|
|
306
309
|
cause
|
|
307
310
|
})));
|
|
308
|
-
const manifest = yield* Effect.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
cause
|
|
314
|
-
})
|
|
315
|
-
});
|
|
311
|
+
const manifest = yield* decodeManifest(raw).pipe(Effect.mapError((cause) => errorFor(pin)({
|
|
312
|
+
reason: "layoutUnexpected",
|
|
313
|
+
subject: "unparseable package.json",
|
|
314
|
+
cause
|
|
315
|
+
})));
|
|
316
316
|
const bins = Option.getOrUndefined(normalizeBins(manifest.bin, pin.name));
|
|
317
317
|
if (bins === void 0) return yield* Effect.fail(errorFor(pin)({
|
|
318
318
|
reason: "layoutUnexpected",
|
|
@@ -458,10 +458,7 @@ const make = Effect.gen(function* () {
|
|
|
458
458
|
subject: packumentUrl,
|
|
459
459
|
cause
|
|
460
460
|
});
|
|
461
|
-
const packument = yield* fs.readFileString(packumentFile).pipe(Effect.mapError(unverifiable), Effect.flatMap((raw) => Effect.
|
|
462
|
-
try: () => JSON.parse(raw),
|
|
463
|
-
catch: unverifiable
|
|
464
|
-
})));
|
|
461
|
+
const packument = yield* fs.readFileString(packumentFile).pipe(Effect.mapError(unverifiable), Effect.flatMap((raw) => decodePackument(raw).pipe(Effect.mapError(unverifiable))));
|
|
465
462
|
return yield* expectedFromSri(pin, packument.dist?.integrity, packumentUrl);
|
|
466
463
|
});
|
|
467
464
|
/**
|
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ Two decisions make that safe for any segment value, with no prefix reasoning at
|
|
|
155
155
|
|
|
156
156
|
`CacheKey.hashFiles` is byte-compatible with `@actions/glob`'s `hashFiles`, and `OidcTokenIssuer.claims` decodes the runner's OIDC token for a SLSA provenance predicate without verifying its signature — the token arrived over TLS from the runner's own token service, so the transport is the trust boundary and nothing here branches on the claims for authorization.
|
|
157
157
|
|
|
158
|
-
`ActionsProvenance.capture` turns those claims into an `@effected/sbom` `SlsaProvenance` predicate, and `ActionsIdentityToken.layer` serves
|
|
158
|
+
`ActionsProvenance.capture` turns those claims into an `@effected/sbom` `SlsaProvenance` predicate, and `ActionsIdentityToken.layer` serves `@effected/oidc`'s narrow `IdentityToken` contract, which that package's signer consumes, from the same issuer — the inversion that keeps the Actions runtime out of every consumer that only wants to emit an SBOM:
|
|
159
159
|
|
|
160
160
|
```ts
|
|
161
161
|
import { ActionsIdentityToken, ActionsProvenance, OidcTokenIssuer } from "@effected/github-actions";
|
|
@@ -303,13 +303,13 @@ const TestOutputs = ActionOutputs.layerTest({
|
|
|
303
303
|
- `ActionCache` / `Artifact` — the runner's cache and artifact protocols over Twirp v2, excluded from the default runtime so a consumer that skips them never links Azure.
|
|
304
304
|
- `CacheKey` — cache keys and their restore-key ladder, `withNamespace(segment)` for a bust token or a shared-runner prefix, plus `hashFiles`/`matchingFiles`/`hashMatching` byte-compatible with `@actions/glob`.
|
|
305
305
|
- `OidcTokenIssuer` — the runner's OIDC token service, with unverified claim decoding for provenance predicates.
|
|
306
|
-
- `ActionsIdentityToken` / `ActionsProvenance` — the `@effected/sbom` seam: `layer` serves
|
|
306
|
+
- `ActionsIdentityToken` / `ActionsProvenance` — the `@effected/sbom` seam: `layer` serves `@effected/oidc`'s `IdentityToken` contract from the runner's issuer, and `capture` builds a `SlsaProvenance` predicate out of the runner's OIDC claims.
|
|
307
307
|
- `GitHubMarkdown` — the escaping-safe writer for GitHub surfaces: `table`, `tableFor(schema)`, `heading`, `link`, `code`, `codeBlock`, `list`, `details` and `raw`, and the package's only importer of `@effected/markdown`.
|
|
308
308
|
- `ManagedDocument` — marker-delimited named regions in text a human also edits, for a sticky comment or a managed pull request description; content outside the regions survives byte-for-byte, and each region can carry marker metadata that round-trips through `entry(key)` without ever addressing the region.
|
|
309
309
|
- `CheckState` / `projectCheckState` — the kit's check vocabulary, wider than GitHub's conclusions, and its projection onto the check-run wire form.
|
|
310
310
|
- `CheckDocument` — the debounced check-to-document reconciler: report states as they change, and one write lands carrying the final one. `stamp` plus a readable sink lets two runs share one document, dropping a strictly older run's pass instead of clobbering the newer one; `flush` reports `written`, `unchanged` or `stale`.
|
|
311
311
|
- `ToolInstaller` — download, extract and cache a toolchain in the runner's tool cache, with no `@actions/tool-cache` dependency; `ToolInstallerError` always names its `subject`.
|
|
312
|
-
- `BlobStore` — durable `get`/`put`/`has` over `StoredBlob<A>` values (bytes plus caller-schema metadata; the type was named `Blob<A>` before, which collided with the global), backed by `layerS3` (SigV4-signed
|
|
312
|
+
- `BlobStore` — durable `get`/`put`/`has` over `StoredBlob<A>` values (bytes plus caller-schema metadata; the type was named `Blob<A>` before, which collided with the global), backed by `layerS3` (SigV4-signed through `@effected/crypto`, so it needs core `Crypto` in `R`; no `@aws-sdk/*` dependency) or `GitHubCacheBlobStore.layer` (the runner's own cache); `layerMemory` runs the real envelope framing for tests.
|
|
313
313
|
|
|
314
314
|
## License
|
|
315
315
|
|
package/Secret.js
CHANGED
|
@@ -94,9 +94,13 @@ var Secret = class Secret {
|
|
|
94
94
|
* Declassify one secret for an in-process use that needs the raw bytes.
|
|
95
95
|
*
|
|
96
96
|
* @remarks
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
* For a consumer that must hand the raw key to code taking a plain string,
|
|
98
|
+
* such as a third-party signing library. A signer that takes a `Redacted`
|
|
99
|
+
* needs no declassification at all: `@effected/crypto`'s `SigV4` and `Hmac`
|
|
100
|
+
* unwrap the key only where they compute, so a caller of those (this
|
|
101
|
+
* package's own S3 `BlobStore` is one) registers the value with
|
|
102
|
+
* {@link Secret.mask} and passes the `Redacted` on. No module in this
|
|
103
|
+
* package calls this member any more.
|
|
100
104
|
*
|
|
101
105
|
* Signing named the member; it does not gate it. **Any in-process use that
|
|
102
106
|
* needs the raw value without writing it to a runner file is this
|
|
@@ -121,9 +125,9 @@ var Secret = class Secret {
|
|
|
121
125
|
* `::add-mask::` command only masks when the runner parses this process's
|
|
122
126
|
* stdout; a detached worker's stdout is a log file no runner parses, so
|
|
123
127
|
* there the command masks nothing *and* spells the plaintext into the log.
|
|
124
|
-
* A worker that signs
|
|
125
|
-
*
|
|
126
|
-
*
|
|
128
|
+
* A worker that signs must compose `ActionOutputs.layerDetached`, under
|
|
129
|
+
* which this member still returns the raw key but the mask is a
|
|
130
|
+
* documented no-op; the masking
|
|
127
131
|
* itself is the **parent's** job, done before the spawn via
|
|
128
132
|
* {@link Secret.forChildEnv} under the real layer.
|
|
129
133
|
*/
|
package/ToolInstaller.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
2
|
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
|
-
import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
|
|
3
|
+
import { isWindowsRunner, runnerArch, toolCacheRoot } from "./internal/runner.js";
|
|
4
4
|
import { spawnOnce } from "./internal/spawn.js";
|
|
5
5
|
import { tarExtractCommand, unzipCommand } from "./internal/archiveCommands.js";
|
|
6
6
|
import { isErrno, typeAt } from "./internal/fsProbe.js";
|
|
@@ -68,11 +68,12 @@ const make = Effect.gen(function* () {
|
|
|
68
68
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
69
69
|
const root = yield* toolCacheRoot(env, path);
|
|
70
70
|
const windows = yield* isWindowsRunner(env);
|
|
71
|
+
const arch = yield* runnerArch(env);
|
|
71
72
|
const cachePath = (tool, version) => ToolInstaller.cachePath({
|
|
72
73
|
root,
|
|
73
74
|
tool,
|
|
74
75
|
version,
|
|
75
|
-
arch
|
|
76
|
+
arch
|
|
76
77
|
});
|
|
77
78
|
const destinationFor = (options) => {
|
|
78
79
|
const requested = options?.destination;
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { AnnotationProperties, AnnotationProperties as AnnotationProperties$1, WorkflowCommand } from "@effected/github-commands";
|
|
2
2
|
import { NodeServices } from "@effect/platform-node";
|
|
3
|
-
import { Cause, Config, ConfigProvider, Context, Duration, Effect, FileSystem, Layer, Logger, Option, Path, Redacted, Result, Schema } from "effect";
|
|
3
|
+
import { Cause, Config, ConfigProvider, Context, Crypto, Duration, Effect, FileSystem, Layer, Logger, Option, Path, Redacted, Result, Schema } from "effect";
|
|
4
4
|
import { HttpClient } from "effect/http";
|
|
5
5
|
import { ChildProcessSpawner } from "effect/process";
|
|
6
|
-
import { IdentityToken
|
|
7
|
-
import { BotIdentity, GitHubApp, GitHubClient, InstallationToken, PermissionLevel, RetryPolicy } from "@effected/github";
|
|
6
|
+
import { IdentityToken } from "@effected/oidc";
|
|
7
|
+
import { ActionsOidcClaims, BotIdentity, GitHubApp, GitHubClient, InstallationToken, PermissionLevel, RetryPolicy } from "@effected/github";
|
|
8
|
+
import { SlsaProvenance } from "@effected/sbom";
|
|
8
9
|
import { IntegrityHashBrand, PackageManagerPin } from "@effected/npm";
|
|
9
10
|
declare const ActionEnvironmentError_base: Schema.Class<ActionEnvironmentError, Schema.TaggedStruct<"ActionEnvironmentError", {
|
|
10
11
|
/** `missing` — absent or empty; `malformed` — present but unusable. */
|
|
@@ -1953,45 +1954,6 @@ declare const OidcTokenError_base: Schema.Class<OidcTokenError, Schema.TaggedStr
|
|
|
1953
1954
|
export declare class OidcTokenError extends OidcTokenError_base {
|
|
1954
1955
|
get message(): string;
|
|
1955
1956
|
}
|
|
1956
|
-
declare const OidcClaims_base: Schema.Class<OidcClaims, Schema.Struct<{
|
|
1957
|
-
/** The issuer, e.g. `https://token.actions.githubusercontent.com`. */
|
|
1958
|
-
readonly iss: Schema.String;
|
|
1959
|
-
/** The full ref the workflow ran on. */
|
|
1960
|
-
readonly ref: Schema.String;
|
|
1961
|
-
/** The commit. */
|
|
1962
|
-
readonly sha: Schema.String;
|
|
1963
|
-
/** `owner/repo`. */
|
|
1964
|
-
readonly repository: Schema.String;
|
|
1965
|
-
/** The event that triggered the run. */
|
|
1966
|
-
readonly event_name: Schema.String;
|
|
1967
|
-
/** The reusable-workflow ref, which is what a verifier pins against. */
|
|
1968
|
-
readonly job_workflow_ref: Schema.String;
|
|
1969
|
-
/** The calling workflow's ref. */
|
|
1970
|
-
readonly workflow_ref: Schema.String;
|
|
1971
|
-
/** The numeric repository id, as a string. */
|
|
1972
|
-
readonly repository_id: Schema.String;
|
|
1973
|
-
/** The numeric owner id, as a string. */
|
|
1974
|
-
readonly repository_owner_id: Schema.String;
|
|
1975
|
-
/** `github-hosted` or `self-hosted`. */
|
|
1976
|
-
readonly runner_environment: Schema.String;
|
|
1977
|
-
/** The run id, as a string. */
|
|
1978
|
-
readonly run_id: Schema.String;
|
|
1979
|
-
/** The run attempt, as a string. */
|
|
1980
|
-
readonly run_attempt: Schema.String;
|
|
1981
|
-
}>, {}>;
|
|
1982
|
-
/**
|
|
1983
|
-
* The claims a GitHub Actions OIDC token carries about the workflow that ran.
|
|
1984
|
-
*
|
|
1985
|
-
* @remarks
|
|
1986
|
-
* The field names are GitHub's own JWT claim names, kept verbatim rather than
|
|
1987
|
-
* translated to the kit's casing. They are an **external vocabulary** — the
|
|
1988
|
-
* same strings that appear in a SLSA provenance predicate and in every
|
|
1989
|
-
* published example of an Actions OIDC policy — and renaming them would put a
|
|
1990
|
-
* translation layer between a consumer and the specification they are reading.
|
|
1991
|
-
*
|
|
1992
|
-
* @public
|
|
1993
|
-
*/
|
|
1994
|
-
export declare class OidcClaims extends OidcClaims_base {}
|
|
1995
1957
|
/**
|
|
1996
1958
|
* The members of the {@link OidcTokenIssuer} service: request an ID `token`
|
|
1997
1959
|
* and read its decoded `claims`, both failing with {@link OidcTokenError}.
|
|
@@ -2009,7 +1971,8 @@ interface OidcTokenIssuerShape {
|
|
|
2009
1971
|
*/
|
|
2010
1972
|
readonly token: (audience?: string) => Effect.Effect<Redacted.Redacted<string>, OidcTokenError>;
|
|
2011
1973
|
/**
|
|
2012
|
-
* The token's claims, decoded
|
|
1974
|
+
* The token's claims, decoded into `@effected/github`'s `ActionsOidcClaims`,
|
|
1975
|
+
* the same class `ActionsOidc.verify` returns.
|
|
2013
1976
|
*
|
|
2014
1977
|
* @remarks
|
|
2015
1978
|
* **The signature is deliberately not verified**, for three reasons that are
|
|
@@ -2034,7 +1997,7 @@ interface OidcTokenIssuerShape {
|
|
|
2034
1997
|
* a test cannot silently skip the path under test because the double
|
|
2035
1998
|
* returned a token that is not a JWT.
|
|
2036
1999
|
*/
|
|
2037
|
-
readonly claims: (audience?: string) => Effect.Effect<
|
|
2000
|
+
readonly claims: (audience?: string) => Effect.Effect<ActionsOidcClaims, OidcTokenError>;
|
|
2038
2001
|
}
|
|
2039
2002
|
declare const OidcTokenIssuer_base: Context.ServiceClass<OidcTokenIssuer, "@effected/github-actions/OidcTokenIssuer", OidcTokenIssuerShape>;
|
|
2040
2003
|
/**
|
|
@@ -2054,7 +2017,7 @@ declare const OidcTokenIssuer_base: Context.ServiceClass<OidcTokenIssuer, "@effe
|
|
|
2054
2017
|
* const program = Effect.gen(function* () {
|
|
2055
2018
|
* const issuer = yield* OidcTokenIssuer;
|
|
2056
2019
|
* const claims = yield* issuer.claims("sigstore");
|
|
2057
|
-
* return claims.
|
|
2020
|
+
* return claims.jobWorkflowRef;
|
|
2058
2021
|
* });
|
|
2059
2022
|
* ```
|
|
2060
2023
|
*
|
|
@@ -2078,7 +2041,7 @@ export declare class OidcTokenIssuer extends OidcTokenIssuer_base {
|
|
|
2078
2041
|
* double returning a synthetic non-JWT would make the provenance path
|
|
2079
2042
|
* structurally unreachable in a test.
|
|
2080
2043
|
*/
|
|
2081
|
-
static readonly unsignedTokenFor: (claims:
|
|
2044
|
+
static readonly unsignedTokenFor: (claims: ActionsOidcClaims) => Redacted.Redacted<string>;
|
|
2082
2045
|
/** A test double. Unstubbed members die rather than answering with a non-token. */
|
|
2083
2046
|
static readonly makeTest: (overrides?: Partial<OidcTokenIssuerShape>) => OidcTokenIssuerShape;
|
|
2084
2047
|
/** {@link OidcTokenIssuer.makeTest} behind `Layer.succeed`. */
|
|
@@ -2093,16 +2056,16 @@ export declare class OidcTokenIssuer extends OidcTokenIssuer_base {
|
|
|
2093
2056
|
* members can disagree is how a test proves a path works while production
|
|
2094
2057
|
* takes the other one.
|
|
2095
2058
|
*/
|
|
2096
|
-
static readonly layerFor: (claims:
|
|
2059
|
+
static readonly layerFor: (claims: ActionsOidcClaims) => Layer.Layer<OidcTokenIssuer>;
|
|
2097
2060
|
}
|
|
2098
2061
|
/**
|
|
2099
|
-
* Serves `@effected/
|
|
2062
|
+
* Serves `@effected/oidc`'s `IdentityToken` contract from the runner's own OIDC
|
|
2100
2063
|
* token service, so signing an SBOM inside a workflow needs no further wiring.
|
|
2101
2064
|
*
|
|
2102
2065
|
* @remarks
|
|
2103
|
-
* `@effected/
|
|
2104
|
-
* one redacted token — precisely so that
|
|
2105
|
-
*
|
|
2066
|
+
* `@effected/oidc` declares `IdentityToken` — one method, one audience,
|
|
2067
|
+
* one redacted token — precisely so that a consumer of a token, such as
|
|
2068
|
+
* `@effected/sbom`'s signer, does not drag the Actions runtime in. This is the
|
|
2106
2069
|
* layer that closes the inversion from the other side: it serves that contract
|
|
2107
2070
|
* from the runner's own OIDC token service ({@link OidcTokenIssuer}), the same
|
|
2108
2071
|
* arrangement as `@effected/workspaces` implementing `@effected/commands`'
|
|
@@ -2152,12 +2115,14 @@ export declare class ActionsIdentityToken {
|
|
|
2152
2115
|
*
|
|
2153
2116
|
* @remarks
|
|
2154
2117
|
* `SlsaProvenance.forGitHubWorkflow` is total and does the real work, but it
|
|
2155
|
-
* takes
|
|
2156
|
-
* actually holds is the runner's own OIDC claims,
|
|
2157
|
-
*
|
|
2158
|
-
* fields, so transposing `
|
|
2159
|
-
* passes every type check, and produces a
|
|
2160
|
-
* the **wrong** provenance. This module
|
|
2118
|
+
* takes an all-string input record — and the only input anyone inside a
|
|
2119
|
+
* workflow actually holds is the runner's own OIDC claims, decoded as
|
|
2120
|
+
* `@effected/github`'s `ActionsOidcClaims` with integer ids. The projection
|
|
2121
|
+
* between the two is eleven close-cousin fields, so transposing `repositoryId`
|
|
2122
|
+
* and `repositoryOwnerId` compiles, passes every type check, and produces a
|
|
2123
|
+
* validly signed attestation asserting the **wrong** provenance. This module
|
|
2124
|
+
* owns that projection once, rendering the ids back to the decimal strings the
|
|
2125
|
+
* token carried, on the same
|
|
2161
2126
|
* `github-actions` ↔ `sbom` seam as {@link ActionsIdentityToken}: `sbom` must
|
|
2162
2127
|
* not depend on the Actions runtime, so the adapter lives here, beside the
|
|
2163
2128
|
* services it reads.
|
|
@@ -2187,7 +2152,7 @@ export declare class ActionsProvenance {
|
|
|
2187
2152
|
*
|
|
2188
2153
|
* @remarks
|
|
2189
2154
|
* Every field `GitHubWorkflowProvenance` declares is populated from
|
|
2190
|
-
*
|
|
2155
|
+
* the runner's `ActionsOidcClaims`, except `serverUrl`, which is read from
|
|
2191
2156
|
* `GITHUB_SERVER_URL` through {@link ActionEnvironment}'s `getOptional`
|
|
2192
2157
|
* with `https://github.com` as the default — **not** through the
|
|
2193
2158
|
* `GitHubContext` projection. The projection fails typed when a `GITHUB_*`
|
|
@@ -2548,9 +2513,13 @@ declare const BlobStoreError_base: Schema.Class<BlobStoreError, Schema.TaggedStr
|
|
|
2548
2513
|
/**
|
|
2549
2514
|
* `unreachable` — the store could not be contacted. `refused` — it answered,
|
|
2550
2515
|
* unhappily; `status` says how. `misconfigured` — the layer was built with
|
|
2551
|
-
* settings the store cannot use
|
|
2516
|
+
* settings the store cannot use, or a request could not be signed with them
|
|
2517
|
+
* (no WebCrypto in the runtime, or key material it refuses). `invalidKey` —
|
|
2518
|
+
* the caller's key cannot be stored at all, whatever the settings: on S3, a
|
|
2519
|
+
* key with a `.` or `..` path segment, which no URL carries as signed.
|
|
2520
|
+
* Nothing is sent.
|
|
2552
2521
|
*/
|
|
2553
|
-
readonly reason: Schema.Literals<readonly ["unreachable", "refused", "misconfigured"]>;
|
|
2522
|
+
readonly reason: Schema.Literals<readonly ["unreachable", "refused", "misconfigured", "invalidKey"]>;
|
|
2554
2523
|
/** The key involved. A stable identifier, never a value. */
|
|
2555
2524
|
readonly key: Schema.optionalKey<Schema.String>;
|
|
2556
2525
|
/** The HTTP status, when the store answered. */
|
|
@@ -2665,16 +2634,23 @@ export declare class BlobStore extends BlobStore_base {
|
|
|
2665
2634
|
* An S3-compatible backend, signed with SigV4.
|
|
2666
2635
|
*
|
|
2667
2636
|
* @remarks
|
|
2668
|
-
* No `@aws-sdk/*` dependency: signing is
|
|
2669
|
-
*
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2637
|
+
* No `@aws-sdk/*` dependency: signing is `@effected/crypto`'s `SigV4`, a
|
|
2638
|
+
* specified algorithm over strings and HMACs. The SDK's weight is in
|
|
2639
|
+
* credential management, retries and a service catalogue none of which this
|
|
2640
|
+
* needs.
|
|
2641
|
+
*
|
|
2642
|
+
* Requires core `Crypto` for the SHA-256 digests signing takes, resolved
|
|
2643
|
+
* once when the layer is built; `NodeServices.layer` supplies it. A request
|
|
2644
|
+
* that cannot be signed fails as {@link BlobStoreError} `misconfigured`. A
|
|
2645
|
+
* key with a `.` or `..` path segment fails as `invalidKey` before anything
|
|
2646
|
+
* is signed or sent: every URL parser collapses such a segment, so the store
|
|
2647
|
+
* would receive another path than the one signed.
|
|
2672
2648
|
*
|
|
2673
2649
|
* A parameterized layer factory mints a fresh layer per call and layers
|
|
2674
2650
|
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
2675
2651
|
* composition site.
|
|
2676
2652
|
*/
|
|
2677
|
-
static readonly layerS3: (config: S3Config) => Layer.Layer<BlobStore, never, HttpClient.HttpClient | ActionOutputs>;
|
|
2653
|
+
static readonly layerS3: (config: S3Config) => Layer.Layer<BlobStore, never, HttpClient.HttpClient | ActionOutputs | Crypto.Crypto>;
|
|
2678
2654
|
/** A test double. Unstubbed members die rather than reporting a miss. */
|
|
2679
2655
|
static readonly makeTest: (overrides?: Partial<BlobStoreShape>) => BlobStoreShape;
|
|
2680
2656
|
/** {@link BlobStore.makeTest} behind `Layer.succeed`. */
|
|
@@ -4988,9 +4964,13 @@ export declare class Secret {
|
|
|
4988
4964
|
* Declassify one secret for an in-process use that needs the raw bytes.
|
|
4989
4965
|
*
|
|
4990
4966
|
* @remarks
|
|
4991
|
-
*
|
|
4992
|
-
*
|
|
4993
|
-
*
|
|
4967
|
+
* For a consumer that must hand the raw key to code taking a plain string,
|
|
4968
|
+
* such as a third-party signing library. A signer that takes a `Redacted`
|
|
4969
|
+
* needs no declassification at all: `@effected/crypto`'s `SigV4` and `Hmac`
|
|
4970
|
+
* unwrap the key only where they compute, so a caller of those (this
|
|
4971
|
+
* package's own S3 `BlobStore` is one) registers the value with
|
|
4972
|
+
* {@link Secret.mask} and passes the `Redacted` on. No module in this
|
|
4973
|
+
* package calls this member any more.
|
|
4994
4974
|
*
|
|
4995
4975
|
* Signing named the member; it does not gate it. **Any in-process use that
|
|
4996
4976
|
* needs the raw value without writing it to a runner file is this
|
|
@@ -5015,9 +4995,9 @@ export declare class Secret {
|
|
|
5015
4995
|
* `::add-mask::` command only masks when the runner parses this process's
|
|
5016
4996
|
* stdout; a detached worker's stdout is a log file no runner parses, so
|
|
5017
4997
|
* there the command masks nothing *and* spells the plaintext into the log.
|
|
5018
|
-
* A worker that signs
|
|
5019
|
-
*
|
|
5020
|
-
*
|
|
4998
|
+
* A worker that signs must compose `ActionOutputs.layerDetached`, under
|
|
4999
|
+
* which this member still returns the raw key but the mask is a
|
|
5000
|
+
* documented no-op; the masking
|
|
5021
5001
|
* itself is the **parent's** job, done before the spawn via
|
|
5022
5002
|
* {@link Secret.forChildEnv} under the real layer.
|
|
5023
5003
|
*/
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { ActionState, ActionStateError } from "./ActionState.js";
|
|
|
6
6
|
import { Action, ActionRuntime, describeCause } from "./Action.js";
|
|
7
7
|
import { BlobTransferError } from "./BlobTransfer.js";
|
|
8
8
|
import { ActionCache, ActionCacheError } from "./ActionCache.js";
|
|
9
|
-
import {
|
|
9
|
+
import { OidcTokenError, OidcTokenIssuer } from "./OidcTokenIssuer.js";
|
|
10
10
|
import { ActionsIdentityToken } from "./ActionsIdentityToken.js";
|
|
11
11
|
import { ActionsProvenance } from "./ActionsProvenance.js";
|
|
12
12
|
import { Artifact, ArtifactError } from "./Artifact.js";
|
|
@@ -27,4 +27,4 @@ import { ToolInstaller, ToolInstallerError } from "./ToolInstaller.js";
|
|
|
27
27
|
import { AmbientPackageManager, CachedPackageManager, InstalledPackageManager, PackageManagerInstaller, PackageManagerInstallerError } from "./PackageManagerInstaller.js";
|
|
28
28
|
import { WorkflowCommand } from "@effected/github-commands";
|
|
29
29
|
|
|
30
|
-
export { Action, ActionCache, ActionCacheError, ActionEnvironment, ActionEnvironmentError, ActionInput, ActionLogger, ActionOutputs, ActionRuntime, ActionState, ActionStateError, ActionsIdentityToken, ActionsProvenance, AmbientPackageManager, Artifact, ArtifactError, BlobEnvelope, BlobMetadataDecodeError, BlobMetadataEncodeError, BlobStore, BlobStoreError, BlobTransferError, CacheKey, CacheKeyBadPatternError, CacheKeyReadError, CachedPackageManager, CheckDocument, CheckDocumentError, CheckDocumentStamp, CheckReport, CheckState, ChildEnv, DetachedLogUnavailableError, DetachedNotReadyError, DetachedOutputError, DetachedProcess, DetachedSignalFailedError, DetachedSpawnFailedError, DryRun, GitHubCacheBlobStore, GitHubContext, GitHubMarkdown, GitHubToken, GitHubTokenError, InstalledPackageManager, InvalidOutputNameError, InvalidPidError, ManagedDocument, ManagedDocumentError, NotABlobEnvelopeError,
|
|
30
|
+
export { Action, ActionCache, ActionCacheError, ActionEnvironment, ActionEnvironmentError, ActionInput, ActionLogger, ActionOutputs, ActionRuntime, ActionState, ActionStateError, ActionsIdentityToken, ActionsProvenance, AmbientPackageManager, Artifact, ArtifactError, BlobEnvelope, BlobMetadataDecodeError, BlobMetadataEncodeError, BlobStore, BlobStoreError, BlobTransferError, CacheKey, CacheKeyBadPatternError, CacheKeyReadError, CachedPackageManager, CheckDocument, CheckDocumentError, CheckDocumentStamp, CheckReport, CheckState, ChildEnv, DetachedLogUnavailableError, DetachedNotReadyError, DetachedOutputError, DetachedProcess, DetachedSignalFailedError, DetachedSpawnFailedError, DryRun, GitHubCacheBlobStore, GitHubContext, GitHubMarkdown, GitHubToken, GitHubTokenError, InstalledPackageManager, InvalidOutputNameError, InvalidPidError, ManagedDocument, ManagedDocumentError, NotABlobEnvelopeError, OidcTokenError, OidcTokenIssuer, OutputEncodeError, PackageManagerInstaller, PackageManagerInstallerError, ProcessId, RecordedOutput, RunnerContext, RunnerFileUnavailableError, RunnerFileWriteError, Secret, ToolInstaller, ToolInstallerError, TruncatedBlobEnvelopeError, UnsupportedBlobEnvelopeVersionError, WorkflowCommand, describeCause, projectCheckState };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { payloadOf } from "./jwt.js";
|
|
2
1
|
import { Effect, Option, Redacted, Result } from "effect";
|
|
2
|
+
import { Jws } from "@effected/jwt";
|
|
3
3
|
|
|
4
4
|
// src/internal/actionsResults.ts
|
|
5
5
|
/** The variable naming the backend. Absent outside a `uses:` step. */
|
|
@@ -26,9 +26,10 @@ const misconfiguredDetail = (variable, service) => `${variable} is not set — t
|
|
|
26
26
|
* @internal
|
|
27
27
|
*/
|
|
28
28
|
const backendIdsFrom = (token) => {
|
|
29
|
-
const
|
|
30
|
-
if (Result.isFailure(
|
|
31
|
-
const
|
|
29
|
+
const decoded = Jws.decodeUnverified(token);
|
|
30
|
+
if (Result.isFailure(decoded)) return Result.fail(`the runtime token is not a readable JWT: ${decoded.failure.detail}`);
|
|
31
|
+
const payload = decoded.success.payload;
|
|
32
|
+
const scope = typeof payload === "object" && payload !== null ? payload.scp : void 0;
|
|
32
33
|
if (typeof scope !== "string") return Result.fail("the runtime token carries no `scp` claim");
|
|
33
34
|
for (const entry of scope.split(" ")) {
|
|
34
35
|
const parts = entry.split(":");
|
package/internal/json.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/internal/json.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parse JSON text to an unknown value, failing with a `SchemaError` on text that is not JSON.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
10
|
+
|
|
11
|
+
export { decodeJson };
|
package/internal/runner.js
CHANGED
|
@@ -26,5 +26,25 @@ const isWindowsRunner = (env) => Effect.map(env.getOptional("RUNNER_OS"), (found
|
|
|
26
26
|
* @internal
|
|
27
27
|
*/
|
|
28
28
|
const toolCacheRoot = (env, path) => Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
|
|
29
|
+
/** `RUNNER_ARCH`'s spellings (`X86`, `X64`, `ARM`, `ARM64`), lower-cased, in Node's `process.arch` spelling. */
|
|
30
|
+
const NODE_ARCH = /* @__PURE__ */ new Map([
|
|
31
|
+
["x86", "ia32"],
|
|
32
|
+
["x64", "x64"],
|
|
33
|
+
["arm", "arm"],
|
|
34
|
+
["arm64", "arm64"]
|
|
35
|
+
]);
|
|
36
|
+
/** A `RUNNER_ARCH` value in Node's spelling; anything unrecognised passes through. */
|
|
37
|
+
const archFromRunner = (runnerArch) => NODE_ARCH.get(runnerArch.toLowerCase()) ?? runnerArch;
|
|
38
|
+
/**
|
|
39
|
+
* The host architecture in Node's spelling: `RUNNER_ARCH` mapped through
|
|
40
|
+
* `archFromRunner`, or `process.arch` off a runner, where the variable is
|
|
41
|
+
* absent and the host's own answer is the honest fallback.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const runnerArch = (env) => Effect.map(env.getOptional("RUNNER_ARCH"), (found) => Option.match(found, {
|
|
46
|
+
onNone: () => process.arch,
|
|
47
|
+
onSome: archFromRunner
|
|
48
|
+
}));
|
|
29
49
|
|
|
30
|
-
export { isWindowsRunner, toolCacheRoot };
|
|
50
|
+
export { isWindowsRunner, runnerArch, toolCacheRoot };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/github-actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The GitHub Actions runtime: env, inputs, outputs, state, logging, cache, artifacts, blob storage, OIDC and the token bridge.",
|
|
6
6
|
"keywords": [
|
|
@@ -39,12 +39,15 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@azure/storage-blob": "^12.33.0",
|
|
42
|
-
"@effected/
|
|
42
|
+
"@effected/crypto": "^0.1.0",
|
|
43
|
+
"@effected/github": "^0.23.0",
|
|
43
44
|
"@effected/github-commands": "^0.6.0",
|
|
44
45
|
"@effected/glob": "^0.14.0",
|
|
46
|
+
"@effected/jwt": "^0.5.0",
|
|
45
47
|
"@effected/markdown": "^0.20.0",
|
|
46
|
-
"@effected/npm": "^0.
|
|
47
|
-
"@effected/
|
|
48
|
+
"@effected/npm": "^0.25.0",
|
|
49
|
+
"@effected/oidc": "^0.1.0",
|
|
50
|
+
"@effected/sbom": "^0.15.0",
|
|
48
51
|
"@effected/semver": "^0.15.0",
|
|
49
52
|
"@effected/templates": "^0.14.0",
|
|
50
53
|
"@effected/walker": "^0.19.0"
|
package/internal/jwt.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { Result } from "effect";
|
|
2
|
-
import * as Base64Url from "effect/encoding/Base64Url";
|
|
3
|
-
|
|
4
|
-
// src/internal/jwt.ts
|
|
5
|
-
/**
|
|
6
|
-
* The decoded payload of `token` — the middle segment, base64url JSON —
|
|
7
|
-
* without any check on the signature.
|
|
8
|
-
*
|
|
9
|
-
* @internal
|
|
10
|
-
*/
|
|
11
|
-
const payloadOf = (token) => {
|
|
12
|
-
const segments = token.split(".");
|
|
13
|
-
const payload = segments[1];
|
|
14
|
-
if (segments.length !== 3 || payload === void 0 || payload === "") return Result.fail({ detail: `expected three segments, got ${segments.length}` });
|
|
15
|
-
const json = Base64Url.decodeString(payload);
|
|
16
|
-
if (Result.isFailure(json)) return Result.fail({
|
|
17
|
-
detail: "the payload is not base64url JSON",
|
|
18
|
-
cause: json.failure
|
|
19
|
-
});
|
|
20
|
-
try {
|
|
21
|
-
return Result.succeed(JSON.parse(json.success));
|
|
22
|
-
} catch (cause) {
|
|
23
|
-
return Result.fail({
|
|
24
|
-
detail: "the payload is not base64url JSON",
|
|
25
|
-
cause
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
|
-
* An **unsigned** JWT: `header.payload.unsigned`, each JSON segment
|
|
31
|
-
* base64url-encoded. For building test doubles and nothing else — the
|
|
32
|
-
* signature segment is a placeholder, so this token fails any verifier.
|
|
33
|
-
*
|
|
34
|
-
* @internal
|
|
35
|
-
*/
|
|
36
|
-
const unsignedJwt = (header, payload) => {
|
|
37
|
-
const segment = (value) => Base64Url.encode(JSON.stringify(value));
|
|
38
|
-
return `${segment(header)}.${segment(payload)}.unsigned`;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export { payloadOf, unsignedJwt };
|
package/internal/sigv4.js
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { sha256Hex } from "./digest.js";
|
|
2
|
-
import { createHmac } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
// src/internal/sigv4.ts
|
|
5
|
-
/**
|
|
6
|
-
* AWS Signature Version 4, for S3-compatible object stores.
|
|
7
|
-
*
|
|
8
|
-
* @remarks
|
|
9
|
-
* **Pure**, and about a hundred lines, which is the whole argument for not
|
|
10
|
-
* taking an `@aws-sdk/*` dependency to obtain it. Signing is a specified
|
|
11
|
-
* algorithm over strings and HMACs; the SDK around it exists to manage
|
|
12
|
-
* credentials, retries and a service catalogue this package does not want.
|
|
13
|
-
*
|
|
14
|
-
* `node:crypto` rather than core `Crypto` because core's `Crypto` offers only
|
|
15
|
-
* one-shot SHA digests over bytes already in memory — no HMAC, no incremental
|
|
16
|
-
* hasher (see `internal/digest.ts`, this package's other `node:crypto`
|
|
17
|
-
* licence). This package is the one place in the kit where a `node:` import
|
|
18
|
-
* is sanctioned, and this is one of the four reasons.
|
|
19
|
-
*
|
|
20
|
-
* @internal
|
|
21
|
-
*/
|
|
22
|
-
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
23
|
-
const hmac = (key, value) => new Uint8Array(createHmac("sha256", key).update(value).digest());
|
|
24
|
-
/**
|
|
25
|
-
* Percent-encode one path segment the way SigV4 requires.
|
|
26
|
-
*
|
|
27
|
-
* @remarks
|
|
28
|
-
* `encodeURIComponent` leaves `!'()*` alone and AWS does not, so a key
|
|
29
|
-
* containing any of them signs one string and requests another — which fails as
|
|
30
|
-
* an opaque `SignatureDoesNotMatch` rather than as anything that names the
|
|
31
|
-
* character. The unreserved set is exactly `A-Za-z0-9-_.~`.
|
|
32
|
-
*/
|
|
33
|
-
const uriEncode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
34
|
-
/** `20130524T000000Z` and `20130524`, the two forms the algorithm uses. */
|
|
35
|
-
const timestamps = (now) => {
|
|
36
|
-
const amzDate = `${now.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15)}Z`;
|
|
37
|
-
return {
|
|
38
|
-
amzDate,
|
|
39
|
-
dateStamp: amzDate.slice(0, 8)
|
|
40
|
-
};
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* Assemble the headers that will be signed, and the canonical request over them.
|
|
44
|
-
*
|
|
45
|
-
* @remarks
|
|
46
|
-
* Exported separately from {@link sign} so a test can pin it against AWS's
|
|
47
|
-
* **own documented** canonical request rather than against this
|
|
48
|
-
* implementation's output.
|
|
49
|
-
*/
|
|
50
|
-
const canonicalize = (request, credentials) => {
|
|
51
|
-
const { amzDate, dateStamp } = timestamps(request.now);
|
|
52
|
-
const payloadHash = sha256Hex(request.body);
|
|
53
|
-
const headers = {
|
|
54
|
-
...request.headers,
|
|
55
|
-
host: request.host,
|
|
56
|
-
"x-amz-date": amzDate,
|
|
57
|
-
"x-amz-content-sha256": payloadHash,
|
|
58
|
-
...credentials.sessionToken === void 0 ? {} : { "x-amz-security-token": credentials.sessionToken }
|
|
59
|
-
};
|
|
60
|
-
const lowered = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
|
|
61
|
-
const canonicalNames = [...lowered.keys()].sort();
|
|
62
|
-
const canonicalHeaders = canonicalNames.map((name) => `${name}:${(lowered.get(name) ?? "").trim().replace(/\s+/g, " ")}\n`).join("");
|
|
63
|
-
const signedHeaders = canonicalNames.join(";");
|
|
64
|
-
const canonicalPath = `/${request.path.split("/").filter((segment) => segment !== "").map(uriEncode).join("/")}`;
|
|
65
|
-
return {
|
|
66
|
-
headers,
|
|
67
|
-
signedHeaders,
|
|
68
|
-
amzDate,
|
|
69
|
-
dateStamp,
|
|
70
|
-
canonicalRequest: [
|
|
71
|
-
request.method,
|
|
72
|
-
canonicalPath,
|
|
73
|
-
"",
|
|
74
|
-
canonicalHeaders,
|
|
75
|
-
signedHeaders,
|
|
76
|
-
payloadHash
|
|
77
|
-
].join("\n")
|
|
78
|
-
};
|
|
79
|
-
};
|
|
80
|
-
/**
|
|
81
|
-
* Derive a signing key.
|
|
82
|
-
*
|
|
83
|
-
* @remarks
|
|
84
|
-
* Four nested HMACs over date, region, service and the literal `aws4_request`.
|
|
85
|
-
* Exported so a test can reproduce AWS's documented derivation example.
|
|
86
|
-
*/
|
|
87
|
-
const signingKey = (secretAccessKey, dateStamp, region, service) => hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), "aws4_request");
|
|
88
|
-
/**
|
|
89
|
-
* Sign a request, returning the headers to send.
|
|
90
|
-
*
|
|
91
|
-
* @remarks
|
|
92
|
-
* The returned record is the caller's headers **plus** everything signing added
|
|
93
|
-
* — `host`, `x-amz-date`, `x-amz-content-sha256`, the optional
|
|
94
|
-
* `x-amz-security-token` and `authorization` — so a caller sends exactly what
|
|
95
|
-
* was signed rather than assembling a second, subtly different set.
|
|
96
|
-
*/
|
|
97
|
-
const sign = (request, credentials) => {
|
|
98
|
-
const { headers, canonicalRequest, signedHeaders, amzDate, dateStamp } = canonicalize(request, credentials);
|
|
99
|
-
const scope = `${dateStamp}/${credentials.region}/${credentials.service}/aws4_request`;
|
|
100
|
-
const stringToSign = [
|
|
101
|
-
ALGORITHM,
|
|
102
|
-
amzDate,
|
|
103
|
-
scope,
|
|
104
|
-
sha256Hex(canonicalRequest)
|
|
105
|
-
].join("\n");
|
|
106
|
-
const key = signingKey(credentials.secretAccessKey, dateStamp, credentials.region, credentials.service);
|
|
107
|
-
const signature = Buffer.from(hmac(key, stringToSign)).toString("hex");
|
|
108
|
-
return {
|
|
109
|
-
...headers,
|
|
110
|
-
authorization: `${ALGORITHM} Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
111
|
-
};
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
export { canonicalize, sha256Hex as digestHex, sign, signingKey, uriEncode };
|