@sentry/junior-github 0.80.1 → 0.82.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/index.js DELETED
@@ -1,1230 +0,0 @@
1
- import { createPrivateKey, createSign } from "node:crypto";
2
- import { defineJuniorPlugin } from "@sentry/junior-plugin-api";
3
- import {
4
- normalizePermissions,
5
- permissionCapabilities,
6
- readGrantPermissions,
7
- } from "./permissions.js";
8
-
9
- const GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
10
- const GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
11
- const GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID";
12
- const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
13
- const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
14
- const MAX_LEASE_MS = 60 * 60 * 1000;
15
- const REFRESH_BUFFER_MS = 5 * 60 * 1000;
16
- const USER_REFRESH_TIMEOUT_MS = 20_000;
17
- const GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
18
- const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
19
- const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]);
20
- const CONTENTS_WRITE_REQUIREMENTS = [
21
- "GitHub App Contents: write on the target repository",
22
- "requesting GitHub user write access to the repository",
23
- ];
24
- const WORKFLOWS_WRITE_REQUIREMENTS = [
25
- "GitHub App Contents: write and Workflows: write on the target repository",
26
- "requesting GitHub user write access to the repository",
27
- ];
28
- const ISSUES_WRITE_REQUIREMENTS = [
29
- "GitHub App Issues: write on the target repository",
30
- "requesting GitHub user issue access to the repository",
31
- ];
32
- const PULL_REQUESTS_WRITE_REQUIREMENTS = [
33
- "GitHub App Pull requests: write on the target repository",
34
- "requesting GitHub user write access to the repository",
35
- ];
36
- const FORK_CREATE_REQUIREMENTS = [
37
- "GitHub App Administration: write and Contents: read",
38
- "app installation access on the source and destination accounts",
39
- "requesting GitHub user permission to fork the repository",
40
- ];
41
-
42
- class GitHubUserRefreshRejectedError extends Error {
43
- constructor(message) {
44
- super(message);
45
- this.name = "GitHubUserRefreshRejectedError";
46
- }
47
- }
48
-
49
- class GitHubRequestError extends Error {
50
- constructor(message, status) {
51
- super(message);
52
- this.name = "GitHubRequestError";
53
- this.status = status;
54
- }
55
- }
56
-
57
- class GitHubPluginSetupError extends Error {
58
- constructor(message) {
59
- super(message);
60
- this.name = "GitHubPluginSetupError";
61
- }
62
- }
63
-
64
- function isRecord(value) {
65
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
66
- }
67
-
68
- function readEnv(name) {
69
- const value = process.env[name];
70
- if (typeof value !== "string") {
71
- return undefined;
72
- }
73
- const trimmed = value.trim();
74
- return trimmed ? trimmed : undefined;
75
- }
76
-
77
- function requireEnv(name) {
78
- const value = readEnv(name);
79
- if (!value) {
80
- throw new GitHubPluginSetupError(`Missing ${name}`);
81
- }
82
- return value;
83
- }
84
-
85
- function normalizeScopeList(scopes) {
86
- return [
87
- ...new Set(
88
- (scopes ?? [])
89
- .flatMap((scope) => String(scope).split(/\s+/))
90
- .map((scope) => scope.trim())
91
- .filter(Boolean),
92
- ),
93
- ].sort();
94
- }
95
-
96
- function normalizeOAuthScope(scope) {
97
- const normalized = normalizeScopeList(scope ? [scope] : []);
98
- return normalized.length ? normalized.join(" ") : undefined;
99
- }
100
-
101
- function hasRequiredOAuthScope(storedScope, requiredScope) {
102
- const required = normalizeScopeList(requiredScope ? [requiredScope] : []);
103
- if (required.length === 0) {
104
- return true;
105
- }
106
- const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : []));
107
- if (stored.size === 0) {
108
- return false;
109
- }
110
- return required.every((scope) => stored.has(scope));
111
- }
112
-
113
- function cleanIdentityPart(value) {
114
- return String(value ?? "")
115
- .replaceAll("\n", " ")
116
- .replaceAll("\r", " ")
117
- .replace(/[<>]/g, "")
118
- .trim();
119
- }
120
-
121
- function isSlackUserId(value) {
122
- return /^[UW][A-Z0-9]{5,}$/.test(value);
123
- }
124
-
125
- function requesterDisplayName(value, requester) {
126
- const name = cleanIdentityPart(value);
127
- if (
128
- !name ||
129
- name.toLowerCase() === "unknown" ||
130
- name === cleanIdentityPart(requester?.userId)
131
- ) {
132
- return undefined;
133
- }
134
- return isSlackUserId(name) ? undefined : name;
135
- }
136
-
137
- function requesterName(requester) {
138
- return (
139
- requesterDisplayName(requester?.fullName, requester) ||
140
- requesterDisplayName(requester?.userName, requester) ||
141
- undefined
142
- );
143
- }
144
-
145
- function requesterEmail(requester) {
146
- const email = cleanIdentityPart(requester?.email);
147
- return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) ? email : undefined;
148
- }
149
-
150
- function isGitCommitCommand(command) {
151
- return /(?:^|[\s;|&])git(?:\s+(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--namespace(?:=\S+|\s+\S+)))*\s+commit(?:\s|$)/.test(
152
- command,
153
- );
154
- }
155
-
156
- function prepareCommitMsgHook() {
157
- return `#!/usr/bin/env bash
158
- set -eu
159
-
160
- message_file="\${1:-}"
161
- if [ -z "$message_file" ]; then
162
- exit 1
163
- fi
164
-
165
- if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then
166
- echo "Junior GitHub plugin internal error: requester commit attribution was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2
167
- exit 1
168
- fi
169
-
170
- if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then
171
- echo "Junior GitHub plugin internal error: Git author was not set to the resolved requester identity. Do not override Git author manually; report this configuration error." >&2
172
- exit 1
173
- fi
174
-
175
- if [ -z "\${JUNIOR_GIT_COAUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_COAUTHOR_EMAIL:-}" ]; then
176
- echo "Junior GitHub plugin internal error: Junior coauthor identity was not injected by the host runtime. Do not set coauthor env vars manually; report this configuration error." >&2
177
- exit 1
178
- fi
179
-
180
- trailer="Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
181
- if grep -Fqx "$trailer" "$message_file"; then
182
- exit 0
183
- fi
184
-
185
- printf '\\n%s\\n' "$trailer" >> "$message_file"
186
- `;
187
- }
188
-
189
- async function configureGit(ctx, key, value) {
190
- const result = await ctx.sandbox.run({
191
- cmd: "git",
192
- args: ["config", "--global", key, value],
193
- });
194
- if (result.exitCode !== 0) {
195
- throw new Error(
196
- `Failed to configure git ${key}: ${result.stderr || result.stdout}`,
197
- );
198
- }
199
- }
200
-
201
- function base64Url(input) {
202
- return Buffer.from(input)
203
- .toString("base64")
204
- .replace(/=/g, "")
205
- .replace(/\+/g, "-")
206
- .replace(/\//g, "_");
207
- }
208
-
209
- function getPrivateKey(envName) {
210
- const raw = requireEnv(envName);
211
- let key;
212
- try {
213
- key = createPrivateKey({ key: raw, format: "pem" });
214
- } catch {
215
- throw new GitHubPluginSetupError(
216
- `Invalid ${envName}: expected a PEM-encoded RSA private key`,
217
- );
218
- }
219
-
220
- if (key.asymmetricKeyType !== "rsa") {
221
- throw new GitHubPluginSetupError(
222
- `Invalid ${envName}: GitHub App signing requires an RSA private key`,
223
- );
224
- }
225
- return key;
226
- }
227
-
228
- function createAppJwt(appId, privateKeyEnv) {
229
- const now = Math.floor(Date.now() / 1000);
230
- const header = { alg: "RS256", typ: "JWT" };
231
- const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
232
- const encodedHeader = base64Url(JSON.stringify(header));
233
- const encodedPayload = base64Url(JSON.stringify(payload));
234
- const signingInput = `${encodedHeader}.${encodedPayload}`;
235
- const signer = createSign("RSA-SHA256");
236
- signer.update(signingInput);
237
- signer.end();
238
- const signature = signer
239
- .sign(getPrivateKey(privateKeyEnv))
240
- .toString("base64")
241
- .replace(/=/g, "")
242
- .replace(/\+/g, "-")
243
- .replace(/\//g, "_");
244
- return `${signingInput}.${signature}`;
245
- }
246
-
247
- async function githubRequest(apiBase, path, params) {
248
- const response = await fetch(`${apiBase}${path}`, {
249
- method: params.method ?? "GET",
250
- headers: {
251
- Accept: "application/vnd.github+json",
252
- Authorization: `Bearer ${params.token}`,
253
- "X-GitHub-Api-Version": "2022-11-28",
254
- ...(params.body ? { "Content-Type": "application/json" } : {}),
255
- },
256
- ...(params.body ? { body: JSON.stringify(params.body) } : {}),
257
- });
258
-
259
- const text = await response.text();
260
- let parsed;
261
- if (text) {
262
- try {
263
- parsed = JSON.parse(text);
264
- } catch {
265
- parsed = undefined;
266
- }
267
- }
268
-
269
- if (!response.ok) {
270
- const message =
271
- parsed && typeof parsed === "object" && typeof parsed.message === "string"
272
- ? parsed.message
273
- : `GitHub API error ${response.status}`;
274
- throw new GitHubRequestError(message, response.status);
275
- }
276
- return parsed;
277
- }
278
-
279
- function buildOAuthTokenRequest(input) {
280
- const payload = {
281
- ...input.payload,
282
- client_id: input.clientId,
283
- client_secret: input.clientSecret,
284
- };
285
- return {
286
- headers: {
287
- Accept: "application/json",
288
- "Content-Type": "application/x-www-form-urlencoded",
289
- },
290
- body: new URLSearchParams(payload),
291
- };
292
- }
293
-
294
- function parseOAuthResponseJson(responseText) {
295
- if (!responseText.trim()) {
296
- return undefined;
297
- }
298
- try {
299
- return JSON.parse(responseText);
300
- } catch {
301
- return undefined;
302
- }
303
- }
304
-
305
- function oauthErrorCode(data) {
306
- return isRecord(data) && typeof data.error === "string"
307
- ? data.error
308
- : undefined;
309
- }
310
-
311
- function isRejectedRefreshError(errorCode) {
312
- return errorCode === "bad_refresh_token" || errorCode === "invalid_grant";
313
- }
314
-
315
- function parseOAuthTokenResponse(data, requestedScope) {
316
- if (!isRecord(data)) {
317
- throw new Error("OAuth token response is invalid");
318
- }
319
- if (typeof data.access_token !== "string" || !data.access_token.trim()) {
320
- throw new Error("OAuth token response missing access_token");
321
- }
322
- if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) {
323
- throw new Error("OAuth token response missing refresh_token");
324
- }
325
- let scope = normalizeOAuthScope(requestedScope);
326
- if (data.scope !== undefined) {
327
- if (typeof data.scope !== "string") {
328
- throw new Error("OAuth token response returned invalid scope");
329
- }
330
- scope = normalizeOAuthScope(data.scope) ?? scope;
331
- }
332
- const result = {
333
- accessToken: data.access_token,
334
- refreshToken: data.refresh_token,
335
- ...(scope ? { scope } : {}),
336
- };
337
- if (data.expires_in !== undefined) {
338
- if (
339
- typeof data.expires_in !== "number" ||
340
- !Number.isFinite(data.expires_in) ||
341
- data.expires_in <= 0
342
- ) {
343
- throw new Error("OAuth token response returned invalid expires_in");
344
- }
345
- result.expiresAt = Date.now() + data.expires_in * 1000;
346
- }
347
- if (data.refresh_token_expires_in !== undefined) {
348
- if (
349
- typeof data.refresh_token_expires_in !== "number" ||
350
- !Number.isFinite(data.refresh_token_expires_in) ||
351
- data.refresh_token_expires_in <= 0
352
- ) {
353
- throw new Error(
354
- "OAuth token response returned invalid refresh_token_expires_in",
355
- );
356
- }
357
- result.refreshTokenExpiresAt =
358
- Date.now() + data.refresh_token_expires_in * 1000;
359
- }
360
- return result;
361
- }
362
-
363
- async function refreshUserAccessToken(input) {
364
- const clientId = requireEnv(input.clientIdEnv);
365
- const clientSecret = requireEnv(input.clientSecretEnv);
366
- const request = buildOAuthTokenRequest({
367
- clientId,
368
- clientSecret,
369
- payload: {
370
- grant_type: "refresh_token",
371
- refresh_token: input.refreshToken,
372
- },
373
- });
374
- const response = await fetch("https://github.com/login/oauth/access_token", {
375
- method: "POST",
376
- headers: request.headers,
377
- body: request.body,
378
- signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS),
379
- });
380
- const responseText = await response.text();
381
- const responseData = parseOAuthResponseJson(responseText);
382
- const errorCode = oauthErrorCode(responseData);
383
- if (isRejectedRefreshError(errorCode)) {
384
- throw new GitHubUserRefreshRejectedError(
385
- `GitHub user token refresh rejected: ${errorCode}`,
386
- );
387
- }
388
- if (!response.ok || errorCode) {
389
- throw new Error(
390
- `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`,
391
- );
392
- }
393
- try {
394
- return parseOAuthTokenResponse(responseData, input.requestedScope);
395
- } catch (error) {
396
- if (
397
- error instanceof Error &&
398
- error.message === "OAuth token response missing access_token"
399
- ) {
400
- throw new GitHubUserRefreshRejectedError(error.message);
401
- }
402
- throw error;
403
- }
404
- }
405
-
406
- function leaseExpiry(expiresAt) {
407
- return expiresAt
408
- ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS)
409
- : Date.now() + MAX_LEASE_MS;
410
- }
411
-
412
- function isGitSmartHttpDomain(domain) {
413
- return domain.toLowerCase() === "github.com";
414
- }
415
-
416
- function authorizationFor(domain, token) {
417
- if (isGitSmartHttpDomain(domain)) {
418
- return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
419
- }
420
- return `Bearer ${token}`;
421
- }
422
-
423
- function createCredentialLease(input) {
424
- return {
425
- type: "lease",
426
- lease: {
427
- ...(input.account ? { account: input.account } : {}),
428
- ...(input.authorization ? { authorization: input.authorization } : {}),
429
- expiresAt: new Date(input.expiresAtMs).toISOString(),
430
- headerTransforms: ["api.github.com", "github.com"].map((domain) => ({
431
- domain,
432
- headers: {
433
- Authorization: authorizationFor(domain, input.token),
434
- },
435
- })),
436
- },
437
- };
438
- }
439
-
440
- function githubUserAuthorization(scope) {
441
- return {
442
- type: "oauth",
443
- provider: "github",
444
- ...(scope ? { scope } : {}),
445
- };
446
- }
447
-
448
- function credentialNeeded(message, scope, allowAuthorization = true) {
449
- return {
450
- type: "needed",
451
- message,
452
- ...(allowAuthorization
453
- ? { authorization: githubUserAuthorization(scope) }
454
- : {}),
455
- };
456
- }
457
-
458
- function credentialUnavailable(message) {
459
- return {
460
- type: "unavailable",
461
- message,
462
- };
463
- }
464
-
465
- function parseInstallationTokenResponse(data) {
466
- if (!isRecord(data)) {
467
- throw new Error("GitHub installation token response is invalid");
468
- }
469
- const token = data.token;
470
- if (typeof token !== "string" || !token.trim()) {
471
- throw new Error("GitHub installation token response missing token");
472
- }
473
- const expiresAt = data.expires_at;
474
- const expiresAtMs =
475
- typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
476
- if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
477
- throw new Error(
478
- "GitHub installation token response returned invalid expires_at",
479
- );
480
- }
481
- return { token, expiresAtMs };
482
- }
483
-
484
- function readInstallationPermissions(installation) {
485
- if (!isRecord(installation) || !isRecord(installation.permissions)) {
486
- throw new Error("GitHub installation response missing permissions");
487
- }
488
- return readGrantPermissions(installation.permissions);
489
- }
490
-
491
- async function resolveUserAccount(tokens) {
492
- const account = await githubRequest("https://api.github.com", "/user", {
493
- token: tokens.accessToken,
494
- });
495
- if (!isRecord(account)) {
496
- throw new Error("GitHub user response is invalid");
497
- }
498
- const id = account.id;
499
- const login = account.login;
500
- if (
501
- (typeof id !== "number" && typeof id !== "string") ||
502
- typeof login !== "string" ||
503
- !login.trim()
504
- ) {
505
- throw new Error("GitHub user response missing id or login");
506
- }
507
- const url =
508
- typeof account.html_url === "string" ? account.html_url : undefined;
509
- return {
510
- id: String(id),
511
- label: login.trim(),
512
- ...(url ? { url } : {}),
513
- };
514
- }
515
-
516
- async function tokensWithAccount(tokenSlot, stored, scope) {
517
- if (stored.account) {
518
- return { ok: true, tokens: stored };
519
- }
520
- let account;
521
- try {
522
- account = await resolveUserAccount(stored);
523
- } catch (error) {
524
- if (
525
- error instanceof GitHubRequestError &&
526
- (error.status === 401 || error.status === 403)
527
- ) {
528
- return {
529
- ok: false,
530
- result: credentialNeeded(
531
- "Your GitHub authorization needs to be refreshed.",
532
- scope,
533
- ),
534
- };
535
- }
536
- throw error;
537
- }
538
- const updated = { ...stored, account };
539
- await tokenSlot.set(updated);
540
- return { ok: true, tokens: updated };
541
- }
542
-
543
- function shouldRefreshUserToken(stored, now = Date.now()) {
544
- return (
545
- stored.expiresAt !== undefined && stored.expiresAt - now < REFRESH_BUFFER_MS
546
- );
547
- }
548
-
549
- function canUseStoredUserToken(stored) {
550
- return (
551
- stored.expiresAt === undefined ||
552
- (stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored))
553
- );
554
- }
555
-
556
- /** Re-read under the token-slot refresh gate so concurrent callers reuse the winner's rotated tokens. */
557
- async function refreshUserTokensWithLock(tokenSlot, scope, options) {
558
- return await tokenSlot.withRefresh(async () => {
559
- const latest = await tokenSlot.get();
560
- if (!latest) {
561
- return {
562
- ok: false,
563
- result: credentialNeeded("Connect your GitHub account.", scope),
564
- };
565
- }
566
- if (!hasRequiredOAuthScope(latest.scope, scope)) {
567
- return {
568
- ok: false,
569
- result: credentialNeeded(
570
- "Your GitHub authorization needs to be refreshed.",
571
- scope,
572
- ),
573
- };
574
- }
575
- if (canUseStoredUserToken(latest)) {
576
- return { ok: true, tokens: latest };
577
- }
578
-
579
- let refreshed;
580
- try {
581
- refreshed = await refreshUserAccessToken({
582
- clientIdEnv: options.clientIdEnv,
583
- clientSecretEnv: options.clientSecretEnv,
584
- refreshToken: latest.refreshToken,
585
- requestedScope: latest.scope ?? scope,
586
- });
587
- } catch (error) {
588
- if (!(error instanceof GitHubUserRefreshRejectedError)) {
589
- throw error;
590
- }
591
- return {
592
- ok: false,
593
- result: credentialNeeded(
594
- "Your GitHub authorization has expired.",
595
- scope,
596
- ),
597
- };
598
- }
599
- if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
600
- return {
601
- ok: false,
602
- result: credentialNeeded(
603
- "Your GitHub authorization needs to be refreshed.",
604
- scope,
605
- ),
606
- };
607
- }
608
- const refreshedTokens = {
609
- ...(latest.refreshTokenExpiresAt
610
- ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt }
611
- : {}),
612
- ...refreshed,
613
- ...(latest.account ? { account: latest.account } : {}),
614
- };
615
- await tokenSlot.set(refreshedTokens);
616
- return { ok: true, tokens: refreshedTokens };
617
- });
618
- }
619
-
620
- async function issueUserCredential(ctx, options) {
621
- const scope = options.userScope;
622
- const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject;
623
- if (!tokenSlot) {
624
- return credentialNeeded(
625
- "GitHub write access requires a current user or delegated user credential subject.",
626
- scope,
627
- false,
628
- );
629
- }
630
-
631
- const stored = await tokenSlot.get();
632
- if (!stored) {
633
- return credentialNeeded(
634
- "GitHub write access requires user authorization.",
635
- scope,
636
- );
637
- }
638
- if (!hasRequiredOAuthScope(stored.scope, scope)) {
639
- return credentialNeeded(
640
- "Your GitHub authorization needs to be refreshed.",
641
- scope,
642
- );
643
- }
644
-
645
- const now = Date.now();
646
- if (
647
- stored.expiresAt !== undefined &&
648
- stored.expiresAt - now < REFRESH_BUFFER_MS
649
- ) {
650
- const refreshResult = await refreshUserTokensWithLock(
651
- tokenSlot,
652
- scope,
653
- options,
654
- );
655
- if (!refreshResult.ok) {
656
- return refreshResult.result;
657
- }
658
- const withAccount = await tokensWithAccount(
659
- tokenSlot,
660
- refreshResult.tokens,
661
- scope,
662
- );
663
- if (!withAccount.ok) {
664
- return withAccount.result;
665
- }
666
- return createCredentialLease({
667
- account: withAccount.tokens.account,
668
- token: withAccount.tokens.accessToken,
669
- expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
670
- authorization: githubUserAuthorization(scope),
671
- });
672
- }
673
-
674
- if (stored.expiresAt === undefined || stored.expiresAt > Date.now()) {
675
- const withAccount = await tokensWithAccount(tokenSlot, stored, scope);
676
- if (!withAccount.ok) {
677
- return withAccount.result;
678
- }
679
- return createCredentialLease({
680
- account: withAccount.tokens.account,
681
- token: withAccount.tokens.accessToken,
682
- expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
683
- authorization: githubUserAuthorization(scope),
684
- });
685
- }
686
-
687
- return credentialNeeded("Your GitHub authorization has expired.", scope);
688
- }
689
-
690
- async function issueInstallationCredential(options) {
691
- const appId = requireEnv(options.appIdEnv);
692
- const installationIdRaw = requireEnv(options.installationIdEnv);
693
- const installationId = Number(installationIdRaw);
694
- if (!Number.isSafeInteger(installationId) || installationId <= 0) {
695
- throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
696
- }
697
-
698
- const appJwt = createAppJwt(appId, options.privateKeyEnv);
699
- let tokenPermissions = options.readPermissions;
700
- if (!tokenPermissions) {
701
- tokenPermissions = await options.loadReadPermissions({
702
- appJwt,
703
- installationId,
704
- });
705
- }
706
-
707
- const accessTokenResponse = await githubRequest(
708
- "https://api.github.com",
709
- `/app/installations/${installationId}/access_tokens`,
710
- {
711
- method: "POST",
712
- token: appJwt,
713
- body: { permissions: tokenPermissions },
714
- },
715
- );
716
- const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
717
- const expiresAtMs = Math.min(
718
- parsedToken.expiresAtMs,
719
- Date.now() + MAX_LEASE_MS,
720
- );
721
- return createCredentialLease({
722
- token: parsedToken.token,
723
- expiresAtMs,
724
- });
725
- }
726
-
727
- function createPermissionCache() {
728
- let cached;
729
- let pending;
730
- return async ({ appJwt, installationId }) => {
731
- if (cached && cached.expiresAtMs > Date.now()) {
732
- return cached.permissions;
733
- }
734
- pending ??= githubRequest(
735
- "https://api.github.com",
736
- `/app/installations/${installationId}`,
737
- { token: appJwt },
738
- )
739
- .then((installation) => {
740
- const permissions = readInstallationPermissions(installation);
741
- cached = {
742
- expiresAtMs: Date.now() + MAX_LEASE_MS,
743
- permissions,
744
- };
745
- return permissions;
746
- })
747
- .finally(() => {
748
- pending = undefined;
749
- });
750
- return await pending;
751
- };
752
- }
753
-
754
- function githubSmartHttpAccess(upstreamUrl) {
755
- const pathname = upstreamUrl.pathname.toLowerCase();
756
- const service = upstreamUrl.searchParams.get("service")?.toLowerCase();
757
- const isSmartHttpPath =
758
- pathname.endsWith("/info/refs") ||
759
- pathname.endsWith("/git-receive-pack") ||
760
- pathname.endsWith("/git-upload-pack");
761
- if (!isSmartHttpPath) {
762
- return undefined;
763
- }
764
- if (
765
- pathname.endsWith("/git-receive-pack") ||
766
- service === "git-receive-pack"
767
- ) {
768
- return "write";
769
- }
770
- if (pathname.endsWith("/git-upload-pack") || service === "git-upload-pack") {
771
- return "read";
772
- }
773
- return undefined;
774
- }
775
-
776
- function isGitHubGraphqlUrl(upstreamUrl) {
777
- return (
778
- upstreamUrl.hostname.toLowerCase() === "api.github.com" &&
779
- upstreamUrl.pathname.toLowerCase().endsWith("/graphql")
780
- );
781
- }
782
-
783
- function isGitHubApiUrl(upstreamUrl) {
784
- return upstreamUrl.hostname.toLowerCase() === "api.github.com";
785
- }
786
-
787
- function githubUserReadReason(method, upstreamUrl) {
788
- if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) {
789
- return undefined;
790
- }
791
- return upstreamUrl.pathname.toLowerCase() === "/user"
792
- ? "github.user-read"
793
- : undefined;
794
- }
795
-
796
- function parseGitHubGraphqlOperation(bodyText) {
797
- if (typeof bodyText !== "string" || bodyText.trim().length === 0) {
798
- return undefined;
799
- }
800
- let parsed;
801
- try {
802
- parsed = JSON.parse(bodyText);
803
- } catch {
804
- return undefined;
805
- }
806
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
807
- return undefined;
808
- }
809
- const query = parsed.query;
810
- if (typeof query !== "string") {
811
- return undefined;
812
- }
813
- const operationName =
814
- typeof parsed.operationName === "string"
815
- ? parsed.operationName.trim()
816
- : undefined;
817
- const normalized = maskGraphqlStringLiterals(
818
- query.replace(/^\s*#[^\n\r]*(?:\r?\n|$)/gm, ""),
819
- ).trim();
820
- if (operationName) {
821
- const namedOperation = normalized.match(
822
- new RegExp(
823
- `\\b(query|mutation|subscription)\\s+${escapeRegExp(operationName)}\\b`,
824
- ),
825
- )?.[1];
826
- return namedOperation ? graphqlOperationAccess(namedOperation) : undefined;
827
- }
828
- const operation = normalized.match(/\b(query|mutation|subscription)\b/)?.[1];
829
- const operationAccess = graphqlOperationAccess(operation);
830
- if (operationAccess) {
831
- return operationAccess;
832
- }
833
- if (normalized.startsWith("{")) {
834
- return "read";
835
- }
836
- return undefined;
837
- }
838
-
839
- function escapeRegExp(value) {
840
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
841
- }
842
-
843
- function graphqlOperationAccess(operation) {
844
- if (operation === "mutation" || operation === "subscription") {
845
- return "write";
846
- }
847
- if (operation === "query") {
848
- return "read";
849
- }
850
- return undefined;
851
- }
852
-
853
- function maskGraphqlStringLiterals(query) {
854
- return query.replace(/"""[\s\S]*?"""|"(?:\\.|[^"\\])*"/g, (match) =>
855
- " ".repeat(match.length),
856
- );
857
- }
858
-
859
- function githubGraphqlAccess(method, upstreamUrl, bodyText) {
860
- if (!isGitHubGraphqlUrl(upstreamUrl)) {
861
- return undefined;
862
- }
863
- if (HTTP_READ_METHODS.has(method)) {
864
- return "read";
865
- }
866
- const operation = parseGitHubGraphqlOperation(bodyText);
867
- if (operation) {
868
- return operation;
869
- }
870
- // Unknown GraphQL POST bodies still require user-write attribution rather
871
- // than risking an unattributed mutation through an installation-read token.
872
- return "write";
873
- }
874
-
875
- function githubGraphqlPermissionDeniedMessage(bodyText) {
876
- let parsed;
877
- try {
878
- parsed = JSON.parse(bodyText);
879
- } catch {
880
- return undefined;
881
- }
882
- if (!isRecord(parsed) || !Array.isArray(parsed.errors)) {
883
- return undefined;
884
- }
885
- for (const error of parsed.errors) {
886
- if (!isRecord(error) || typeof error.message !== "string") {
887
- continue;
888
- }
889
- const message = error.message;
890
- if (
891
- error.type === "NOT_FOUND" &&
892
- /\bCould not resolve to a Repository with the name\b/.test(message)
893
- ) {
894
- return `GitHub GraphQL could not access the repository: ${message}`;
895
- }
896
- if (/\bResource not accessible by integration\b/.test(message)) {
897
- return `GitHub GraphQL denied access: ${message}`;
898
- }
899
- }
900
- return undefined;
901
- }
902
-
903
- function shouldInspectGitHubGraphqlResponse(ctx) {
904
- if (
905
- ctx.request.method.toUpperCase() !== "POST" ||
906
- ctx.response.status !== 200
907
- ) {
908
- return false;
909
- }
910
- let upstreamUrl;
911
- try {
912
- upstreamUrl = new URL(ctx.request.url);
913
- } catch {
914
- return false;
915
- }
916
- if (!isGitHubGraphqlUrl(upstreamUrl)) {
917
- return false;
918
- }
919
- const contentType = ctx.response.headers.get("content-type");
920
- return contentType ? /\bjson\b/i.test(contentType) : false;
921
- }
922
-
923
- function githubApiWriteReason(method, upstreamUrl) {
924
- const pathname = upstreamUrl.pathname.toLowerCase();
925
- if (!isGitHubApiUrl(upstreamUrl)) {
926
- return undefined;
927
- }
928
- if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) {
929
- return "github.issue-create";
930
- }
931
- if (
932
- method === "POST" &&
933
- /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname)
934
- ) {
935
- return "github.issues-write";
936
- }
937
- if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) {
938
- return "github.pull-create";
939
- }
940
- if (
941
- method === "PATCH" &&
942
- /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname)
943
- ) {
944
- return "github.pull-requests-write";
945
- }
946
- if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/forks$/.test(pathname)) {
947
- return "github.fork-create";
948
- }
949
- if (
950
- /^\/repos\/[^/]+\/[^/]+\/contents(?:\/|$)/.test(pathname) &&
951
- (method === "PUT" || method === "DELETE")
952
- ) {
953
- return pathname.includes("/.github/workflows/")
954
- ? "github.workflows-write"
955
- : "github.contents-write";
956
- }
957
- if (
958
- method === "POST" &&
959
- /^\/repos\/[^/]+\/[^/]+\/git\/(blobs|trees|commits)$/.test(pathname)
960
- ) {
961
- return "github.contents-write";
962
- }
963
- if (
964
- method === "POST" &&
965
- /^\/repos\/[^/]+\/[^/]+\/git\/refs$/.test(pathname)
966
- ) {
967
- return "github.contents-write";
968
- }
969
- if (
970
- (method === "PATCH" || method === "DELETE") &&
971
- /^\/repos\/[^/]+\/[^/]+\/git\/refs\/.+/.test(pathname)
972
- ) {
973
- return "github.contents-write";
974
- }
975
- if (
976
- method === "PUT" &&
977
- /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge$/.test(pathname)
978
- ) {
979
- return "github.contents-write";
980
- }
981
- return undefined;
982
- }
983
-
984
- function grantRequirements(reason) {
985
- if (reason === "github.git-write" || reason === "github.contents-write") {
986
- return CONTENTS_WRITE_REQUIREMENTS;
987
- }
988
- if (reason === "github.workflows-write") {
989
- return WORKFLOWS_WRITE_REQUIREMENTS;
990
- }
991
- if (reason === "github.issue-create" || reason === "github.issues-write") {
992
- return ISSUES_WRITE_REQUIREMENTS;
993
- }
994
- if (
995
- reason === "github.pull-create" ||
996
- reason === "github.pull-requests-write"
997
- ) {
998
- return PULL_REQUESTS_WRITE_REQUIREMENTS;
999
- }
1000
- if (reason === "github.fork-create") {
1001
- return FORK_CREATE_REQUIREMENTS;
1002
- }
1003
- return undefined;
1004
- }
1005
-
1006
- function grantForAccess(access, reason, name) {
1007
- const requirements = grantRequirements(reason);
1008
- return {
1009
- name,
1010
- access,
1011
- reason,
1012
- ...(requirements ? { requirements } : {}),
1013
- };
1014
- }
1015
-
1016
- async function githubGrantForEgress(ctx) {
1017
- const method = ctx.request.method.toUpperCase();
1018
- const upstreamUrl = new URL(ctx.request.url);
1019
- const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
1020
- if (smartHttpAccess) {
1021
- return grantForAccess(
1022
- smartHttpAccess,
1023
- smartHttpAccess === "write" ? "github.git-write" : "github.git-read",
1024
- smartHttpAccess === "write" ? "user-write" : "installation-read",
1025
- );
1026
- }
1027
-
1028
- const userReadReason = githubUserReadReason(method, upstreamUrl);
1029
- if (userReadReason) {
1030
- return grantForAccess("read", userReadReason, "user-read");
1031
- }
1032
-
1033
- const writeReason = githubApiWriteReason(method, upstreamUrl);
1034
- if (writeReason) {
1035
- return grantForAccess("write", writeReason, "user-write");
1036
- }
1037
-
1038
- const graphqlAccess = githubGraphqlAccess(
1039
- method,
1040
- upstreamUrl,
1041
- ctx.request.bodyText,
1042
- );
1043
- if (graphqlAccess) {
1044
- return grantForAccess(
1045
- graphqlAccess,
1046
- graphqlAccess === "write"
1047
- ? "github.graphql-write"
1048
- : "github.graphql-read",
1049
- graphqlAccess === "write" ? "user-write" : "installation-read",
1050
- );
1051
- }
1052
-
1053
- const access = HTTP_READ_METHODS.has(method) ? "read" : "write";
1054
- return grantForAccess(
1055
- access,
1056
- access === "write" ? "github.api-write" : "github.api-read",
1057
- access === "write" ? "user-write" : "installation-read",
1058
- );
1059
- }
1060
-
1061
- /** Register GitHub runtime hooks for repository workflows. */
1062
- export function githubPlugin(options = {}) {
1063
- const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
1064
- const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL";
1065
- const clientIdEnv = options.clientIdEnv ?? "GITHUB_APP_CLIENT_ID";
1066
- const clientSecretEnv = options.clientSecretEnv ?? "GITHUB_APP_CLIENT_SECRET";
1067
- const appIdEnv = options.appIdEnv ?? GITHUB_APP_ID_ENV;
1068
- const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV;
1069
- const installationIdEnv =
1070
- options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV;
1071
- const appPermissions = normalizePermissions(options.appPermissions);
1072
- const appReadPermissions = appPermissions
1073
- ? readGrantPermissions(appPermissions)
1074
- : undefined;
1075
- const loadReadPermissions = createPermissionCache();
1076
- const appCapabilities = permissionCapabilities(appPermissions);
1077
- const userScopes = normalizeScopeList(options.additionalUserScopes);
1078
- const userScope = userScopes.length ? userScopes.join(" ") : undefined;
1079
-
1080
- return defineJuniorPlugin({
1081
- packageName: "@sentry/junior-github",
1082
- manifest: {
1083
- name: "github",
1084
- displayName: "GitHub",
1085
- description:
1086
- "GitHub issue, pull request, and repository workflows via GitHub App",
1087
- ...(appCapabilities ? { capabilities: appCapabilities } : {}),
1088
- configKeys: ["org", "repo"],
1089
- domains: ["api.github.com", "github.com"],
1090
- envVars: {
1091
- [appIdEnv]: {},
1092
- [privateKeyEnv]: {},
1093
- [installationIdEnv]: {},
1094
- [clientIdEnv]: {},
1095
- [clientSecretEnv]: {},
1096
- [botNameEnv]: { exposeToCommandEnv: true },
1097
- [botEmailEnv]: { exposeToCommandEnv: true },
1098
- },
1099
- oauth: {
1100
- clientIdEnv,
1101
- clientSecretEnv,
1102
- authorizeEndpoint: "https://github.com/login/oauth/authorize",
1103
- tokenEndpoint: "https://github.com/login/oauth/access_token",
1104
- // GitHub App user-to-server tokens always return scope: "" regardless
1105
- // of what was requested; treat empty response scope as unreported.
1106
- treatEmptyScopeAsUnreported: true,
1107
- ...(userScope ? { scope: userScope } : {}),
1108
- },
1109
- commandEnv: {
1110
- [GITHUB_AUTH_TOKEN_ENV]: GITHUB_AUTH_TOKEN_PLACEHOLDER,
1111
- GIT_COMMITTER_NAME: `\${${botNameEnv}}`,
1112
- GIT_COMMITTER_EMAIL: `\${${botEmailEnv}}`,
1113
- },
1114
- target: {
1115
- type: "repo",
1116
- configKey: "repo",
1117
- commandFlags: ["--repo", "-R"],
1118
- },
1119
- runtimeDependencies: [
1120
- {
1121
- type: "system",
1122
- package: "gh",
1123
- },
1124
- ],
1125
- },
1126
- hooks: {
1127
- async sandboxPrepare(ctx) {
1128
- const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`;
1129
- await ctx.sandbox.writeFile({
1130
- path: `${hooksPath}/prepare-commit-msg`,
1131
- mode: 0o755,
1132
- content: prepareCommitMsgHook(),
1133
- });
1134
- await configureGit(ctx, "core.hooksPath", hooksPath);
1135
- await configureGit(ctx, "commit.gpgsign", "false");
1136
- await configureGit(ctx, "credential.helper", "");
1137
- await configureGit(ctx, "http.emptyAuth", "true");
1138
- },
1139
- beforeToolExecute(ctx) {
1140
- if (ctx.tool.name !== "bash") {
1141
- return;
1142
- }
1143
- const command =
1144
- typeof ctx.tool.input === "object" &&
1145
- ctx.tool.input &&
1146
- "command" in ctx.tool.input
1147
- ? String(ctx.tool.input.command ?? "")
1148
- : "";
1149
- const botName = readEnv(botNameEnv);
1150
- const botEmail = readEnv(botEmailEnv);
1151
- if ((!botName || !botEmail) && isGitCommitCommand(command)) {
1152
- ctx.decision.deny(
1153
- `Junior GitHub plugin is misconfigured: host env vars ${botNameEnv} and ${botEmailEnv} are missing. This is an internal deployment configuration error; do not set them in the sandbox.`,
1154
- );
1155
- return;
1156
- }
1157
- if (!botName || !botEmail) {
1158
- return;
1159
- }
1160
- const authorName = requesterName(ctx.requester);
1161
- const authorEmail = requesterEmail(ctx.requester);
1162
- if ((!authorName || !authorEmail) && isGitCommitCommand(command)) {
1163
- ctx.decision.deny(
1164
- "Junior GitHub plugin could not determine a resolved requester name and email for commit attribution. This is an internal request-context error; do not set author env vars manually.",
1165
- );
1166
- return;
1167
- }
1168
- if (authorName && authorEmail) {
1169
- ctx.env.set("GIT_AUTHOR_NAME", authorName);
1170
- ctx.env.set("GIT_AUTHOR_EMAIL", authorEmail);
1171
- ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", authorName);
1172
- ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", authorEmail);
1173
- }
1174
- ctx.env.set("GIT_COMMITTER_NAME", botName);
1175
- ctx.env.set("GIT_COMMITTER_EMAIL", botEmail);
1176
- ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", botName);
1177
- ctx.env.set("JUNIOR_GIT_COAUTHOR_EMAIL", botEmail);
1178
- },
1179
- grantForEgress(ctx) {
1180
- return githubGrantForEgress(ctx);
1181
- },
1182
- async onEgressResponse(ctx) {
1183
- if (!shouldInspectGitHubGraphqlResponse(ctx)) {
1184
- return;
1185
- }
1186
- const bodyText = await ctx.response.readText(
1187
- GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES,
1188
- );
1189
- if (!bodyText) {
1190
- return;
1191
- }
1192
- const message = githubGraphqlPermissionDeniedMessage(bodyText);
1193
- if (message) {
1194
- ctx.permissionDenied(message);
1195
- }
1196
- },
1197
- async resolveOAuthAccount(ctx) {
1198
- return await resolveUserAccount(ctx.tokens);
1199
- },
1200
- async issueCredential(ctx) {
1201
- try {
1202
- if (ctx.grant.name === "installation-read") {
1203
- return await issueInstallationCredential({
1204
- appIdEnv,
1205
- privateKeyEnv,
1206
- installationIdEnv,
1207
- readPermissions: appReadPermissions,
1208
- loadReadPermissions,
1209
- });
1210
- }
1211
- if (USER_TOKEN_GRANTS.has(ctx.grant.name)) {
1212
- return await issueUserCredential(ctx, {
1213
- clientIdEnv,
1214
- clientSecretEnv,
1215
- userScope,
1216
- });
1217
- }
1218
- } catch (error) {
1219
- if (error instanceof GitHubPluginSetupError) {
1220
- return credentialUnavailable(error.message);
1221
- }
1222
- throw error;
1223
- }
1224
- throw new Error(
1225
- `GitHub plugin cannot issue unknown grant "${ctx.grant.name}".`,
1226
- );
1227
- },
1228
- },
1229
- });
1230
- }