@mannyc1/ts-release 0.2.2 → 0.3.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/ARCHITECTURE.md +60 -0
- package/CHANGELOG.md +82 -0
- package/README.md +129 -6
- package/SPEC.md +51 -0
- package/apps/release-ts/README.md +7 -0
- package/apps/ts-release-agents/package.json +1 -1
- package/apps/ts-release-agents/src/check.ts +7 -2
- package/apps/ts-release-agents/test/contract.test.ts +2 -2
- package/dist/bin/ts-release.js +154 -47
- package/dist/drivers/archive.d.ts +2 -1
- package/dist/drivers/archive.d.ts.map +1 -1
- package/dist/drivers/archive.js +12 -8
- package/dist/drivers/archive.js.map +1 -1
- package/dist/drivers/process.d.ts.map +1 -1
- package/dist/drivers/process.js +4 -4
- package/dist/drivers/process.js.map +1 -1
- package/dist/drivers/python-wheel.d.ts +21 -0
- package/dist/drivers/python-wheel.d.ts.map +1 -0
- package/dist/drivers/python-wheel.js +109 -0
- package/dist/drivers/python-wheel.js.map +1 -0
- package/dist/drivers/workspace.d.ts +3 -0
- package/dist/drivers/workspace.d.ts.map +1 -1
- package/dist/drivers/workspace.js +5 -1
- package/dist/drivers/workspace.js.map +1 -1
- package/dist/operation-journal/authority.d.ts +3 -0
- package/dist/operation-journal/authority.d.ts.map +1 -0
- package/dist/operation-journal/authority.js +147 -0
- package/dist/operation-journal/authority.js.map +1 -0
- package/dist/operation-journal/aws/deadline.d.ts +12 -0
- package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
- package/dist/operation-journal/aws/deadline.js +38 -0
- package/dist/operation-journal/aws/deadline.js.map +1 -0
- package/dist/operation-journal/aws/oidc.d.ts +27 -0
- package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
- package/dist/operation-journal/aws/oidc.js +361 -0
- package/dist/operation-journal/aws/oidc.js.map +1 -0
- package/dist/operation-journal/aws/policy.d.ts +42 -0
- package/dist/operation-journal/aws/policy.d.ts.map +1 -0
- package/dist/operation-journal/aws/policy.js +404 -0
- package/dist/operation-journal/aws/policy.js.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.js +417 -0
- package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
- package/dist/operation-journal/aws.d.ts +11 -0
- package/dist/operation-journal/aws.d.ts.map +1 -0
- package/dist/operation-journal/aws.js +50 -0
- package/dist/operation-journal/aws.js.map +1 -0
- package/dist/operation-journal/canonical.d.ts +52 -0
- package/dist/operation-journal/canonical.d.ts.map +1 -0
- package/dist/operation-journal/canonical.js +282 -0
- package/dist/operation-journal/canonical.js.map +1 -0
- package/dist/operation-journal/model.d.ts +251 -0
- package/dist/operation-journal/model.d.ts.map +1 -0
- package/dist/operation-journal/model.js +56 -0
- package/dist/operation-journal/model.js.map +1 -0
- package/dist/operation-journal/reducer.d.ts +28 -0
- package/dist/operation-journal/reducer.d.ts.map +1 -0
- package/dist/operation-journal/reducer.js +73 -0
- package/dist/operation-journal/reducer.js.map +1 -0
- package/dist/operation-journal/s3.d.ts +7 -0
- package/dist/operation-journal/s3.d.ts.map +1 -0
- package/dist/operation-journal/s3.js +667 -0
- package/dist/operation-journal/s3.js.map +1 -0
- package/dist/operation-journal.d.ts +14 -0
- package/dist/operation-journal.d.ts.map +1 -0
- package/dist/operation-journal.js +12 -0
- package/dist/operation-journal.js.map +1 -0
- package/dist/platform/credentials.d.ts.map +1 -1
- package/dist/platform/credentials.js +65 -3
- package/dist/platform/credentials.js.map +1 -1
- package/dist/publication/github.d.ts.map +1 -1
- package/dist/publication/github.js +66 -15
- package/dist/publication/github.js.map +1 -1
- package/dist/release/capabilities.d.ts.map +1 -1
- package/dist/release/capabilities.js +2 -2
- package/dist/release/capabilities.js.map +1 -1
- package/dist/release/prepare.d.ts.map +1 -1
- package/dist/release/prepare.js +4 -5
- package/dist/release/prepare.js.map +1 -1
- package/dist/release/prepared-store.d.ts.map +1 -1
- package/dist/release/prepared-store.js +11 -9
- package/dist/release/prepared-store.js.map +1 -1
- package/package.json +19 -4
- package/src/drivers/archive.ts +17 -8
- package/src/drivers/process.ts +3 -4
- package/src/drivers/python-wheel.ts +145 -0
- package/src/drivers/workspace.ts +7 -1
- package/src/operation-journal/authority.ts +162 -0
- package/src/operation-journal/aws/deadline.ts +41 -0
- package/src/operation-journal/aws/oidc.ts +439 -0
- package/src/operation-journal/aws/policy.ts +491 -0
- package/src/operation-journal/aws/s3-boundary.ts +562 -0
- package/src/operation-journal/aws.ts +60 -0
- package/src/operation-journal/canonical.ts +373 -0
- package/src/operation-journal/model.ts +301 -0
- package/src/operation-journal/reducer.ts +112 -0
- package/src/operation-journal/s3.ts +884 -0
- package/src/operation-journal.ts +51 -0
- package/src/platform/credentials.ts +71 -3
- package/src/publication/github.ts +83 -15
- package/src/release/capabilities.ts +3 -2
- package/src/release/prepare.ts +3 -5
- package/src/release/prepared-store.ts +8 -7
- package/templates/README.md +2 -2
- package/templates/github-actions/release.yml +24 -7
- package/templates/github-actions/reviewed-release.yml +49 -14
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const operationJournalNetworkDeadlineMilliseconds = 10_000
|
|
2
|
+
|
|
3
|
+
export class OperationJournalNetworkDeadlineExceeded extends Error {
|
|
4
|
+
readonly name = "OperationJournalNetworkDeadlineExceeded"
|
|
5
|
+
|
|
6
|
+
constructor() {
|
|
7
|
+
super("The operational-journal network deadline elapsed.")
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bound one complete network operation even when the underlying transport
|
|
13
|
+
* ignores AbortSignal. The signal still gives cooperative transports an
|
|
14
|
+
* immediate opportunity to close DNS, sockets, and response streams.
|
|
15
|
+
*/
|
|
16
|
+
export const withOperationJournalNetworkDeadline = async <Value>(
|
|
17
|
+
run: (signal: AbortSignal) => Promise<Value>,
|
|
18
|
+
milliseconds = operationJournalNetworkDeadlineMilliseconds
|
|
19
|
+
): Promise<Value> => {
|
|
20
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 ||
|
|
21
|
+
milliseconds > operationJournalNetworkDeadlineMilliseconds) {
|
|
22
|
+
throw new Error("The operational-journal network deadline is not canonical.")
|
|
23
|
+
}
|
|
24
|
+
const controller = new AbortController()
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
const cause = new OperationJournalNetworkDeadlineExceeded()
|
|
29
|
+
controller.abort(cause)
|
|
30
|
+
reject(cause)
|
|
31
|
+
}, milliseconds)
|
|
32
|
+
})
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([
|
|
35
|
+
Promise.resolve().then(() => run(controller.signal)),
|
|
36
|
+
deadline
|
|
37
|
+
])
|
|
38
|
+
} finally {
|
|
39
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
AssumeRoleWithWebIdentityCommand,
|
|
6
|
+
GetCallerIdentityCommand,
|
|
7
|
+
STSClient,
|
|
8
|
+
type STSClientConfig
|
|
9
|
+
} from "@aws-sdk/client-sts"
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import { parseStrictJson, type Json } from "../../model/canonical.js"
|
|
12
|
+
import { validateS3JournalAuthority } from "../authority.js"
|
|
13
|
+
import {
|
|
14
|
+
S3JournalBoundaryError,
|
|
15
|
+
type S3JournalAuthority,
|
|
16
|
+
type S3JournalOidcClaims
|
|
17
|
+
} from "../model.js"
|
|
18
|
+
import {
|
|
19
|
+
operationJournalNetworkDeadlineMilliseconds,
|
|
20
|
+
withOperationJournalNetworkDeadline
|
|
21
|
+
} from "./deadline.js"
|
|
22
|
+
|
|
23
|
+
export interface AwsJournalCredentials {
|
|
24
|
+
readonly accessKeyId: string
|
|
25
|
+
readonly secretAccessKey: string
|
|
26
|
+
readonly sessionToken: string
|
|
27
|
+
readonly expiration: Date
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AwsJournalSession {
|
|
31
|
+
readonly credentials: AwsJournalCredentials
|
|
32
|
+
readonly oidc: S3JournalOidcClaims
|
|
33
|
+
readonly callerArn: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AwsJournalOidcRuntime {
|
|
37
|
+
readonly environment: Record<string, string | undefined>
|
|
38
|
+
readonly fileExists: (path: string) => boolean
|
|
39
|
+
readonly fetch: typeof globalThis.fetch
|
|
40
|
+
readonly now: () => Date
|
|
41
|
+
readonly makeAnonymousSts: (config: STSClientConfig) => STSClient
|
|
42
|
+
readonly makeSessionSts: (config: STSClientConfig) => STSClient
|
|
43
|
+
/** Internal qualification seam; production always uses the fixed maximum. */
|
|
44
|
+
readonly networkDeadlineMilliseconds?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const maximumOidcResponseBytes = 16_384
|
|
48
|
+
const maximumOidcTokenCharacters = 12_000
|
|
49
|
+
const maximumOidcHeaderCharacters = 2_048
|
|
50
|
+
const maximumOidcPayloadCharacters = 8_192
|
|
51
|
+
const maximumOidcSignatureCharacters = 2_048
|
|
52
|
+
|
|
53
|
+
const fail = (reason: string): never => {
|
|
54
|
+
throw S3JournalBoundaryError.make({
|
|
55
|
+
operation: "observe-authority",
|
|
56
|
+
commitment: "not-applicable",
|
|
57
|
+
reason
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const boundaryError = (cause: unknown, reason: string): S3JournalBoundaryError =>
|
|
62
|
+
cause instanceof S3JournalBoundaryError
|
|
63
|
+
? cause
|
|
64
|
+
: S3JournalBoundaryError.make({
|
|
65
|
+
operation: "observe-authority",
|
|
66
|
+
commitment: "not-applicable",
|
|
67
|
+
reason
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const isObject = (value: Json): value is { readonly [key: string]: Json } =>
|
|
71
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
72
|
+
|
|
73
|
+
const requireString = (
|
|
74
|
+
object: { readonly [key: string]: Json },
|
|
75
|
+
field: string
|
|
76
|
+
): string => {
|
|
77
|
+
const value = object[field]
|
|
78
|
+
if (typeof value !== "string" || value.length === 0) fail(`GitHub OIDC claim ${field} is missing or invalid.`)
|
|
79
|
+
return value as string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requireInteger = (
|
|
83
|
+
object: { readonly [key: string]: Json },
|
|
84
|
+
field: string
|
|
85
|
+
): number => {
|
|
86
|
+
const value = object[field]
|
|
87
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
88
|
+
fail(`GitHub OIDC claim ${field} is missing or invalid.`)
|
|
89
|
+
}
|
|
90
|
+
return value as number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const decodeBase64Url = (value: string, maximum: number, label: string): string => {
|
|
94
|
+
if (value.length > maximum || !/^[A-Za-z0-9_-]+$/u.test(value)) {
|
|
95
|
+
fail(`${label} is not bounded canonical base64url.`)
|
|
96
|
+
}
|
|
97
|
+
const bytes = Buffer.from(value, "base64url")
|
|
98
|
+
if (bytes.toString("base64url") !== value) fail(`${label} is not canonical base64url.`)
|
|
99
|
+
try {
|
|
100
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
101
|
+
} catch {
|
|
102
|
+
return fail(`${label} is not UTF-8.`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const decodeOidcToken = (token: string, now: Date): S3JournalOidcClaims => {
|
|
107
|
+
if (token.length < 64 || token.length > maximumOidcTokenCharacters ||
|
|
108
|
+
!/^[A-Za-z0-9_.-]+$/u.test(token)) {
|
|
109
|
+
fail("GitHub OIDC response is not one bounded compact JWT.")
|
|
110
|
+
}
|
|
111
|
+
const segments = token.split(".")
|
|
112
|
+
if (segments.length !== 3 || segments.some((segment) => segment.length === 0)) {
|
|
113
|
+
fail("GitHub OIDC response is not one compact JWT.")
|
|
114
|
+
}
|
|
115
|
+
const signature = segments[2]!
|
|
116
|
+
if (signature.length > maximumOidcSignatureCharacters ||
|
|
117
|
+
Buffer.from(signature, "base64url").toString("base64url") !== signature) {
|
|
118
|
+
fail("GitHub OIDC signature is not bounded canonical base64url.")
|
|
119
|
+
}
|
|
120
|
+
let header: Json
|
|
121
|
+
let payload: Json
|
|
122
|
+
try {
|
|
123
|
+
header = parseStrictJson(decodeBase64Url(
|
|
124
|
+
segments[0]!,
|
|
125
|
+
maximumOidcHeaderCharacters,
|
|
126
|
+
"GitHub OIDC header"
|
|
127
|
+
))
|
|
128
|
+
payload = parseStrictJson(decodeBase64Url(
|
|
129
|
+
segments[1]!,
|
|
130
|
+
maximumOidcPayloadCharacters,
|
|
131
|
+
"GitHub OIDC payload"
|
|
132
|
+
))
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
if (cause instanceof S3JournalBoundaryError) throw cause
|
|
135
|
+
return fail("GitHub OIDC JWT does not contain strict JSON.")
|
|
136
|
+
}
|
|
137
|
+
if (!isObject(header) || header.alg !== "RS256" || header.typ !== "JWT" ||
|
|
138
|
+
typeof header.kid !== "string" || header.kid.length === 0) {
|
|
139
|
+
fail("GitHub OIDC JWT header is not the admitted RS256 identity.")
|
|
140
|
+
}
|
|
141
|
+
if (!isObject(payload)) fail("GitHub OIDC JWT payload must be an object.")
|
|
142
|
+
const claimsPayload = payload as { readonly [key: string]: Json }
|
|
143
|
+
const nowSeconds = Math.floor(now.getTime() / 1000)
|
|
144
|
+
const issuedAt = requireInteger(claimsPayload, "iat")
|
|
145
|
+
const notBefore = requireInteger(claimsPayload, "nbf")
|
|
146
|
+
const expiresAt = requireInteger(claimsPayload, "exp")
|
|
147
|
+
if (issuedAt > nowSeconds + 30 || notBefore > nowSeconds + 30 || expiresAt <= nowSeconds + 30 ||
|
|
148
|
+
expiresAt <= issuedAt || expiresAt - issuedAt > 900) {
|
|
149
|
+
fail("GitHub OIDC JWT validity window is not current and bounded.")
|
|
150
|
+
}
|
|
151
|
+
const claims: S3JournalOidcClaims = {
|
|
152
|
+
issuer: requireString(claimsPayload, "iss") as S3JournalOidcClaims["issuer"],
|
|
153
|
+
audience: requireString(claimsPayload, "aud") as S3JournalOidcClaims["audience"],
|
|
154
|
+
subject: requireString(claimsPayload, "sub"),
|
|
155
|
+
repository: requireString(claimsPayload, "repository"),
|
|
156
|
+
repositoryId: requireString(claimsPayload, "repository_id"),
|
|
157
|
+
repositoryOwnerId: requireString(claimsPayload, "repository_owner_id"),
|
|
158
|
+
repositoryVisibility: requireString(claimsPayload, "repository_visibility") as S3JournalOidcClaims["repositoryVisibility"],
|
|
159
|
+
eventName: requireString(claimsPayload, "event_name") as S3JournalOidcClaims["eventName"],
|
|
160
|
+
ref: requireString(claimsPayload, "ref"),
|
|
161
|
+
refType: requireString(claimsPayload, "ref_type") as S3JournalOidcClaims["refType"],
|
|
162
|
+
sha: requireString(claimsPayload, "sha"),
|
|
163
|
+
environment: requireString(claimsPayload, "environment"),
|
|
164
|
+
runnerEnvironment: requireString(claimsPayload, "runner_environment") as S3JournalOidcClaims["runnerEnvironment"],
|
|
165
|
+
runId: requireString(claimsPayload, "run_id"),
|
|
166
|
+
runAttempt: requireString(claimsPayload, "run_attempt"),
|
|
167
|
+
workflow: requireString(claimsPayload, "workflow"),
|
|
168
|
+
workflowRef: requireString(claimsPayload, "workflow_ref"),
|
|
169
|
+
workflowSha: requireString(claimsPayload, "workflow_sha"),
|
|
170
|
+
jobWorkflowRef: requireString(claimsPayload, "job_workflow_ref"),
|
|
171
|
+
jobWorkflowSha: requireString(claimsPayload, "job_workflow_sha")
|
|
172
|
+
}
|
|
173
|
+
if (claims.issuer !== "https://token.actions.githubusercontent.com" || claims.audience !== "sts.amazonaws.com") {
|
|
174
|
+
fail("GitHub OIDC issuer or audience is not the admitted AWS exchange identity.")
|
|
175
|
+
}
|
|
176
|
+
const immutableJobWorkflow = claims.jobWorkflowRef.match(/@([a-f0-9]{40})$/u)
|
|
177
|
+
if (immutableJobWorkflow?.[1] !== claims.jobWorkflowSha) {
|
|
178
|
+
fail("GitHub OIDC called-workflow ref is not pinned to its exact source SHA.")
|
|
179
|
+
}
|
|
180
|
+
return claims
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sameClaims = (left: S3JournalOidcClaims, right: S3JournalOidcClaims): boolean => {
|
|
184
|
+
const fields = Object.keys(left) as ReadonlyArray<keyof S3JournalOidcClaims>
|
|
185
|
+
return fields.length === Object.keys(right).length && fields.every((field) => left[field] === right[field])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const requireCleanAwsEnvironment = (
|
|
189
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
190
|
+
fileExists: (path: string) => boolean
|
|
191
|
+
): void => {
|
|
192
|
+
const forbiddenHostVariables = new Set([
|
|
193
|
+
"ALL_PROXY",
|
|
194
|
+
"HTTP_PROXY",
|
|
195
|
+
"HTTPS_PROXY",
|
|
196
|
+
"NO_PROXY",
|
|
197
|
+
"NODE_DEBUG",
|
|
198
|
+
"NODE_DEBUG_NATIVE",
|
|
199
|
+
"NODE_EXTRA_CA_CERTS",
|
|
200
|
+
"NODE_OPTIONS",
|
|
201
|
+
"NODE_PATH",
|
|
202
|
+
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
203
|
+
"NODE_USE_ENV_PROXY",
|
|
204
|
+
"OPENSSL_CONF",
|
|
205
|
+
"SSLKEYLOGFILE",
|
|
206
|
+
"SSL_CERT_DIR",
|
|
207
|
+
"SSL_CERT_FILE",
|
|
208
|
+
"all_proxy",
|
|
209
|
+
"http_proxy",
|
|
210
|
+
"https_proxy",
|
|
211
|
+
"no_proxy"
|
|
212
|
+
])
|
|
213
|
+
const ambient = Object.entries(environment)
|
|
214
|
+
.filter(([key, value]) => value !== undefined && value !== "" &&
|
|
215
|
+
(key.startsWith("AWS_") || forbiddenHostVariables.has(key)))
|
|
216
|
+
.map(([key]) => key)
|
|
217
|
+
.sort()
|
|
218
|
+
if (ambient.length > 0) {
|
|
219
|
+
fail(`Ambient AWS configuration is forbidden (${ambient.join(", ")}).`)
|
|
220
|
+
}
|
|
221
|
+
const homeDirectories = [environment.HOME, environment.USERPROFILE]
|
|
222
|
+
.filter((value): value is string => value !== undefined && value.length > 0)
|
|
223
|
+
const sharedFiles = [...new Set(homeDirectories)]
|
|
224
|
+
.flatMap((home) => [join(home, ".aws", "credentials"), join(home, ".aws", "config")])
|
|
225
|
+
if (sharedFiles.some(fileExists)) {
|
|
226
|
+
fail("Ambient AWS shared credential or configuration files are forbidden.")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const consumeGitHubOidcCoordinates = (
|
|
231
|
+
environment: Record<string, string | undefined>
|
|
232
|
+
): { readonly requestUrl: string, readonly requestToken: string } => {
|
|
233
|
+
const requestUrl = environment.ACTIONS_ID_TOKEN_REQUEST_URL
|
|
234
|
+
const requestToken = environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
235
|
+
delete environment.ACTIONS_ID_TOKEN_REQUEST_URL
|
|
236
|
+
delete environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
237
|
+
if (environment.ACTIONS_ID_TOKEN_REQUEST_URL !== undefined ||
|
|
238
|
+
environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN !== undefined) {
|
|
239
|
+
return fail("GitHub OIDC request coordinates could not be consumed from the process environment.")
|
|
240
|
+
}
|
|
241
|
+
if (requestUrl === undefined || requestUrl === "" || requestToken === undefined || requestToken === "") {
|
|
242
|
+
return fail("GitHub OIDC request coordinates are unavailable.")
|
|
243
|
+
}
|
|
244
|
+
if (requestUrl.length > 4_096 || requestToken.length > 8_192 ||
|
|
245
|
+
!/^[!-~]+$/u.test(requestUrl) || !/^[!-~]+$/u.test(requestToken)) {
|
|
246
|
+
return fail("GitHub OIDC request coordinates exceed their canonical bounds.")
|
|
247
|
+
}
|
|
248
|
+
return { requestUrl, requestToken }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const readOidcResponse = async (response: Response): Promise<Json> => {
|
|
252
|
+
const rawLength = response.headers.get("content-length")
|
|
253
|
+
if (rawLength === null || !/^[1-9][0-9]{0,5}$/u.test(rawLength)) {
|
|
254
|
+
return fail("GitHub OIDC token response lacks one bounded Content-Length.")
|
|
255
|
+
}
|
|
256
|
+
const contentLength = Number(rawLength)
|
|
257
|
+
if (contentLength > maximumOidcResponseBytes || response.body === null) {
|
|
258
|
+
return fail("GitHub OIDC token response exceeds its byte bound.")
|
|
259
|
+
}
|
|
260
|
+
const bytes = new Uint8Array(contentLength)
|
|
261
|
+
const reader = response.body.getReader()
|
|
262
|
+
let offset = 0
|
|
263
|
+
try {
|
|
264
|
+
while (true) {
|
|
265
|
+
const chunk = await reader.read()
|
|
266
|
+
if (chunk.done) break
|
|
267
|
+
if (offset + chunk.value.length > bytes.length) {
|
|
268
|
+
return fail("GitHub OIDC token response stream exceeded Content-Length.")
|
|
269
|
+
}
|
|
270
|
+
bytes.set(chunk.value, offset)
|
|
271
|
+
offset += chunk.value.length
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
reader.releaseLock()
|
|
275
|
+
}
|
|
276
|
+
if (offset !== bytes.length) {
|
|
277
|
+
return fail("GitHub OIDC token response stream disagrees with Content-Length.")
|
|
278
|
+
}
|
|
279
|
+
let text: string
|
|
280
|
+
try {
|
|
281
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
282
|
+
return parseStrictJson(text)
|
|
283
|
+
} catch {
|
|
284
|
+
return fail("GitHub OIDC token response is not strict UTF-8 JSON.")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const requestGitHubOidcToken = async (
|
|
289
|
+
requestUrl: string,
|
|
290
|
+
requestToken: string,
|
|
291
|
+
fetchImpl: typeof globalThis.fetch,
|
|
292
|
+
deadlineMilliseconds: number
|
|
293
|
+
): Promise<string> => {
|
|
294
|
+
let url: URL
|
|
295
|
+
try {
|
|
296
|
+
url = new URL(requestUrl)
|
|
297
|
+
} catch {
|
|
298
|
+
return fail("GitHub OIDC request URL is invalid.")
|
|
299
|
+
}
|
|
300
|
+
const rawAuthority = requestUrl.slice(requestUrl.indexOf("//") + 2).split(/[/?#]/u, 1)[0] ?? ""
|
|
301
|
+
if (url.protocol !== "https:" ||
|
|
302
|
+
!/^[a-z0-9-]+\.actions\.githubusercontent\.com$/u.test(url.hostname) ||
|
|
303
|
+
rawAuthority.includes(":") || url.port !== "" ||
|
|
304
|
+
url.username !== "" || url.password !== "" || url.hash !== "") {
|
|
305
|
+
return fail("GitHub OIDC request URL is not one authenticated Actions HTTPS endpoint.")
|
|
306
|
+
}
|
|
307
|
+
url.searchParams.set("audience", "sts.amazonaws.com")
|
|
308
|
+
if (url.href.length > 4_096 || !/^[!-~]+$/u.test(url.href)) {
|
|
309
|
+
return fail("GitHub OIDC request URL exceeds its canonical bounds after audience binding.")
|
|
310
|
+
}
|
|
311
|
+
return withOperationJournalNetworkDeadline(async (signal) => {
|
|
312
|
+
const response = await fetchImpl(url, {
|
|
313
|
+
method: "GET",
|
|
314
|
+
headers: {
|
|
315
|
+
Accept: "application/json",
|
|
316
|
+
"Accept-Encoding": "identity",
|
|
317
|
+
Authorization: `bearer ${requestToken}`
|
|
318
|
+
},
|
|
319
|
+
redirect: "error",
|
|
320
|
+
signal
|
|
321
|
+
})
|
|
322
|
+
if (!response.ok || response.redirected || response.url !== url.href) {
|
|
323
|
+
fail("GitHub OIDC token request did not return the exact requested endpoint.")
|
|
324
|
+
}
|
|
325
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase()
|
|
326
|
+
if (contentType !== "application/json") fail("GitHub OIDC token response is not JSON.")
|
|
327
|
+
const body = await readOidcResponse(response)
|
|
328
|
+
if (!isObject(body) || Object.keys(body).length !== 1 || typeof body.value !== "string") {
|
|
329
|
+
return fail("GitHub OIDC token response fields are not the exact admitted set.")
|
|
330
|
+
}
|
|
331
|
+
return body.value as string
|
|
332
|
+
}, deadlineMilliseconds)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const roleName = (roleArn: string): string => roleArn.slice(roleArn.lastIndexOf("/") + 1)
|
|
336
|
+
|
|
337
|
+
export const defaultAwsJournalOidcRuntime: AwsJournalOidcRuntime = {
|
|
338
|
+
environment: process.env,
|
|
339
|
+
fileExists: existsSync,
|
|
340
|
+
fetch: globalThis.fetch,
|
|
341
|
+
now: () => new Date(),
|
|
342
|
+
makeAnonymousSts: (config) => new STSClient(config),
|
|
343
|
+
makeSessionSts: (config) => new STSClient(config)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export const acquireAwsJournalSession = (
|
|
347
|
+
authority: S3JournalAuthority,
|
|
348
|
+
runtime: AwsJournalOidcRuntime = defaultAwsJournalOidcRuntime
|
|
349
|
+
): Effect.Effect<AwsJournalSession, S3JournalBoundaryError> => Effect.tryPromise({
|
|
350
|
+
try: async () => {
|
|
351
|
+
try {
|
|
352
|
+
validateS3JournalAuthority(authority)
|
|
353
|
+
} catch {
|
|
354
|
+
fail("AWS journal activation contract is malformed.")
|
|
355
|
+
}
|
|
356
|
+
const coordinates = consumeGitHubOidcCoordinates(runtime.environment)
|
|
357
|
+
let runnerRequestToken = coordinates.requestToken
|
|
358
|
+
let webIdentityToken = ""
|
|
359
|
+
try {
|
|
360
|
+
requireCleanAwsEnvironment(runtime.environment, runtime.fileExists)
|
|
361
|
+
const deadlineMilliseconds = runtime.networkDeadlineMilliseconds ??
|
|
362
|
+
operationJournalNetworkDeadlineMilliseconds
|
|
363
|
+
webIdentityToken = await requestGitHubOidcToken(
|
|
364
|
+
coordinates.requestUrl,
|
|
365
|
+
runnerRequestToken,
|
|
366
|
+
runtime.fetch,
|
|
367
|
+
deadlineMilliseconds
|
|
368
|
+
)
|
|
369
|
+
const observedClaims = decodeOidcToken(webIdentityToken, runtime.now())
|
|
370
|
+
if (!sameClaims(observedClaims, authority.oidc)) {
|
|
371
|
+
fail("GitHub OIDC claims drifted from the exact activation contract.")
|
|
372
|
+
}
|
|
373
|
+
const sessionName = `ts-release-journal-${observedClaims.runId}-${observedClaims.runAttempt}`
|
|
374
|
+
if (!/^[A-Za-z0-9+=,.@-]{2,64}$/u.test(sessionName)) {
|
|
375
|
+
fail("Derived AWS journal session name is not canonical.")
|
|
376
|
+
}
|
|
377
|
+
const noAmbientCredentials = async (): Promise<never> => {
|
|
378
|
+
throw new Error("Ambient AWS credential resolution is disabled.")
|
|
379
|
+
}
|
|
380
|
+
const anonymous = runtime.makeAnonymousSts({
|
|
381
|
+
region: authority.region,
|
|
382
|
+
maxAttempts: 1,
|
|
383
|
+
credentials: noAmbientCredentials
|
|
384
|
+
})
|
|
385
|
+
const assumed = await withOperationJournalNetworkDeadline((signal) => anonymous.send(
|
|
386
|
+
new AssumeRoleWithWebIdentityCommand({
|
|
387
|
+
RoleArn: authority.roleArn,
|
|
388
|
+
RoleSessionName: sessionName,
|
|
389
|
+
WebIdentityToken: webIdentityToken,
|
|
390
|
+
DurationSeconds: 900
|
|
391
|
+
}),
|
|
392
|
+
{ abortSignal: signal }
|
|
393
|
+
), deadlineMilliseconds)
|
|
394
|
+
const accessKeyId = assumed.Credentials?.AccessKeyId
|
|
395
|
+
const secretAccessKey = assumed.Credentials?.SecretAccessKey
|
|
396
|
+
const sessionToken = assumed.Credentials?.SessionToken
|
|
397
|
+
const expiration = assumed.Credentials?.Expiration
|
|
398
|
+
if (accessKeyId === undefined || secretAccessKey === undefined ||
|
|
399
|
+
sessionToken === undefined || expiration === undefined ||
|
|
400
|
+
accessKeyId.length === 0 || secretAccessKey.length === 0 || sessionToken.length === 0) {
|
|
401
|
+
fail("AWS STS did not return one complete temporary credential set.")
|
|
402
|
+
}
|
|
403
|
+
const now = runtime.now().getTime()
|
|
404
|
+
const validExpiration = expiration as Date
|
|
405
|
+
if (validExpiration.getTime() <= now + 5 * 60_000 || validExpiration.getTime() > now + 20 * 60_000) {
|
|
406
|
+
fail("AWS STS journal session expiration is not current and bounded.")
|
|
407
|
+
}
|
|
408
|
+
const sessionCredentials: AwsJournalCredentials = {
|
|
409
|
+
accessKeyId: accessKeyId as string,
|
|
410
|
+
secretAccessKey: secretAccessKey as string,
|
|
411
|
+
sessionToken: sessionToken as string,
|
|
412
|
+
expiration: validExpiration
|
|
413
|
+
}
|
|
414
|
+
const authenticated = runtime.makeSessionSts({
|
|
415
|
+
region: authority.region,
|
|
416
|
+
maxAttempts: 1,
|
|
417
|
+
credentials: sessionCredentials
|
|
418
|
+
})
|
|
419
|
+
const caller = await withOperationJournalNetworkDeadline((signal) => authenticated.send(
|
|
420
|
+
new GetCallerIdentityCommand({}),
|
|
421
|
+
{ abortSignal: signal }
|
|
422
|
+
), deadlineMilliseconds)
|
|
423
|
+
const expectedArn = `arn:aws:sts::${authority.accountId}:assumed-role/${roleName(authority.roleArn)}/${sessionName}`
|
|
424
|
+
if (caller.Account !== authority.accountId || caller.Arn !== expectedArn ||
|
|
425
|
+
assumed.AssumedRoleUser?.Arn !== expectedArn || caller.UserId === undefined || caller.UserId.length === 0) {
|
|
426
|
+
fail("AWS STS caller identity does not equal the exact admitted journal role session.")
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
credentials: sessionCredentials,
|
|
430
|
+
oidc: observedClaims,
|
|
431
|
+
callerArn: expectedArn
|
|
432
|
+
}
|
|
433
|
+
} finally {
|
|
434
|
+
runnerRequestToken = ""
|
|
435
|
+
webIdentityToken = ""
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
catch: (cause) => boundaryError(cause, "GitHub OIDC to AWS STS journal authentication failed.")
|
|
439
|
+
})
|