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