/** * Safe-attribute helpers — these are the ONLY attribute keys/values that * Shade internals are allowed to put on spans. The PII-policy guarantees: * * - No plaintext peer addresses (use `peerHash`). * - No plaintext payloads. * - No exact byte counts for stream content (use `bytesBin`). * - Counters/codes are fine. * * Custom-op authors who want to add their own attributes MUST go through * `safeAttribute()`, which rejects keys/values that look like PII. */ import { sha256 } from '@noble/hashes/sha2.js'; // ─── Standard attribute keys ───────────────────────────────── export const ATTR_PEER_HASH = 'shade.peer.hash'; export const ATTR_BYTES_BIN = 'shade.bytes.bin'; export const ATTR_LANE_COUNT = 'shade.lane.count'; export const ATTR_LANE_ID = 'shade.lane.id'; export const ATTR_RETRY_COUNT = 'shade.retry.count'; export const ATTR_ERROR_CODE = 'shade.error.code'; export const ATTR_OP = 'shade.op'; export const ATTR_ROUTE = 'shade.route'; export const ATTR_HTTP_STATUS = 'shade.http.status'; export const ATTR_DIRECTION = 'shade.direction'; export const ATTR_PARTITION = 'shade.partition'; export const ATTR_RESULT = 'shade.result'; /** * Forbidden substrings — if these appear in attribute keys we refuse. * Mirrors the intent of the PII policy doc: never log addresses or * exact-byte sizes. */ const FORBIDDEN_KEY_FRAGMENTS = [ 'peer.address', 'peer_address', 'plaintext', 'payload', 'bytes.exact', 'bytes_exact', ]; /** 8-byte stable pseudonym derived from a peer address. */ export function peerHash(address: string): string { const enc = new TextEncoder().encode(address); const digest = sha256(enc); let hex = ''; for (let i = 0; i < 4; i++) hex += digest[i]!.toString(16).padStart(2, '0'); return hex; } /** Bin a byte count into a coarse PII-safe bucket. */ export function bytesBin(n: number): string { if (!Number.isFinite(n) || n < 0) return 'unknown'; if (n <= 4 * 1024) return '≤4KB'; if (n <= 64 * 1024) return '4–64KB'; if (n <= 1024 * 1024) return '64KB–1MB'; if (n <= 10 * 1024 * 1024) return '1–10MB'; if (n <= 100 * 1024 * 1024) return '10–100MB'; if (n <= 1024 * 1024 * 1024) return '100MB–1GB'; return '≥1GB'; } /** Bin a lane count into a stable bucket. */ export function laneCountBin(n: number): number { if (n <= 1) return 1; if (n <= 4) return 4; if (n <= 16) return 16; if (n <= 64) return 64; return 64; } export class UnsafeAttributeError extends Error { override readonly name = 'UnsafeAttributeError'; constructor(reason: string) { super(reason); } } /** * Validate a user-supplied custom attribute. Returns the key/value pair * untouched on success, or throws `UnsafeAttributeError`. Use this in * any code path that accepts attributes from outside Shade's own * helpers (e.g. plugin-supplied tags). */ export function safeAttribute( key: string, value: string | number | boolean, ): { key: string; value: string | number | boolean } { const lower = key.toLowerCase(); for (const frag of FORBIDDEN_KEY_FRAGMENTS) { if (lower.includes(frag)) { throw new UnsafeAttributeError(`attribute key "${key}" is PII-unsafe (contains "${frag}")`); } } if (typeof value === 'string') { if (value.length > 256) { throw new UnsafeAttributeError( `attribute "${key}" value too long (${value.length}B); cap at 256B to avoid embedded PII`, ); } if (looksLikeAddress(value)) { throw new UnsafeAttributeError( `attribute "${key}" value looks like a peer address — use peerHash() first`, ); } } return { key, value }; } function looksLikeAddress(s: string): boolean { // Heuristic: emails, "device:UUID", and DID-style identifiers all look // like PII to the grep tester. Hashes (8 hex chars) and small ints are // fine. if (/^[a-f0-9]{1,16}$/i.test(s)) return false; if (/@/.test(s)) return true; if (/^device:/i.test(s)) return true; if (/^did:/i.test(s)) return true; return false; }