From 4567c5d39ce9a48d864277e74da2e58f4abc08b4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:46:48 +0000 Subject: [PATCH 1/3] fix(github): add write credential correlation telemetry Log a safe token fingerprint from mint through lease cache and outbound injection, and keep mint permissions/repositories on the issue path so intermittent git-receive-pack 403s can be correlated. Co-Authored-By: David Cramer --- TELEMETRY.md | 19 +++- .../junior-github/src/credential-support.ts | 95 ++++++++++++++++++- packages/junior-github/src/plugin.ts | 38 ++++---- .../junior-github/tests/github-plugin.test.ts | 30 +++++- .../src/chat/credentials/token-fingerprint.ts | 65 +++++++++++++ .../junior/src/chat/egress/credentialed.ts | 71 +++++++++++--- .../src/chat/sandbox/egress/credentials.ts | 45 ++++++++- .../credentials/token-fingerprint.test.ts | 39 ++++++++ 8 files changed, 364 insertions(+), 38 deletions(-) create mode 100644 packages/junior/src/chat/credentials/token-fingerprint.ts create mode 100644 packages/junior/tests/unit/credentials/token-fingerprint.test.ts diff --git a/TELEMETRY.md b/TELEMETRY.md index f27e546e1a..c9efe4e98d 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -248,18 +248,33 @@ conversation, use `app.dispatch.id` or `agent-dispatch:` as A turn parked for auth, resumed late, or failed after callback. Events: `sandbox.egress.credential.needed`, -`sandbox.egress.credential.unavailable`, `plugin.credential.rejected`, +`sandbox.egress.credential.unavailable`, `sandbox.egress.credential_lease.stored`, +`sandbox.egress.credential_lease.reused`, `sandbox.egress.credential.injected`, +`sandbox.egress.upstream_auth.rejected`, `plugin.credential.rejected`, +`plugin.log.info` (GitHub mint: `app.log.message=github.installation_token.issued`), `subscribed_message.authorization.required`, `agent.continue.schedule.failed`, `agent.continue.lock.busy`, `agent.continue.lock.retrying`, `oauth.callback.resume.completed`, `oauth.callback.resume.busy`, `mcp.oauth_callback.failed` -Spans: resumed `chat.turn`, `chat.reply` +Spans: resumed `chat.turn`, `chat.reply`, sandbox egress `http.server` Attributes: `app.credential.provider`, `app.credential.delivery`, +`app.credential.token_fingerprint`, +`app.credential.injected_token_fingerprint`, `app.github.accepted_permissions`, +`app.github.sso`, `app.github.token_permissions`, +`app.github.token_repositories`, `app.github.token_expires_at`, +`app.grant.name`, `app.grant.access`, `app.grant.lease_scope`, `app.ai.retryable_reason`, `app.ai.session_id`, `app.ai.resume_session_version` +Correlate intermittent GitHub write 403s by matching +`app.credential.token_fingerprint` across mint (`github.installation_token.issued`), +lease store/reuse, injection, and `sandbox.egress.upstream_auth.rejected`. +Compare that value with `app.credential.injected_token_fingerprint` on the +rejected hop. Include `app.github.accepted_permissions` and mint +`app.github.token_permissions` / `app.github.token_repositories` when present. + ### Skills And Plugins A skill/tool is missing, plugin discovery failed, or capability activation looks wrong. diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index f08ee05743..431df256b9 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -4,10 +4,11 @@ * This module owns OAuth refresh, installation tokens, credential leases, and * repository-scoped credential parsing. */ -import { createPrivateKey, createSign } from "node:crypto"; +import { createHash, createPrivateKey, createSign } from "node:crypto"; import type { PluginCredentialResult, PluginGrant, + PluginLogger, PluginProviderAccount, PluginStoredTokens, PluginUserTokenSlot, @@ -510,9 +511,60 @@ export function credentialUnavailable(message: string): PluginCredentialResult { }; } +function fingerprintCredentialToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12); +} + +function parseTokenPermissions( + value: unknown, +): Record | undefined { + if (!isRecord(value)) return undefined; + const permissions: Record = {}; + for (const [scope, level] of Object.entries(value)) { + if (typeof level === "string" && scope.trim() && level.trim()) { + permissions[scope.trim()] = level.trim(); + } + } + return Object.keys(permissions).length > 0 ? permissions : undefined; +} + +function parseTokenRepositories(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const repositories: string[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.trim()) { + repositories.push(entry.trim()); + continue; + } + if (!isRecord(entry)) continue; + const fullName = + typeof entry.full_name === "string" ? entry.full_name.trim() : ""; + if (fullName) { + repositories.push(fullName); + continue; + } + const name = typeof entry.name === "string" ? entry.name.trim() : ""; + if (name) repositories.push(name); + } + return repositories.length > 0 ? repositories : undefined; +} + +function serializeTokenPermissions( + permissions: Record | undefined, +): string | undefined { + if (!permissions) return undefined; + const entries = Object.entries(permissions) + .map(([scope, level]) => `${scope}:${level}`) + .sort(); + return entries.length > 0 ? entries.join(",") : undefined; +} + function parseInstallationTokenResponse(data: unknown): { expiresAtMs: number; + permissions?: Record; + repositories?: string[]; token: string; + tokenFingerprint: string; } { if (!isRecord(data)) { throw new Error("GitHub installation token response is invalid"); @@ -529,7 +581,15 @@ function parseInstallationTokenResponse(data: unknown): { "GitHub installation token response returned invalid expires_at", ); } - return { token, expiresAtMs }; + const permissions = parseTokenPermissions(data.permissions); + const repositories = parseTokenRepositories(data.repositories); + return { + token, + tokenFingerprint: fingerprintCredentialToken(token), + expiresAtMs, + ...(permissions ? { permissions } : {}), + ...(repositories ? { repositories } : {}), + }; } function readInstallationPermissions( @@ -809,10 +869,23 @@ export async function issueUserCredential( return credentialNeeded("Your GitHub authorization has expired.", scope); } +export interface IssuedInstallationToken { + expiresAtMs: number; + permissions?: Record; + repositories?: string[]; + token: string; + tokenFingerprint: string; +} + +interface IssueInstallationCredentialTelemetry { + grantName?: string; + log?: Pick; +} + /** Issue a bounded raw token for plugin-owned GitHub API calls. */ export async function issueInstallationToken( options: InstallationCredentialOptions, -): Promise<{ expiresAtMs: number; token: string }> { +): Promise { const appId = requireEnv(options.appIdEnv); const installationIdRaw = requireEnv(options.installationIdEnv); const installationId = Number(installationIdRaw); @@ -846,14 +919,30 @@ export async function issueInstallationToken( return { expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS), token: parsedToken.token, + tokenFingerprint: parsedToken.tokenFingerprint, + ...(parsedToken.permissions ? { permissions: parsedToken.permissions } : {}), + ...(parsedToken.repositories + ? { repositories: parsedToken.repositories } + : {}), }; } /** Issue a bounded GitHub App installation credential. */ export async function issueInstallationCredential( options: InstallationCredentialOptions, + telemetry?: IssueInstallationCredentialTelemetry, ): Promise { const token = await issueInstallationToken(options); + const permissions = serializeTokenPermissions(token.permissions); + telemetry?.log?.info("github.installation_token.issued", { + "app.credential.token_fingerprint": token.tokenFingerprint, + ...(telemetry.grantName ? { "app.grant.name": telemetry.grantName } : {}), + ...(permissions ? { "app.github.token_permissions": permissions } : {}), + ...(token.repositories + ? { "app.github.token_repositories": token.repositories } + : {}), + "app.github.token_expires_at": new Date(token.expiresAtMs).toISOString(), + }); return createCredentialLease({ token: token.token, expiresAtMs: token.expiresAtMs, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index b9ed177ad3..a8e8bdee69 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -956,27 +956,33 @@ export function githubPlugin( async issueCredential(ctx) { try { if (ctx.grant.name === "installation-read") { - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - ...(declaredReadPermissions - ? { permissions: declaredReadPermissions } - : { loadPermissions: loadReadPermissions }), - }); + return await issueInstallationCredential( + { + appIdEnv, + privateKeyEnv, + installationIdEnv, + ...(declaredReadPermissions + ? { permissions: declaredReadPermissions } + : { loadPermissions: loadReadPermissions }), + }, + { grantName: ctx.grant.name, log: ctx.log }, + ); } if (ctx.grant.name === "installation-write") { const repository = githubRepositoryFromLeaseScope( ctx.grant.leaseScope, ); - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - // This repository-only variant cannot downscope the installed - // App envelope with an operation-specific permission body. - repositories: [repository.name], - }); + return await issueInstallationCredential( + { + appIdEnv, + privateKeyEnv, + installationIdEnv, + // This repository-only variant cannot downscope the installed + // App envelope with an operation-specific permission body. + repositories: [repository.name], + }, + { grantName: ctx.grant.name, log: ctx.log }, + ); } if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { return await issueUserCredential(ctx, { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index ce5ed57a6d..e725263386 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -105,9 +105,10 @@ function beforeToolContext(actor: TestActor, actors?: TestActor[]) { }; } +const pluginLogInfo = vi.fn(); const pluginLog = { error() {}, - info() {}, + info: pluginLogInfo, warn() {}, }; @@ -220,10 +221,25 @@ function mockGitHubInstallationApi(): CapturedRequest[] { http.post( "https://api.github.com/app/installations/:installationId/access_tokens", async ({ request }) => { - requests.push(await captureRequest(request)); + const captured = await captureRequest(request); + requests.push(captured); + const body = + captured.body && + typeof captured.body === "object" && + !Array.isArray(captured.body) + ? (captured.body as { repositories?: string[] }) + : {}; return HttpResponse.json({ token: "installation-token", expires_at: new Date(Date.now() + 60_000).toISOString(), + permissions: { + contents: "write", + metadata: "read", + }, + repositories: (body.repositories ?? ["junior"]).map((name) => ({ + full_name: `getsentry/${name}`, + name, + })), }); }, ), @@ -2232,6 +2248,7 @@ Conversation: \`local:test:old-conversation\` process.env.GITHUB_APP_ID = "123"; process.env.GITHUB_INSTALLATION_ID = "456"; process.env.GITHUB_APP_PRIVATE_KEY = privateKey; + pluginLogInfo.mockClear(); const requests = mockGitHubInstallationApi(); const plugin = githubPlugin({ appPermissions: { @@ -2263,6 +2280,15 @@ Conversation: \`local:test:old-conversation\` }, headers: expect.any(Object), }); + expect(pluginLogInfo).toHaveBeenCalledWith( + "github.installation_token.issued", + expect.objectContaining({ + "app.grant.name": "installation-write", + "app.credential.token_fingerprint": expect.any(String), + "app.github.token_permissions": "contents:write,metadata:read", + "app.github.token_repositories": ["getsentry/junior"], + }), + ); }); it("issues read-only GitHub App installation credentials from plugin hooks", async () => { diff --git a/packages/junior/src/chat/credentials/token-fingerprint.ts b/packages/junior/src/chat/credentials/token-fingerprint.ts new file mode 100644 index 0000000000..7aa4f59471 --- /dev/null +++ b/packages/junior/src/chat/credentials/token-fingerprint.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; + +/** + * Build a short non-reversible fingerprint for correlating a credential across + * mint, lease cache, and outbound injection without logging the secret. + */ +export function fingerprintCredentialToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12); +} + +/** + * Recover the credential token from a lease Authorization header value. + * + * Supports `Bearer ` and Git smart-HTTP + * `Basic base64(x-access-token:)`. + */ +export function credentialTokenFromAuthorizationHeader( + value: string | undefined, +): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const bearer = /^Bearer\s+(.+)$/i.exec(trimmed); + if (bearer?.[1]) { + const token = bearer[1].trim(); + return token || undefined; + } + const basic = /^Basic\s+(.+)$/i.exec(trimmed); + if (!basic?.[1]) { + return undefined; + } + try { + const decoded = Buffer.from(basic[1].trim(), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator < 0) { + return undefined; + } + const password = decoded.slice(separator + 1); + return password || undefined; + } catch { + return undefined; + } +} + +/** Fingerprint the first Authorization token found in lease header transforms. */ +export function fingerprintLeaseAuthorization( + headerTransforms: Array<{ headers: Record }>, +): string | undefined { + for (const transform of headerTransforms) { + for (const [key, value] of Object.entries(transform.headers)) { + if (key.toLowerCase() !== "authorization") { + continue; + } + const token = credentialTokenFromAuthorizationHeader(value); + if (token) { + return fingerprintCredentialToken(token); + } + } + } + return undefined; +} diff --git a/packages/junior/src/chat/egress/credentialed.ts b/packages/junior/src/chat/egress/credentialed.ts index bfe1b259ce..5eb6f4f0b9 100644 --- a/packages/junior/src/chat/egress/credentialed.ts +++ b/packages/junior/src/chat/egress/credentialed.ts @@ -1,3 +1,8 @@ +import { + credentialTokenFromAuthorizationHeader, + fingerprintCredentialToken, + fingerprintLeaseAuthorization, +} from "@/chat/credentials/token-fingerprint"; import { logInfo, logWarn } from "@/chat/logging"; import { onPluginEgressResponse } from "@/chat/plugins/credential-hooks"; import { matchesSandboxEgressDomain } from "@/chat/sandbox/egress/policy"; @@ -505,15 +510,30 @@ function responseHeaders(upstream: Response): Headers { return headers; } +function leaseTokenFingerprint( + lease: SandboxEgressCredentialLease, +): string | undefined { + return fingerprintLeaseAuthorization(lease.headerTransforms); +} + +function injectedTokenFingerprint(headers: Headers): string | undefined { + const token = credentialTokenFromAuthorizationHeader( + headers.get("authorization") ?? undefined, + ); + return token ? fingerprintCredentialToken(token) : undefined; +} + function leaseLogAttributes(input: { egressId: string; + injectedTokenFingerprint?: string; lease: SandboxEgressCredentialLease; provider: string; request: Request; - status: number; + status?: number; upstream?: Response; upstreamUrl: URL; }): Record { + const leaseFingerprint = leaseTokenFingerprint(input.lease); return { ...egressAttributes({ egressId: input.egressId, @@ -524,9 +544,21 @@ function leaseLogAttributes(input: { method: input.request.method, path: input.upstreamUrl.pathname, provider: input.provider, - status: input.status, + ...(input.status !== undefined ? { status: input.status } : {}), }), ...routingAttributes(input.request, input.upstreamUrl), + ...(input.lease.grant.leaseScope + ? { "app.grant.lease_scope": input.lease.grant.leaseScope } + : {}), + ...(leaseFingerprint + ? { "app.credential.token_fingerprint": leaseFingerprint } + : {}), + ...(input.injectedTokenFingerprint + ? { + "app.credential.injected_token_fingerprint": + input.injectedTokenFingerprint, + } + : {}), ...(input.upstream ? upstreamPermissionAttributes(input.provider, input.upstream) : {}), @@ -694,20 +726,16 @@ export async function executeCredentialedEgressRequest(input: { throw error; } - const attributes = (status: number, upstream?: Response) => - leaseLogAttributes({ - egressId: activeEgressId, - lease, - provider, - request, - status, - ...(upstream ? { upstream } : {}), - upstreamUrl, - }); - if (!hasSandboxEgressLeaseTransformForHost(lease, upstreamUrl.hostname)) { logWarn("sandbox.egress.transform.missing", { - ...attributes(403), + ...leaseLogAttributes({ + egressId: activeEgressId, + lease, + provider, + request, + status: 403, + upstreamUrl, + }), "app.sandbox.egress.transform_domains": lease.headerTransforms.map( (transform) => transform.domain, ), @@ -725,6 +753,21 @@ export async function executeCredentialedEgressRequest(input: { upstreamUrl.hostname, deps.tracePropagation ?? {}, ); + const injectedFingerprint = injectedTokenFingerprint(headers); + const attributes = (status?: number, upstream?: Response) => + leaseLogAttributes({ + egressId: activeEgressId, + lease, + provider, + request, + ...(status !== undefined ? { status } : {}), + ...(injectedFingerprint + ? { injectedTokenFingerprint: injectedFingerprint } + : {}), + ...(upstream ? { upstream } : {}), + upstreamUrl, + }); + logInfo("sandbox.egress.credential.injected", attributes()); const body = bodyForGrantSelection ?? (await requestBodyBytes(request)); const intercepted = await deps.interceptHttp?.({ provider, diff --git a/packages/junior/src/chat/sandbox/egress/credentials.ts b/packages/junior/src/chat/sandbox/egress/credentials.ts index 4faa84670b..7235624fda 100644 --- a/packages/junior/src/chat/sandbox/egress/credentials.ts +++ b/packages/junior/src/chat/sandbox/egress/credentials.ts @@ -3,6 +3,8 @@ import { issueProviderCredentialLease, } from "@/chat/capabilities/factory"; import { CredentialUnavailableError } from "@/chat/credentials/broker"; +import { fingerprintLeaseAuthorization } from "@/chat/credentials/token-fingerprint"; +import { logInfo } from "@/chat/logging"; import type { PluginAuthorization, PluginGrant, @@ -182,6 +184,30 @@ export function authorizationForSandboxEgressGrant( * domains, and reused only while both the provider lease and sandbox context are * still valid. */ +function leaseCredentialAttributes(input: { + context: SandboxEgressCredentialContext; + grant: PluginGrant; + lease: Pick; + provider: string; +}): Record { + const tokenFingerprint = fingerprintLeaseAuthorization( + input.lease.headerTransforms, + ); + return { + "app.sandbox.egress_id": input.context.egressId, + "app.provider.name": input.provider, + "app.grant.name": input.grant.name, + "app.grant.access": input.grant.access, + ...(input.grant.reason ? { "app.grant.reason": input.grant.reason } : {}), + ...(input.grant.leaseScope + ? { "app.grant.lease_scope": input.grant.leaseScope } + : {}), + ...(tokenFingerprint + ? { "app.credential.token_fingerprint": tokenFingerprint } + : {}), + }; +} + export async function sandboxEgressCredentialLease( provider: string, selection: SandboxEgressGrantSelection, @@ -199,10 +225,19 @@ export async function sandboxEgressCredentialLease( `Cached credential lease for ${provider}/${grant.name} has ${cached.grant.access} access, but ${grant.access} was selected`, ); } - return { + const reused = { ...cached, grant, }; + logInfo("sandbox.egress.credential_lease.reused", { + ...leaseCredentialAttributes({ + context, + grant, + lease: reused, + provider, + }), + }); + return reused; } let lease: { @@ -291,6 +326,14 @@ export async function sandboxEgressCredentialLease( }; assertLeaseTransformsOwnedByProvider(provider, cachedLease); await setSandboxEgressCredentialLease(context, cachedLease); + logInfo("sandbox.egress.credential_lease.stored", { + ...leaseCredentialAttributes({ + context, + grant, + lease: cachedLease, + provider, + }), + }); return cachedLease; } diff --git a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts new file mode 100644 index 0000000000..3a3b85236a --- /dev/null +++ b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + credentialTokenFromAuthorizationHeader, + fingerprintCredentialToken, + fingerprintLeaseAuthorization, +} from "@/chat/credentials/token-fingerprint"; + +describe("token fingerprint helpers", () => { + it("hashes tokens to a stable short fingerprint", () => { + const token = "installation-token"; + expect(fingerprintCredentialToken(token)).toBe( + createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12), + ); + }); + + it("recovers bearer and git smart-http basic tokens", () => { + expect( + credentialTokenFromAuthorizationHeader("Bearer installation-token"), + ).toBe("installation-token"); + const basic = Buffer.from("x-access-token:installation-token").toString( + "base64", + ); + expect(credentialTokenFromAuthorizationHeader(`Basic ${basic}`)).toBe( + "installation-token", + ); + }); + + it("fingerprints the first authorization header on a lease", () => { + const fingerprint = fingerprintLeaseAuthorization([ + { + headers: { + Authorization: "Bearer installation-token", + }, + }, + ]); + expect(fingerprint).toBe(fingerprintCredentialToken("installation-token")); + }); +}); From ff6ae10fcb491df401c0052c0a10f882031d1b76 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:51:03 +0000 Subject: [PATCH 2/3] fix(github): clear codeql false positive and file-length lint Use HMAC domain fingerprints for credential correlation and keep plugin.ts under the 1000-line limit. --- .../junior-github/src/credential-support.ts | 7 +++++-- packages/junior-github/src/plugin.ts | 19 ++++++------------- .../src/chat/credentials/token-fingerprint.ts | 15 +++++++++++---- .../credentials/token-fingerprint.test.ts | 15 ++++++++++----- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index 431df256b9..bd19570551 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -4,7 +4,7 @@ * This module owns OAuth refresh, installation tokens, credential leases, and * repository-scoped credential parsing. */ -import { createHash, createPrivateKey, createSign } from "node:crypto"; +import { createHmac, createPrivateKey, createSign } from "node:crypto"; import type { PluginCredentialResult, PluginGrant, @@ -512,7 +512,10 @@ export function credentialUnavailable(message: string): PluginCredentialResult { } function fingerprintCredentialToken(token: string): string { - return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12); + return createHmac("sha256", "junior.credential-fingerprint.v1") + .update(token, "utf8") + .digest("hex") + .slice(0, 12); } function parseTokenPermissions( diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index a8e8bdee69..60c9966e73 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -954,6 +954,7 @@ export function githubPlugin( return await resolveUserAccount(ctx.tokens); }, async issueCredential(ctx) { + const mintTelemetry = { grantName: ctx.grant.name, log: ctx.log }; try { if (ctx.grant.name === "installation-read") { return await issueInstallationCredential( @@ -965,23 +966,15 @@ export function githubPlugin( ? { permissions: declaredReadPermissions } : { loadPermissions: loadReadPermissions }), }, - { grantName: ctx.grant.name, log: ctx.log }, + mintTelemetry, ); } if (ctx.grant.name === "installation-write") { - const repository = githubRepositoryFromLeaseScope( - ctx.grant.leaseScope, - ); + // Repository-only mint keeps the installed App permission envelope. + const repository = githubRepositoryFromLeaseScope(ctx.grant.leaseScope); return await issueInstallationCredential( - { - appIdEnv, - privateKeyEnv, - installationIdEnv, - // This repository-only variant cannot downscope the installed - // App envelope with an operation-specific permission body. - repositories: [repository.name], - }, - { grantName: ctx.grant.name, log: ctx.log }, + { appIdEnv, privateKeyEnv, installationIdEnv, repositories: [repository.name] }, + mintTelemetry, ); } if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { diff --git a/packages/junior/src/chat/credentials/token-fingerprint.ts b/packages/junior/src/chat/credentials/token-fingerprint.ts index 7aa4f59471..bf5cc64eaa 100644 --- a/packages/junior/src/chat/credentials/token-fingerprint.ts +++ b/packages/junior/src/chat/credentials/token-fingerprint.ts @@ -1,11 +1,17 @@ -import { createHash } from "node:crypto"; +import { createHmac } from "node:crypto"; + +/** Domain separator so fingerprints are correlation ids, not secret digests. */ +const CREDENTIAL_FINGERPRINT_DOMAIN = "junior.credential-fingerprint.v1"; /** * Build a short non-reversible fingerprint for correlating a credential across * mint, lease cache, and outbound injection without logging the secret. */ export function fingerprintCredentialToken(token: string): string { - return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12); + return createHmac("sha256", CREDENTIAL_FINGERPRINT_DOMAIN) + .update(token, "utf8") + .digest("hex") + .slice(0, 12); } /** @@ -39,8 +45,9 @@ export function credentialTokenFromAuthorizationHeader( if (separator < 0) { return undefined; } - const password = decoded.slice(separator + 1); - return password || undefined; + // Git smart-HTTP uses x-access-token:. + const credential = decoded.slice(separator + 1); + return credential || undefined; } catch { return undefined; } diff --git a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts index 3a3b85236a..959d2e71d6 100644 --- a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts +++ b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { credentialTokenFromAuthorizationHeader, @@ -6,12 +6,17 @@ import { fingerprintLeaseAuthorization, } from "@/chat/credentials/token-fingerprint"; +function expectedFingerprint(token: string): string { + return createHmac("sha256", "junior.credential-fingerprint.v1") + .update(token, "utf8") + .digest("hex") + .slice(0, 12); +} + describe("token fingerprint helpers", () => { it("hashes tokens to a stable short fingerprint", () => { const token = "installation-token"; - expect(fingerprintCredentialToken(token)).toBe( - createHash("sha256").update(token, "utf8").digest("hex").slice(0, 12), - ); + expect(fingerprintCredentialToken(token)).toBe(expectedFingerprint(token)); }); it("recovers bearer and git smart-http basic tokens", () => { @@ -34,6 +39,6 @@ describe("token fingerprint helpers", () => { }, }, ]); - expect(fingerprint).toBe(fingerprintCredentialToken("installation-token")); + expect(fingerprint).toBe(expectedFingerprint("installation-token")); }); }); From 9f821cdb1769be1c1a815baacb4307099dd7851c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:39:44 +0000 Subject: [PATCH 3/3] fix(github): slim credential correlation telemetry Keep only a mint fingerprint and the same fingerprint on upstream auth rejection. Drop the extra lease/injection events and mint-envelope logging. Co-Authored-By: David Cramer --- TELEMETRY.md | 21 +--- .../junior-github/src/credential-support.ts | 104 ++---------------- packages/junior-github/src/plugin.ts | 5 +- .../junior-github/tests/github-plugin.test.ts | 20 +--- .../src/chat/credentials/token-fingerprint.ts | 44 ++------ .../junior/src/chat/egress/credentialed.ts | 78 ++++--------- .../src/chat/sandbox/egress/credentials.ts | 45 +------- .../credentials/token-fingerprint.test.ts | 20 ++-- 8 files changed, 61 insertions(+), 276 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index c9efe4e98d..9c63fc1908 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -248,32 +248,23 @@ conversation, use `app.dispatch.id` or `agent-dispatch:` as A turn parked for auth, resumed late, or failed after callback. Events: `sandbox.egress.credential.needed`, -`sandbox.egress.credential.unavailable`, `sandbox.egress.credential_lease.stored`, -`sandbox.egress.credential_lease.reused`, `sandbox.egress.credential.injected`, -`sandbox.egress.upstream_auth.rejected`, `plugin.credential.rejected`, -`plugin.log.info` (GitHub mint: `app.log.message=github.installation_token.issued`), +`sandbox.egress.credential.unavailable`, `plugin.credential.rejected`, `subscribed_message.authorization.required`, `agent.continue.schedule.failed`, `agent.continue.lock.busy`, `agent.continue.lock.retrying`, `oauth.callback.resume.completed`, `oauth.callback.resume.busy`, `mcp.oauth_callback.failed` -Spans: resumed `chat.turn`, `chat.reply`, sandbox egress `http.server` +Spans: resumed `chat.turn`, `chat.reply` Attributes: `app.credential.provider`, `app.credential.delivery`, `app.credential.token_fingerprint`, -`app.credential.injected_token_fingerprint`, `app.github.accepted_permissions`, -`app.github.sso`, `app.github.token_permissions`, -`app.github.token_repositories`, `app.github.token_expires_at`, -`app.grant.name`, `app.grant.access`, `app.grant.lease_scope`, `app.ai.retryable_reason`, `app.ai.session_id`, `app.ai.resume_session_version` -Correlate intermittent GitHub write 403s by matching -`app.credential.token_fingerprint` across mint (`github.installation_token.issued`), -lease store/reuse, injection, and `sandbox.egress.upstream_auth.rejected`. -Compare that value with `app.credential.injected_token_fingerprint` on the -rejected hop. Include `app.github.accepted_permissions` and mint -`app.github.token_permissions` / `app.github.token_repositories` when present. +`app.credential.token_fingerprint` appears on GitHub installation mint +(`plugin.log.info` with `app.log.message=github.installation_token.issued`) and +on `sandbox.egress.upstream_auth.rejected` so mint and rejected hops can be +matched. ### Skills And Plugins diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index bd19570551..87b0e71d10 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -511,63 +511,9 @@ export function credentialUnavailable(message: string): PluginCredentialResult { }; } -function fingerprintCredentialToken(token: string): string { - return createHmac("sha256", "junior.credential-fingerprint.v1") - .update(token, "utf8") - .digest("hex") - .slice(0, 12); -} - -function parseTokenPermissions( - value: unknown, -): Record | undefined { - if (!isRecord(value)) return undefined; - const permissions: Record = {}; - for (const [scope, level] of Object.entries(value)) { - if (typeof level === "string" && scope.trim() && level.trim()) { - permissions[scope.trim()] = level.trim(); - } - } - return Object.keys(permissions).length > 0 ? permissions : undefined; -} - -function parseTokenRepositories(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const repositories: string[] = []; - for (const entry of value) { - if (typeof entry === "string" && entry.trim()) { - repositories.push(entry.trim()); - continue; - } - if (!isRecord(entry)) continue; - const fullName = - typeof entry.full_name === "string" ? entry.full_name.trim() : ""; - if (fullName) { - repositories.push(fullName); - continue; - } - const name = typeof entry.name === "string" ? entry.name.trim() : ""; - if (name) repositories.push(name); - } - return repositories.length > 0 ? repositories : undefined; -} - -function serializeTokenPermissions( - permissions: Record | undefined, -): string | undefined { - if (!permissions) return undefined; - const entries = Object.entries(permissions) - .map(([scope, level]) => `${scope}:${level}`) - .sort(); - return entries.length > 0 ? entries.join(",") : undefined; -} - function parseInstallationTokenResponse(data: unknown): { expiresAtMs: number; - permissions?: Record; - repositories?: string[]; token: string; - tokenFingerprint: string; } { if (!isRecord(data)) { throw new Error("GitHub installation token response is invalid"); @@ -584,15 +530,7 @@ function parseInstallationTokenResponse(data: unknown): { "GitHub installation token response returned invalid expires_at", ); } - const permissions = parseTokenPermissions(data.permissions); - const repositories = parseTokenRepositories(data.repositories); - return { - token, - tokenFingerprint: fingerprintCredentialToken(token), - expiresAtMs, - ...(permissions ? { permissions } : {}), - ...(repositories ? { repositories } : {}), - }; + return { token, expiresAtMs }; } function readInstallationPermissions( @@ -872,23 +810,10 @@ export async function issueUserCredential( return credentialNeeded("Your GitHub authorization has expired.", scope); } -export interface IssuedInstallationToken { - expiresAtMs: number; - permissions?: Record; - repositories?: string[]; - token: string; - tokenFingerprint: string; -} - -interface IssueInstallationCredentialTelemetry { - grantName?: string; - log?: Pick; -} - /** Issue a bounded raw token for plugin-owned GitHub API calls. */ export async function issueInstallationToken( options: InstallationCredentialOptions, -): Promise { +): Promise<{ expiresAtMs: number; token: string }> { const appId = requireEnv(options.appIdEnv); const installationIdRaw = requireEnv(options.installationIdEnv); const installationId = Number(installationIdRaw); @@ -922,29 +847,24 @@ export async function issueInstallationToken( return { expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS), token: parsedToken.token, - tokenFingerprint: parsedToken.tokenFingerprint, - ...(parsedToken.permissions ? { permissions: parsedToken.permissions } : {}), - ...(parsedToken.repositories - ? { repositories: parsedToken.repositories } - : {}), }; } +function fingerprintInstallationToken(token: string): string { + return createHmac("sha256", "junior.credential-fingerprint.v1") + .update(token, "utf8") + .digest("hex") + .slice(0, 12); +} + /** Issue a bounded GitHub App installation credential. */ export async function issueInstallationCredential( options: InstallationCredentialOptions, - telemetry?: IssueInstallationCredentialTelemetry, + log?: Pick, ): Promise { const token = await issueInstallationToken(options); - const permissions = serializeTokenPermissions(token.permissions); - telemetry?.log?.info("github.installation_token.issued", { - "app.credential.token_fingerprint": token.tokenFingerprint, - ...(telemetry.grantName ? { "app.grant.name": telemetry.grantName } : {}), - ...(permissions ? { "app.github.token_permissions": permissions } : {}), - ...(token.repositories - ? { "app.github.token_repositories": token.repositories } - : {}), - "app.github.token_expires_at": new Date(token.expiresAtMs).toISOString(), + log?.info("github.installation_token.issued", { + "app.credential.token_fingerprint": fingerprintInstallationToken(token.token), }); return createCredentialLease({ token: token.token, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 60c9966e73..74cd412c40 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -954,7 +954,6 @@ export function githubPlugin( return await resolveUserAccount(ctx.tokens); }, async issueCredential(ctx) { - const mintTelemetry = { grantName: ctx.grant.name, log: ctx.log }; try { if (ctx.grant.name === "installation-read") { return await issueInstallationCredential( @@ -966,7 +965,7 @@ export function githubPlugin( ? { permissions: declaredReadPermissions } : { loadPermissions: loadReadPermissions }), }, - mintTelemetry, + ctx.log, ); } if (ctx.grant.name === "installation-write") { @@ -974,7 +973,7 @@ export function githubPlugin( const repository = githubRepositoryFromLeaseScope(ctx.grant.leaseScope); return await issueInstallationCredential( { appIdEnv, privateKeyEnv, installationIdEnv, repositories: [repository.name] }, - mintTelemetry, + ctx.log, ); } if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index e725263386..4ce668d609 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -221,25 +221,10 @@ function mockGitHubInstallationApi(): CapturedRequest[] { http.post( "https://api.github.com/app/installations/:installationId/access_tokens", async ({ request }) => { - const captured = await captureRequest(request); - requests.push(captured); - const body = - captured.body && - typeof captured.body === "object" && - !Array.isArray(captured.body) - ? (captured.body as { repositories?: string[] }) - : {}; + requests.push(await captureRequest(request)); return HttpResponse.json({ token: "installation-token", expires_at: new Date(Date.now() + 60_000).toISOString(), - permissions: { - contents: "write", - metadata: "read", - }, - repositories: (body.repositories ?? ["junior"]).map((name) => ({ - full_name: `getsentry/${name}`, - name, - })), }); }, ), @@ -2283,10 +2268,7 @@ Conversation: \`local:test:old-conversation\` expect(pluginLogInfo).toHaveBeenCalledWith( "github.installation_token.issued", expect.objectContaining({ - "app.grant.name": "installation-write", "app.credential.token_fingerprint": expect.any(String), - "app.github.token_permissions": "contents:write,metadata:read", - "app.github.token_repositories": ["getsentry/junior"], }), ); }); diff --git a/packages/junior/src/chat/credentials/token-fingerprint.ts b/packages/junior/src/chat/credentials/token-fingerprint.ts index bf5cc64eaa..8d87e74d12 100644 --- a/packages/junior/src/chat/credentials/token-fingerprint.ts +++ b/packages/junior/src/chat/credentials/token-fingerprint.ts @@ -1,12 +1,8 @@ import { createHmac } from "node:crypto"; -/** Domain separator so fingerprints are correlation ids, not secret digests. */ const CREDENTIAL_FINGERPRINT_DOMAIN = "junior.credential-fingerprint.v1"; -/** - * Build a short non-reversible fingerprint for correlating a credential across - * mint, lease cache, and outbound injection without logging the secret. - */ +/** Short non-reversible id for correlating a credential without logging the secret. */ export function fingerprintCredentialToken(token: string): string { return createHmac("sha256", CREDENTIAL_FINGERPRINT_DOMAIN) .update(token, "utf8") @@ -14,38 +10,20 @@ export function fingerprintCredentialToken(token: string): string { .slice(0, 12); } -/** - * Recover the credential token from a lease Authorization header value. - * - * Supports `Bearer ` and Git smart-HTTP - * `Basic base64(x-access-token:)`. - */ +/** Recover a token from Bearer or git smart-HTTP Basic Authorization values. */ export function credentialTokenFromAuthorizationHeader( value: string | undefined, ): string | undefined { - if (!value) { - return undefined; - } + if (!value?.trim()) return undefined; const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } const bearer = /^Bearer\s+(.+)$/i.exec(trimmed); - if (bearer?.[1]) { - const token = bearer[1].trim(); - return token || undefined; - } + if (bearer?.[1]?.trim()) return bearer[1].trim(); const basic = /^Basic\s+(.+)$/i.exec(trimmed); - if (!basic?.[1]) { - return undefined; - } + if (!basic?.[1]) return undefined; try { const decoded = Buffer.from(basic[1].trim(), "base64").toString("utf8"); const separator = decoded.indexOf(":"); - if (separator < 0) { - return undefined; - } - // Git smart-HTTP uses x-access-token:. + if (separator < 0) return undefined; const credential = decoded.slice(separator + 1); return credential || undefined; } catch { @@ -53,19 +31,15 @@ export function credentialTokenFromAuthorizationHeader( } } -/** Fingerprint the first Authorization token found in lease header transforms. */ +/** Fingerprint the first Authorization token on lease header transforms. */ export function fingerprintLeaseAuthorization( headerTransforms: Array<{ headers: Record }>, ): string | undefined { for (const transform of headerTransforms) { for (const [key, value] of Object.entries(transform.headers)) { - if (key.toLowerCase() !== "authorization") { - continue; - } + if (key.toLowerCase() !== "authorization") continue; const token = credentialTokenFromAuthorizationHeader(value); - if (token) { - return fingerprintCredentialToken(token); - } + if (token) return fingerprintCredentialToken(token); } } return undefined; diff --git a/packages/junior/src/chat/egress/credentialed.ts b/packages/junior/src/chat/egress/credentialed.ts index 5eb6f4f0b9..ca328efdfb 100644 --- a/packages/junior/src/chat/egress/credentialed.ts +++ b/packages/junior/src/chat/egress/credentialed.ts @@ -1,8 +1,4 @@ -import { - credentialTokenFromAuthorizationHeader, - fingerprintCredentialToken, - fingerprintLeaseAuthorization, -} from "@/chat/credentials/token-fingerprint"; +import { fingerprintLeaseAuthorization } from "@/chat/credentials/token-fingerprint"; import { logInfo, logWarn } from "@/chat/logging"; import { onPluginEgressResponse } from "@/chat/plugins/credential-hooks"; import { matchesSandboxEgressDomain } from "@/chat/sandbox/egress/policy"; @@ -510,30 +506,15 @@ function responseHeaders(upstream: Response): Headers { return headers; } -function leaseTokenFingerprint( - lease: SandboxEgressCredentialLease, -): string | undefined { - return fingerprintLeaseAuthorization(lease.headerTransforms); -} - -function injectedTokenFingerprint(headers: Headers): string | undefined { - const token = credentialTokenFromAuthorizationHeader( - headers.get("authorization") ?? undefined, - ); - return token ? fingerprintCredentialToken(token) : undefined; -} - function leaseLogAttributes(input: { egressId: string; - injectedTokenFingerprint?: string; lease: SandboxEgressCredentialLease; provider: string; request: Request; - status?: number; + status: number; upstream?: Response; upstreamUrl: URL; }): Record { - const leaseFingerprint = leaseTokenFingerprint(input.lease); return { ...egressAttributes({ egressId: input.egressId, @@ -544,21 +525,9 @@ function leaseLogAttributes(input: { method: input.request.method, path: input.upstreamUrl.pathname, provider: input.provider, - ...(input.status !== undefined ? { status: input.status } : {}), + status: input.status, }), ...routingAttributes(input.request, input.upstreamUrl), - ...(input.lease.grant.leaseScope - ? { "app.grant.lease_scope": input.lease.grant.leaseScope } - : {}), - ...(leaseFingerprint - ? { "app.credential.token_fingerprint": leaseFingerprint } - : {}), - ...(input.injectedTokenFingerprint - ? { - "app.credential.injected_token_fingerprint": - input.injectedTokenFingerprint, - } - : {}), ...(input.upstream ? upstreamPermissionAttributes(input.provider, input.upstream) : {}), @@ -726,16 +695,20 @@ export async function executeCredentialedEgressRequest(input: { throw error; } + const attributes = (status: number, upstream?: Response) => + leaseLogAttributes({ + egressId: activeEgressId, + lease, + provider, + request, + status, + ...(upstream ? { upstream } : {}), + upstreamUrl, + }); + if (!hasSandboxEgressLeaseTransformForHost(lease, upstreamUrl.hostname)) { logWarn("sandbox.egress.transform.missing", { - ...leaseLogAttributes({ - egressId: activeEgressId, - lease, - provider, - request, - status: 403, - upstreamUrl, - }), + ...attributes(403), "app.sandbox.egress.transform_domains": lease.headerTransforms.map( (transform) => transform.domain, ), @@ -753,21 +726,6 @@ export async function executeCredentialedEgressRequest(input: { upstreamUrl.hostname, deps.tracePropagation ?? {}, ); - const injectedFingerprint = injectedTokenFingerprint(headers); - const attributes = (status?: number, upstream?: Response) => - leaseLogAttributes({ - egressId: activeEgressId, - lease, - provider, - request, - ...(status !== undefined ? { status } : {}), - ...(injectedFingerprint - ? { injectedTokenFingerprint: injectedFingerprint } - : {}), - ...(upstream ? { upstream } : {}), - upstreamUrl, - }); - logInfo("sandbox.egress.credential.injected", attributes()); const body = bodyForGrantSelection ?? (await requestBodyBytes(request)); const intercepted = await deps.interceptHttp?.({ provider, @@ -857,8 +815,14 @@ export async function executeCredentialedEgressRequest(input: { upstream.status === UPSTREAM_TOKEN_REJECTION_STATUS || upstream.status === UPSTREAM_PERMISSION_REJECTION_STATUS ) { + const tokenFingerprint = fingerprintLeaseAuthorization( + lease.headerTransforms, + ); logWarn("sandbox.egress.upstream_auth.rejected", { ...attributes(upstream.status, upstream), + ...(tokenFingerprint + ? { "app.credential.token_fingerprint": tokenFingerprint } + : {}), ...(upstream.status === UPSTREAM_TOKEN_REJECTION_STATUS ? { "app.sandbox.egress.www_authenticate": diff --git a/packages/junior/src/chat/sandbox/egress/credentials.ts b/packages/junior/src/chat/sandbox/egress/credentials.ts index 7235624fda..4faa84670b 100644 --- a/packages/junior/src/chat/sandbox/egress/credentials.ts +++ b/packages/junior/src/chat/sandbox/egress/credentials.ts @@ -3,8 +3,6 @@ import { issueProviderCredentialLease, } from "@/chat/capabilities/factory"; import { CredentialUnavailableError } from "@/chat/credentials/broker"; -import { fingerprintLeaseAuthorization } from "@/chat/credentials/token-fingerprint"; -import { logInfo } from "@/chat/logging"; import type { PluginAuthorization, PluginGrant, @@ -184,30 +182,6 @@ export function authorizationForSandboxEgressGrant( * domains, and reused only while both the provider lease and sandbox context are * still valid. */ -function leaseCredentialAttributes(input: { - context: SandboxEgressCredentialContext; - grant: PluginGrant; - lease: Pick; - provider: string; -}): Record { - const tokenFingerprint = fingerprintLeaseAuthorization( - input.lease.headerTransforms, - ); - return { - "app.sandbox.egress_id": input.context.egressId, - "app.provider.name": input.provider, - "app.grant.name": input.grant.name, - "app.grant.access": input.grant.access, - ...(input.grant.reason ? { "app.grant.reason": input.grant.reason } : {}), - ...(input.grant.leaseScope - ? { "app.grant.lease_scope": input.grant.leaseScope } - : {}), - ...(tokenFingerprint - ? { "app.credential.token_fingerprint": tokenFingerprint } - : {}), - }; -} - export async function sandboxEgressCredentialLease( provider: string, selection: SandboxEgressGrantSelection, @@ -225,19 +199,10 @@ export async function sandboxEgressCredentialLease( `Cached credential lease for ${provider}/${grant.name} has ${cached.grant.access} access, but ${grant.access} was selected`, ); } - const reused = { + return { ...cached, grant, }; - logInfo("sandbox.egress.credential_lease.reused", { - ...leaseCredentialAttributes({ - context, - grant, - lease: reused, - provider, - }), - }); - return reused; } let lease: { @@ -326,14 +291,6 @@ export async function sandboxEgressCredentialLease( }; assertLeaseTransformsOwnedByProvider(provider, cachedLease); await setSandboxEgressCredentialLease(context, cachedLease); - logInfo("sandbox.egress.credential_lease.stored", { - ...leaseCredentialAttributes({ - context, - grant, - lease: cachedLease, - provider, - }), - }); return cachedLease; } diff --git a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts index 959d2e71d6..9a8fb9c0a7 100644 --- a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts +++ b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts @@ -15,8 +15,9 @@ function expectedFingerprint(token: string): string { describe("token fingerprint helpers", () => { it("hashes tokens to a stable short fingerprint", () => { - const token = "installation-token"; - expect(fingerprintCredentialToken(token)).toBe(expectedFingerprint(token)); + expect(fingerprintCredentialToken("installation-token")).toBe( + expectedFingerprint("installation-token"), + ); }); it("recovers bearer and git smart-http basic tokens", () => { @@ -31,14 +32,11 @@ describe("token fingerprint helpers", () => { ); }); - it("fingerprints the first authorization header on a lease", () => { - const fingerprint = fingerprintLeaseAuthorization([ - { - headers: { - Authorization: "Bearer installation-token", - }, - }, - ]); - expect(fingerprint).toBe(expectedFingerprint("installation-token")); + it("fingerprints lease authorization headers", () => { + expect( + fingerprintLeaseAuthorization([ + { headers: { Authorization: "Bearer installation-token" } }, + ]), + ).toBe(expectedFingerprint("installation-token")); }); });