-
Notifications
You must be signed in to change notification settings - Fork 43
fix: strip access tokens from exported span attributes #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d08b38a
fix: strip access tokens from exported span attributes
Valiunia 7468ece
fix: keep token prefix and account name in redacted spans
Valiunia 96ceb53
test: use a neutral account name in redaction fixtures
Valiunia 47b3970
docs: correct an unverified claim in the redaction comment
Valiunia f34a9da
review: revert versionUtils-cjs build artifacts and drop stale StyleC…
Valiunia 6fc1def
Merge branch 'main' into fix/redact-tokens-from-span-attributes
Valiunia d27067a
Merge branch 'main' into fix/redact-tokens-from-span-attributes
Valiunia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // Copyright (c) Mapbox, Inc. | ||
| // Licensed under the MIT License. | ||
|
|
||
| /** Token prefixes Mapbox uses: public, secret, and temporary. */ | ||
| const TOKEN_PREFIXES = new Set(['pk', 'sk', 'tk']); | ||
|
|
||
| /** | ||
| * Character allowlist for an account name lifted out of a token payload. This | ||
| * bounds what a token payload can put into a span attribute or log line — the | ||
| * value comes from decoding untrusted JWT payload data, so the allowlist is a | ||
| * safety gate against log/span injection and oversized values, not a | ||
| * statement of Mapbox's actual account naming rules. A name outside it is not | ||
| * partially disclosed — it falls back to `***`. | ||
| */ | ||
| const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; | ||
|
|
||
| /** | ||
| * Replace a token value with a placeholder that keeps the parts safe to publish. | ||
| * | ||
| * Mapbox tokens are JWTs (`<prefix>.<payload>.<signature>`) whose payload carries | ||
| * the account name under `u`, so `pk.eyJ1IjoiZXhhbXBsZSJ9.signature` becomes | ||
| * `pk.example.redacted`. Keeping the prefix and account name makes traces and logs | ||
| * readable — you can still tell a secret token from a public one, and whose account | ||
| * a request billed to — while the signature, which is the part that authenticates, | ||
| * never leaves the process. | ||
| * | ||
| * Anything that does not parse cleanly as such a token falls back to `***`, so an | ||
| * unrecognized shape is never partially disclosed on the assumption it was harmless. | ||
| */ | ||
| function maskTokenValue(token: string): string { | ||
| const parts = token.split('.'); | ||
| if (parts.length !== 3) { | ||
| return '***'; | ||
| } | ||
|
|
||
| const [prefix, payload] = parts; | ||
| if (!TOKEN_PREFIXES.has(prefix)) { | ||
| return '***'; | ||
| } | ||
|
|
||
| try { | ||
| const decoded = JSON.parse( | ||
| Buffer.from(payload, 'base64').toString('utf-8') | ||
| ) as { u?: unknown }; | ||
|
|
||
| if ( | ||
| typeof decoded.u !== 'string' || | ||
| !ACCOUNT_NAME_PATTERN.test(decoded.u) | ||
| ) { | ||
| return '***'; | ||
| } | ||
|
|
||
| return `${prefix}.${decoded.u}.redacted`; | ||
| } catch { | ||
| return '***'; | ||
| } | ||
| } | ||
|
|
||
| /** Remove access_token query parameter values from strings before logging or returning to callers. */ | ||
| export function redactToken(s: string): string { | ||
| return s.replace( | ||
| /access_token=([^&\s#"']+)/g, | ||
| (_match, token: string) => `access_token=${maskTokenValue(token)}` | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| // Copyright (c) Mapbox, Inc. | ||
| // Licensed under the MIT License. | ||
|
|
||
| import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; | ||
| import type { ExportResult } from '@opentelemetry/core'; | ||
| import type { Attributes } from '@opentelemetry/api'; | ||
| import { redactToken } from './redactToken.js'; | ||
|
|
||
| /** | ||
| * Rewrites every string attribute value through `redactToken`, returning the | ||
| * original object when nothing changed so unaffected spans pass through as-is. | ||
| */ | ||
| function redactAttributes(attributes: Attributes): Attributes { | ||
| let redacted: Attributes | undefined; | ||
|
|
||
| for (const [key, value] of Object.entries(attributes)) { | ||
| if (typeof value !== 'string') { | ||
| continue; | ||
| } | ||
| const clean = redactToken(value); | ||
| if (clean !== value) { | ||
| redacted ??= { ...attributes }; | ||
| redacted[key] = clean; | ||
| } | ||
| } | ||
|
|
||
| return redacted ?? attributes; | ||
| } | ||
|
|
||
| /** | ||
| * Collect every property name reachable on a span, including accessors defined | ||
| * on its prototype chain. Copying by key list rather than a hardcoded field list | ||
| * keeps this working across OpenTelemetry SDK versions, which have moved fields | ||
| * (e.g. `parentSpanId` to `parentSpanContext`) between releases. | ||
| */ | ||
| function collectKeys(span: ReadableSpan): Set<string> { | ||
| const keys = new Set<string>(); | ||
|
|
||
| for ( | ||
| let current: object | null = span; | ||
| current && current !== Object.prototype; | ||
| current = Object.getPrototypeOf(current) | ||
| ) { | ||
| for (const key of Object.getOwnPropertyNames(current)) { | ||
| if (key !== 'constructor') { | ||
| keys.add(key); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return keys; | ||
| } | ||
|
|
||
| /** | ||
| * Return a copy of `span` with redacted attributes, or the span itself when no | ||
| * attribute needed redaction. The copy is a plain object rather than a mutation | ||
| * of the original, so the SDK's own span state is left untouched. | ||
| */ | ||
| function redactSpan(span: ReadableSpan): ReadableSpan { | ||
| const attributes = redactAttributes(span.attributes); | ||
| const events = span.events.map((event) => | ||
| event.attributes | ||
| ? { ...event, attributes: redactAttributes(event.attributes) } | ||
| : event | ||
| ); | ||
|
|
||
| const attributesChanged = attributes !== span.attributes; | ||
| const eventsChanged = events.some( | ||
| (event, index) => event !== span.events[index] | ||
| ); | ||
|
|
||
| if (!attributesChanged && !eventsChanged) { | ||
| return span; | ||
| } | ||
|
|
||
| const copy: Record<string, unknown> = {}; | ||
| for (const key of collectKeys(span)) { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic property copy across unknown SDK span shapes | ||
| const value = (span as any)[key]; | ||
| copy[key] = typeof value === 'function' ? value.bind(span) : value; | ||
| } | ||
| copy.attributes = attributes; | ||
| copy.events = events; | ||
|
|
||
| return copy as unknown as ReadableSpan; | ||
| } | ||
|
|
||
| /** | ||
| * Wraps a SpanExporter and strips access token signatures from span attributes | ||
| * before they leave the process, leaving behind the prefix and account name | ||
| * (`access_token=pk.some-account.redacted`). | ||
| * | ||
| * Auto-instrumentation of `fetch`/`undici` records the full request URL on client | ||
| * spans (`url.full`, `url.query`), and Mapbox APIs take the access token as a | ||
| * query parameter. Without this wrapper, an operator who configures an OTLP | ||
| * endpoint gets those tokens copied verbatim into their telemetry backend. | ||
| * | ||
| * Redaction happens at the exporter rather than in an instrumentation | ||
| * `requestHook` so it covers every attribute on every span, including attribute | ||
| * names introduced by future semantic-convention or instrumentation changes. | ||
| */ | ||
| export class RedactingSpanExporter implements SpanExporter { | ||
| constructor(private readonly delegate: SpanExporter) {} | ||
|
|
||
| export( | ||
| spans: ReadableSpan[], | ||
| resultCallback: (result: ExportResult) => void | ||
| ): void { | ||
| this.delegate.export(spans.map(redactSpan), resultCallback); | ||
| } | ||
|
|
||
| shutdown(): Promise<void> { | ||
| return this.delegate.shutdown(); | ||
| } | ||
|
|
||
| forceFlush(): Promise<void> { | ||
| return this.delegate.forceFlush?.() ?? Promise.resolve(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Copyright (c) Mapbox, Inc. | ||
| // Licensed under the MIT License. | ||
|
|
||
| import { describe, it, expect } from 'vitest'; | ||
| import { redactToken } from '../../src/utils/redactToken.js'; | ||
|
|
||
| describe('redactToken', () => { | ||
| const PUBLIC_TOKEN = 'pk.eyJ1IjoiZXhhbXBsZS1hY2NvdW50In0.signaturevalue'; | ||
| const SECRET_TOKEN = 'sk.eyJ1IjoidGVzdHVzZXIifQ.signaturevalue'; | ||
| const TEMP_TOKEN = 'tk.eyJ1IjoidGVtcC11c2VyXzEifQ.signaturevalue'; | ||
|
|
||
| it('keeps the prefix and account name, dropping the signature', () => { | ||
| expect(redactToken(`access_token=${PUBLIC_TOKEN}`)).toBe( | ||
| 'access_token=pk.example-account.redacted' | ||
| ); | ||
| expect(redactToken(`access_token=${SECRET_TOKEN}`)).toBe( | ||
| 'access_token=sk.testuser.redacted' | ||
| ); | ||
| expect(redactToken(`access_token=${TEMP_TOKEN}`)).toBe( | ||
| 'access_token=tk.temp-user_1.redacted' | ||
| ); | ||
| }); | ||
|
|
||
| it('never emits the token signature', () => { | ||
| expect( | ||
| redactToken( | ||
| `https://api.mapbox.com/directions/v5/mapbox/driving/0,0;1,1?access_token=${PUBLIC_TOKEN}&geometries=geojson` | ||
| ) | ||
| ).toBe( | ||
| 'https://api.mapbox.com/directions/v5/mapbox/driving/0,0;1,1?access_token=pk.example-account.redacted&geometries=geojson' | ||
| ); | ||
| }); | ||
|
|
||
| it('redacts every occurrence in a string', () => { | ||
| expect( | ||
| redactToken( | ||
| `first access_token=${PUBLIC_TOKEN} second access_token=${SECRET_TOKEN}` | ||
| ) | ||
| ).toBe( | ||
| 'first access_token=pk.example-account.redacted second access_token=sk.testuser.redacted' | ||
| ); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['an unrecognized prefix', 'zz.eyJ1IjoidGVzdHVzZXIifQ.signaturevalue'], | ||
| ['too few segments', 'pk.eyJ1IjoidGVzdHVzZXIifQ'], | ||
| ['a payload that is not base64 JSON', 'pk.@@@notbase64@@@.signaturevalue'], | ||
| [ | ||
| 'a payload with no account name', | ||
| 'pk.eyJhIjoibm9hY2NvdW50In0.signaturevalue' | ||
| ], | ||
| ['an opaque value', 'some-legacy-opaque-token'] | ||
| ])('falls back to *** for %s', (_case, token) => { | ||
| expect(redactToken(`access_token=${token}`)).toBe('access_token=***'); | ||
| }); | ||
|
|
||
| it('leaves strings without a token untouched', () => { | ||
| expect( | ||
| redactToken('https://api.mapbox.com/isochrone/v1/mapbox/driving') | ||
| ).toBe('https://api.mapbox.com/isochrone/v1/mapbox/driving'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is stale in this repo —
StyleComparisonTooldoesn't exist in mcp-server (only in mcp-devkit-server, where this comment originated). Suggested wording that keeps the substance without the dangling cross-repo reference: