-
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
base: main
Are you sure you want to change the base?
Changes from all commits
d08b38a
7468ece
96ceb53
47b3970
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||||||||||||||||||||||||||||
| // 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, matching the | ||||||||||||||||||||||||||||||
| * username validation in StyleComparisonTool. This bounds what a token payload can put | ||||||||||||||||||||||||||||||
| * into a span attribute or log line; it is not a statement of Mapbox's account naming | ||||||||||||||||||||||||||||||
| * rules. A name outside it is not partially disclosed — it falls back to `***`. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
|
Comment on lines
+7
to
+12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is stale in this repo —
Suggested change
|
||||||||||||||||||||||||||||||
| 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)}` | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| 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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,48 +1,58 @@ | ||
| "use strict"; | ||
| 'use strict'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file shouldn't be included |
||
| // Copyright (c) Mapbox, Inc. | ||
| // Licensed under the MIT License. | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| var __importDefault = | ||
| (this && this.__importDefault) || | ||
| function (mod) { | ||
| return mod && mod.__esModule ? mod : { default: mod }; | ||
| }; | ||
| Object.defineProperty(exports, '__esModule', { value: true }); | ||
| exports.getVersionInfo = getVersionInfo; | ||
| const node_fs_1 = require("node:fs"); | ||
| const node_path_1 = __importDefault(require("node:path")); | ||
| const node_fs_1 = require('node:fs'); | ||
| const node_path_1 = __importDefault(require('node:path')); | ||
| function getVersionInfo() { | ||
| const name = 'Mapbox MCP server'; | ||
| const name = 'Mapbox MCP server'; | ||
| try { | ||
| const dirname = __dirname; | ||
| // Try to read from version.json first (for build artifacts) | ||
| const versionJsonPath = node_path_1.default.resolve( | ||
| dirname, | ||
| '..', | ||
| 'version.json' | ||
| ); | ||
| try { | ||
| const dirname = __dirname; | ||
| // Try to read from version.json first (for build artifacts) | ||
| const versionJsonPath = node_path_1.default.resolve(dirname, '..', 'version.json'); | ||
| try { | ||
| const versionData = (0, node_fs_1.readFileSync)(versionJsonPath, 'utf-8'); | ||
| const info = JSON.parse(versionData); | ||
| info['name'] = name; | ||
| return info; | ||
| } | ||
| catch { | ||
| // Fall back to package.json | ||
| const packageJsonPath = node_path_1.default.resolve(dirname, '..', '..', '..', 'package.json'); | ||
| const packageData = (0, node_fs_1.readFileSync)(packageJsonPath, 'utf-8'); | ||
| const packageInfo = JSON.parse(packageData); | ||
| return { | ||
| name: name, | ||
| version: packageInfo.version || '0.0.0', | ||
| sha: 'unknown', | ||
| tag: 'unknown', | ||
| branch: 'unknown' | ||
| }; | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.warn(`Failed to read version info: ${error}`); | ||
| return { | ||
| name: name, | ||
| version: '0.0.0', | ||
| sha: 'unknown', | ||
| tag: 'unknown', | ||
| branch: 'unknown' | ||
| }; | ||
| const versionData = (0, node_fs_1.readFileSync)(versionJsonPath, 'utf-8'); | ||
| const info = JSON.parse(versionData); | ||
| info['name'] = name; | ||
| return info; | ||
| } catch { | ||
| // Fall back to package.json | ||
| const packageJsonPath = node_path_1.default.resolve( | ||
| dirname, | ||
| '..', | ||
| '..', | ||
| '..', | ||
| 'package.json' | ||
| ); | ||
| const packageData = (0, node_fs_1.readFileSync)(packageJsonPath, 'utf-8'); | ||
| const packageInfo = JSON.parse(packageData); | ||
| return { | ||
| name: name, | ||
| version: packageInfo.version || '0.0.0', | ||
| sha: 'unknown', | ||
| tag: 'unknown', | ||
| branch: 'unknown' | ||
| }; | ||
| } | ||
| } catch (error) { | ||
| console.warn(`Failed to read version info: ${error}`); | ||
| return { | ||
| name: name, | ||
| version: '0.0.0', | ||
| sha: 'unknown', | ||
| tag: 'unknown', | ||
| branch: 'unknown' | ||
| }; | ||
| } | ||
| } | ||
| //# sourceMappingURL=versionUtils-cjs.cjs.map | ||
| //# sourceMappingURL=versionUtils-cjs.cjs.map | ||
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.