UNPKG

token-guardian

Version:

A comprehensive solution for protecting and managing API tokens and secrets

823 lines (725 loc) 26.1 kB
import crypto from 'crypto'; import { EventEmitter } from 'events'; import axios from 'axios'; import jwt from 'jsonwebtoken'; /** * Interface representing the structure of canary token alert data */ interface AlertData { /** * Name of the token that was detected */ tokenName: string; /** * ISO timestamp when the alert was generated */ timestamp: string; /** * Method used to detect the canary token */ detectionMethod: string; /** * Information about the source of the detection */ source: { /** * IP address where the token was detected */ ipAddress: string; /** * User agent information */ userAgent: string; /** * ISO timestamp of the detection */ timestamp: string; }; /** * Masked/partial version of the detected token for safe logging */ partialToken: string; } /** * Represents an error that occurred during canary token operations */ interface CanaryError { message: string; stack?: string; name: string; cause?: unknown; } /** * Service for managing canary tokens */ export class CanaryService extends EventEmitter { private enabled: boolean; private canaries: Map<string, string>; private webhookUrl: string | null = null; private alertEndpoints: Map<string, string> = new Map(); /** * Creates a new CanaryService * @param enabled Whether canary tokens are enabled */ constructor(enabled: boolean) { super(); this.enabled = enabled; this.canaries = new Map(); } /** * Configure webhook for canary notifications * @param webhookUrl URL to send notifications to */ public configureWebhook(webhookUrl: string): void { this.webhookUrl = webhookUrl; } /** * Add an alert endpoint for specific tokens * @param tokenName Token to monitor * @param endpointUrl URL to notify on detection */ public addAlertEndpoint(tokenName: string, endpointUrl: string): void { this.alertEndpoints.set(tokenName, endpointUrl); } /** * Embeds a canary marker in a token * @param token The original token * @param tokenName The name/identifier of the token * @returns Token with embedded canary marker */ public embedCanary(token: string, tokenValue: string): string { if (!this.enabled) { return token; } // Generate a unique canary ID for this token const canaryId = this.generateCanaryId(tokenValue); // Store the canary ID for future reference this.canaries.set(canaryId, tokenValue); // Determine how to embed the canary based on token format if (token.includes('.') && token.split('.').length === 3) { // Looks like a JWT, embed in the payload return this.embedInJWT(token, canaryId); } else if (token.length > 20) { // For long tokens, embed as a subtle modification return this.embedInGenericToken(token, canaryId); } else { // For short tokens, we can't safely modify them return token; } } /** * Generates a unique canary ID * @param tokenName The name/identifier of the token * @returns Unique canary ID */ private generateCanaryId(tokenName: string): string { const timestamp = Date.now().toString(); const randomBytes = crypto.randomBytes(4).toString('hex'); return crypto .createHash('sha256') .update(`${tokenName}:${timestamp}:${randomBytes}`) .digest('hex') .substring(0, 8); } /** * Embeds a canary ID in a JWT token * @param token The JWT token * @param canaryId The canary ID to embed * @returns Modified JWT with embedded canary */ private embedInJWT(token: string, canaryId: string): string { try { const [header, payload, signature] = token.split('.'); // Decode the payload const decodedPayload = Buffer.from(payload, 'base64').toString('utf-8'); try { // Try to parse as JSON const payloadObj = JSON.parse(decodedPayload); // Add the canary as a custom claim // Use a name that looks innocuous but unique payloadObj._cid = canaryId; // Encode the modified payload const newPayload = Buffer.from(JSON.stringify(payloadObj)) .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); // Return the modified JWT // Note: This breaks the signature, but since we're just monitoring for leaks, it's acceptable return `${header}.${newPayload}.${signature}`; } catch (error) { // Log error and handle appropriately this.logError('Failed to parse JWT payload', error); // Try alternative embedding method const modifiedPayload = this.embedBase64Marker(payload, canaryId); return `${header}.${modifiedPayload}.${signature}`; } } catch (error) { // Log error and handle appropriately this.logError('Failed to parse JWT for canary detection', error); // Continue to try other detection methods return token; } } /** * Embeds a marker in base64 encoded data * @param base64Data The base64 data to modify * @param marker The marker to embed * @returns Modified base64 data */ private embedBase64Marker(base64Data: string, marker: string): string { // Decode base64 to binary data const binaryData = Buffer.from(base64Data, 'base64'); const dataLength = binaryData.length; // Only modify if we have enough data (at least 32 bytes) if (dataLength >= 32) { // Convert marker to binary const markerBinary = Buffer.from(marker, 'utf8'); // Choose a position that's 1/3 into the data const position = Math.floor(dataLength / 3); // Create a new buffer with the marker embedded // XOR the marker data with existing data to avoid breaking functionality for (let i = 0; i < markerBinary.length && i + position < dataLength; i++) { // Subtle XOR that preserves most of the original value // Only modifies the 2 least significant bits binaryData[position + i] = (binaryData[position + i] & 0xFC) | (markerBinary[i] & 0x03); } // Convert back to base64, ensuring same format as JWT expects return binaryData.toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } return base64Data; } /** * Embeds a canary ID in a generic token * @param token The token * @param canaryId The canary ID to embed * @returns Modified token with embedded canary */ private embedInGenericToken(token: string, canaryId: string): string { // First, determine the token type to choose the best embedding strategy if (/^[A-Za-z0-9+/=]+$/.test(token)) { // Looks like base64, use our binary embedding technique return this.embedBase64Token(token, canaryId); } else if (/^[A-Fa-f0-9]+$/.test(token)) { // Looks like hex, use hex embedding technique return this.embedHexToken(token, canaryId); } else { // Mixed character token, use unicode embedding return this.embedMixedToken(token, canaryId); } } /** * Embeds a canary ID in a base64-encoded token * @param token The base64 token * @param canaryId The canary ID to embed * @returns Modified token with embedded canary */ private embedBase64Token(token: string, canaryId: string): string { try { // Decode token const data = Buffer.from(token, 'base64'); // Only continue if token is long enough if (data.length < 16) {return token;} // Insert the canary ID at 1/4 position using LSB embedding const insertPos = Math.floor(data.length / 4); const canaryData = Buffer.from(canaryId); // Only modify a few bits to maintain token functionality for (let i = 0; i < Math.min(canaryData.length, 8); i++) { if (i + insertPos < data.length) { // Modify only the least significant bit (LSB steganography) data[insertPos + i] = (data[insertPos + i] & 0xFE) | ((canaryData[i] & 0x80) >> 7); } } // Convert back to base64 return data.toString('base64'); } catch (error) { this.logError('Failed to decode base64 token', error); // Fall back to simpler approach const pos = Math.floor(token.length / 4); return token.substring(0, pos) + canaryId.substring(0, 1) + token.substring(pos + 1); } } /** * Embeds a canary ID in a hex token * @param token The hex token * @param canaryId The canary ID to embed * @returns Modified token with embedded canary */ private embedHexToken(token: string, canaryId: string): string { // For hex tokens, we can replace certain characters // Choose position that's 3/4 through the token const pos = Math.floor(token.length * 3 / 4); // Get the canary hex value const canaryHex = crypto.createHash('md5') .update(canaryId) .digest('hex') .substring(0, 4); // Replace 4 characters at the chosen position // But maintain case consistency with the original token let modifiedToken = token.substring(0, pos); for (let i = 0; i < 4; i++) { if (pos + i < token.length) { const originalChar = token.charAt(pos + i); const canaryChar = canaryHex.charAt(i); // Maintain the case of the original character if (originalChar >= 'A' && originalChar <= 'F') { modifiedToken += canaryChar.toUpperCase(); } else { modifiedToken += canaryChar.toLowerCase(); } } } // Add the rest of the token modifiedToken += token.substring(pos + 4); return modifiedToken; } /** * Embeds a canary ID in a mixed-character token * @param token The mixed-character token * @param canaryId The canary ID to embed * @returns Modified token with embedded canary */ private embedMixedToken(token: string, canaryId: string): string { // For mixed character tokens, use a zero-width unicode character approach // This is invisible to most systems but can be detected // Choose position at 2/3 through the token const pos = Math.floor(token.length * 2 / 3); // We'll use zero-width characters to encode a simplified version of the canaryId // Convert canaryId to a binary representation using zero-width characters let binaryCanary = ''; for (let i = 0; i < Math.min(canaryId.length, 4); i++) { const charCode = canaryId.charCodeAt(i); // Use zero-width space for 0 and zero-width non-joiner for 1 // These are invisible in most contexts for (let bit = 0; bit < 8; bit++) { binaryCanary += ((charCode >> bit) & 1) ? '\u200C' : '\u200B'; } } // Insert the zero-width encoded canary ID return token.substring(0, pos) + binaryCanary + token.substring(pos); } /** * Checks if a token contains a canary marker * @param token The token to check * @returns The token name if a canary was found, null otherwise */ public detectCanary(token: string): string | null { if (!this.enabled) { return null; } // Check if it's a JWT if (token.includes('.') && token.split('.').length === 3) { try { const decoded = jwt.decode(token, { complete: true }); if (!decoded || typeof decoded !== 'object') { throw new Error('Invalid JWT format'); } try { const payload = decoded.payload; if (typeof payload === 'object' && payload !== null) { const possibleCanary = this.extractJWTCanary(payload); if (possibleCanary && this.canaries.has(possibleCanary)) { const tokenName = this.canaries.get(possibleCanary) || null; if (tokenName) { this.triggerAlert(tokenName, token, 'jwt_standard'); } return tokenName; } } } catch (error) { this.logError('Failed to extract JWT canary from payload', error); // Try binary analysis as fallback const possibleCanary = this.extractBase64Marker(token); if (possibleCanary && this.canaries.has(possibleCanary)) { const tokenName = this.canaries.get(possibleCanary) || null; if (tokenName) { this.triggerAlert(tokenName, token, 'jwt_binary'); } return tokenName; } } } catch (error) { this.logError('Failed to decode JWT for canary detection', error); // Continue to try other detection methods } } else if (/^[A-Za-z0-9+/=]+$/.test(token)) { // Try base64 token detection try { const possibleCanary = this.extractBase64TokenCanary(token); if (possibleCanary && this.canaries.has(possibleCanary)) { const tokenName = this.canaries.get(possibleCanary) || null; if (tokenName) { this.triggerAlert(tokenName, token, 'base64'); } return tokenName; } } catch (error) { this.logError('Failed to extract base64 token canary', error); // Continue to try other detection methods } } else if (/^[A-Fa-f0-9]+$/.test(token)) { // Try hex token detection try { const possibleCanary = this.extractHexTokenCanary(token); if (possibleCanary && this.canaries.has(possibleCanary)) { const tokenName = this.canaries.get(possibleCanary) || null; if (tokenName) { this.triggerAlert(tokenName, token, 'hex'); } return tokenName; } } catch (error) { this.logError('Failed to extract hex token canary', error); // Continue to try other detection methods } } else { // Try mixed character token detection (zero-width) try { const possibleCanary = this.extractMixedTokenCanary(token); if (possibleCanary && this.canaries.has(possibleCanary)) { const tokenName = this.canaries.get(possibleCanary) || null; if (tokenName) { this.triggerAlert(tokenName, token, 'mixed'); } return tokenName; } } catch (error) { this.logError('Failed to extract mixed token canary', error); // Continue to try other detection methods } } return null; } /** * Extract canary ID from JWT payload * @param payload The JWT payload object * @returns Extracted canary ID or null */ private extractJWTCanary(payload: Record<string, unknown>): string | null { // Check for direct canary ID in payload if (payload._cid && typeof payload._cid === 'string') { return payload._cid; } // Check for embedded canary in custom claims for (const [, value] of Object.entries(payload)) { if (typeof value === 'string' && this.canaries.has(value)) { return value; } } return null; } /** * Extract canary marker from base64 data * @param base64Data The base64 data to check * @returns Extracted canary ID or null */ private extractBase64Marker(base64Data: string): string | null { try { // Convert to properly padded base64 if needed const paddedBase64 = base64Data.replace(/-/g, '+').replace(/_/g, '/'); const paddedLength = paddedBase64.length; const paddingNeeded = paddedLength % 4 ? 4 - (paddedLength % 4) : 0; const fullyPadded = paddedBase64 + '='.repeat(paddingNeeded); // Decode to binary const binaryData = Buffer.from(fullyPadded, 'base64'); if (binaryData.length < 32) {return null;} // Calculate the position where we embedded the marker (1/3 into the data) const position = Math.floor(binaryData.length / 3); // Extract the marker (up to 8 bytes) const markerLength = Math.min(8, binaryData.length - position); const markerBuffer = Buffer.alloc(markerLength); // Reverse the XOR operation we did during embedding for (let i = 0; i < markerLength; i++) { // Extract the 2 least significant bits markerBuffer[i] = binaryData[position + i] & 0x03; } // Convert to string and return return markerBuffer.toString('utf8').replace(/\0/g, ''); } catch (extractError) { this.logError('Failed to extract base64 marker', extractError); return null; } } /** * Extract canary ID from a base64 token * @param token The base64 token * @returns Extracted canary ID or null */ private extractBase64TokenCanary(token: string): string | null { try { // Decode token const data = Buffer.from(token, 'base64'); // Only continue if token is long enough if (data.length < 16) { return null; } // Extract from 1/4 position const extractPos = Math.floor(data.length / 4); const canaryData = Buffer.alloc(8); // Extract the LSB from each byte for (let i = 0; i < 8; i++) { if (i + extractPos < data.length) { // Extract the least significant bit canaryData[i] = ((data[extractPos + i] & 0x01) << 7); } } // Try to convert to a valid canary ID // We check against all our known canary IDs for partial matches const extractedBits = canaryData.toString('hex').substring(0, 4); // Look for partial matches in our canary IDs for (const [canaryId] of this.canaries.entries()) { const canaryHex = Buffer.from(canaryId).toString('hex').substring(0, 4); if (this.hammingDistance(extractedBits, canaryHex) <= 2) { return canaryId; } } return null; } catch (extractError) { this.logError('Failed to extract base64 token canary', extractError); return null; } } /** * Extract canary ID from a hex token * @param token The hex token * @returns Extracted canary ID or null */ private extractHexTokenCanary(token: string): string | null { try { // Extract from 3/4 position const pos = Math.floor(token.length * 3 / 4); // Extract 4 characters from the token if (pos + 4 > token.length) {return null;} const extractedHex = token.substring(pos, pos + 4).toLowerCase(); // Check against our canary IDs for (const [canaryId] of this.canaries.entries()) { const canaryHex = crypto.createHash('md5') .update(canaryId) .digest('hex') .substring(0, 4) .toLowerCase(); // Allow for some bit errors (up to 2 characters different) if (this.hammingDistance(extractedHex, canaryHex) <= 2) { return canaryId; } } return null; } catch (error) { this.logError('Failed to extract hex token canary', error); return null; } } /** * Extract canary ID from a mixed-character token * @param token The mixed-character token * @returns Extracted canary ID or null */ private extractMixedTokenCanary(token: string): string | null { try { // Look for zero-width characters const zeroWidthRegex = /[\u200B\u200C]+/; const match = token.match(zeroWidthRegex); if (!match) {return null;} // Extract the zero-width characters const zeroWidthSequence = match[0]; // Convert zero-width sequence to binary, then to a string let binaryString = ''; for (let i = 0; i < zeroWidthSequence.length; i++) { binaryString += (zeroWidthSequence[i] === '\u200C') ? '1' : '0'; } // Group into bytes const bytes = []; for (let i = 0; i < binaryString.length; i += 8) { if (i + 8 <= binaryString.length) { const byte = parseInt(binaryString.substring(i, i + 8), 2); bytes.push(byte); } } // Convert to a string const extractedString = Buffer.from(bytes).toString('utf8'); // Check against our canary IDs for partial matches for (const [canaryId] of this.canaries.entries()) { // Check for first few characters matching if (extractedString && canaryId.startsWith(extractedString.substring(0, 2))) { return canaryId; } } return null; } catch (error) { this.logError('Failed to extract mixed token canary', error); return null; } } /** * Calculate Hamming distance between two strings * @param str1 First string * @param str2 Second string * @returns Number of differing characters */ private hammingDistance(str1: string, str2: string): number { let distance = 0; const len = Math.min(str1.length, str2.length); for (let i = 0; i < len; i++) { if (str1[i] !== str2[i]) { distance++; } } // Add difference in length distance += Math.abs(str1.length - str2.length); return distance; } /** * Triggers alert for a detected canary * @param tokenName The name of the detected token * @param token The actual token that was detected * @param detectionMethod How the canary was detected */ private triggerAlert(tokenName: string, token: string, detectionMethod: string): void { const alertData = { tokenName, timestamp: new Date().toISOString(), detectionMethod, source: this.getSourceInfo(), partialToken: this.maskToken(token) }; // Emit event this.emit('canaryDetected', alertData); // Send webhook if configured if (this.webhookUrl) { this.sendWebhookAlert(alertData).catch(error => { console.error('Failed to send canary webhook alert:', error); }); } // Send custom endpoint alert if configured for this token const endpoint = this.alertEndpoints.get(tokenName); if (endpoint) { this.sendEndpointAlert(endpoint, alertData).catch(error => { console.error(`Failed to send canary alert to endpoint ${endpoint}:`, error); }); } } /** * Gets source information for alerts * @returns Object with source info */ private getSourceInfo(): { ipAddress: string; userAgent: string; timestamp: string; } { // Get IP address from environment or request context if available const context = global as unknown as { requestContext?: { ip?: string; connection?: { remoteAddress?: string }; headers?: Record<string, string>; }; }; const ipAddress = process.env.CLIENT_IP || context.requestContext?.ip || context.requestContext?.connection?.remoteAddress || 'unknown'; // Get user agent from environment or request context if available const userAgent = process.env.CLIENT_USER_AGENT || context.requestContext?.headers?.['user-agent'] || 'unknown'; return { ipAddress, userAgent, timestamp: new Date().toISOString() }; } /** * Masks a token for safe logging * @param token The token to mask * @returns Masked token */ private maskToken(token: string): string { if (token.length <= 8) { return '****'; } return token.substring(0, 4) + '****' + token.substring(token.length - 4); } /** * Sends a webhook alert * @param alertData Data to send in the alert */ private async sendWebhookAlert(alertData: AlertData): Promise<void> { if (!this.webhookUrl) {return;} await axios.post(this.webhookUrl, { text: `🚨 SECURITY ALERT: Canary token detected!`, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*SECURITY ALERT: Canary Token Detected*` } }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*Token:* ${alertData.tokenName}` }, { type: 'mrkdwn', text: `*Detected:* ${alertData.timestamp}` }, { type: 'mrkdwn', text: `*Method:* ${alertData.detectionMethod}` } ] }, { type: 'section', text: { type: 'mrkdwn', text: `*Token Fragment:* \`${alertData.partialToken}\`` } } ] }); } /** * Sends an alert to a custom endpoint * @param endpoint Endpoint URL * @param alertData Data to send in the alert */ private async sendEndpointAlert(endpoint: string, alertData: AlertData): Promise<void> { await axios.post(endpoint, alertData); } /** * Registers a callback for canary alerts * @param callback Function to call when a canary is triggered */ public onCanaryTriggered(callback: (tokenName: string, context: AlertData) => void): void { this.on('canaryDetected', (alertData) => { callback(alertData.tokenName, alertData); }); } /** * Logs an error with context * @param message Error context message * @param error The caught error */ private logError(message: string, error: unknown): void { const errorDetails: CanaryError = { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, name: error instanceof Error ? error.name : 'UnknownError', cause: error instanceof Error ? error.cause : error }; // Emit error event for logging/monitoring this.emit('error', { context: message, error: errorDetails, timestamp: new Date().toISOString() }); // Log to console for development/debugging console.error(`${message}:`, errorDetails); } }