@vouchington/utils 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,7 +149,15 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
149
149
  returned by identity. Callers provide `credentialHeaders`, an optional `redactUrl` hook (applied
150
150
  after query and fragment stripping, for path-borne credentials), and an opt-in `dropRequestBody`.
151
151
  URL-only and credential-only passes are exported separately, and `composeBeforeSend` accepts
152
- either `ScrubOptions` or a final scrubber function. This package has no product defaults.
152
+ either `ScrubOptions` or a final scrubber function. `scrubEvent` also makes a recursive pass over
153
+ the whole event (`user`, `extra`, `contexts`, `tags`, `message`, exception values, breadcrumb
154
+ messages and data): values under sensitive keys (authorization, cookie, password, secret, token,
155
+ api/access key, email, dsn, credential) are replaced with `[Filtered]` (a plain object keeps its
156
+ keys), and every string has bearer/basic credentials, `token=` style assignments, and email
157
+ addresses redacted. `ScrubOptions` accepts additive `sensitiveKeyPatterns`, `textRedactors`, and
158
+ `maxDepth` (default 12, minimum `MIN_MAX_DEPTH` = 8; deeper containers become `[Filtered]`, cycles become `[Circular]`). Dates,
159
+ Errors, and other class instances are left as-is. `scrubDeep(value, options?)`, `redactText`, and
160
+ `scrubBreadcrumb(breadcrumb, options)` are exported for direct use.
153
161
 
154
162
  - `error-tree`: `formatErrorTree(error, { maxDepth?, maxNested? })` renders an error with its
155
163
  `errors[]` and `cause` chain, marks circular references, and reports how many nested errors the
@@ -8,10 +8,10 @@ export type ModerationInputPart = {
8
8
  url: string;
9
9
  };
10
10
  };
11
- /** The fields of a moderation result this module reads. */
11
+ /** The fields of a moderation result this module reads. A null category counts as not flagged. */
12
12
  export type ModerationResultLike = {
13
13
  flagged: boolean;
14
- categories: Record<string, boolean>;
14
+ categories: Readonly<Record<string, boolean | null>>;
15
15
  };
16
16
  export type MessageSafetyViolation = {
17
17
  code: 'PROMPT_INJECTION';
@@ -7,7 +7,7 @@ export function buildModerationInput(texts, imageUrls = []) {
7
7
  }
8
8
  /** Names of the categories a result flagged. */
9
9
  export function getFlaggedCategories(result) {
10
- return Object.entries(result.categories).flatMap(([category, flagged]) => flagged ? [category] : []);
10
+ return Object.entries(result.categories).flatMap(([category, flagged]) => flagged === true ? [category] : []);
11
11
  }
12
12
  /**
13
13
  * A violation naming every category flagged by any result, else null. Results line up with the
@@ -0,0 +1,26 @@
1
+ export declare const EMAIL_REDACTED = "[Filtered email]";
2
+ export declare const TOKEN_REDACTED = "[Filtered token]";
3
+ export declare const CIRCULAR_VALUE = "[Circular]";
4
+ export declare const DEFAULT_MAX_DEPTH = 12;
5
+ /** Deep enough to keep `event.exception.values[].stacktrace.frames[]` objects intact. */
6
+ export declare const MIN_MAX_DEPTH = 8;
7
+ export interface DeepScrubOptions {
8
+ /** Extra key patterns, added to the defaults, whose values are replaced with the filtered marker. */
9
+ sensitiveKeyPatterns?: readonly RegExp[];
10
+ /** Extra text redactors, run after the defaults, applied to every string in the event. */
11
+ textRedactors?: ReadonlyArray<(text: string) => string>;
12
+ /**
13
+ * Containers nested deeper than this are replaced with the filtered marker. Default 12; at least
14
+ * `MIN_MAX_DEPTH` so event structure (fingerprint, exception frames) is never truncated.
15
+ */
16
+ maxDepth?: number;
17
+ }
18
+ /** Default text redactor: bearer/basic credentials, token assignments, then email addresses. */
19
+ export declare function redactText(text: string): string;
20
+ /**
21
+ * Recursively redact sensitive keys and free-text secrets. Plain objects and arrays are walked;
22
+ * Date, Error, and other class instances are returned as-is. Cycles become a marker, containers
23
+ * beyond `maxDepth` are replaced (fail closed), and unchanged input is returned by identity.
24
+ * Shared subtrees are scrubbed once per depth they are first reached at, not once per path.
25
+ */
26
+ export declare function scrubDeep<T>(value: T, options?: DeepScrubOptions): T;
@@ -0,0 +1,79 @@
1
+ import { redactEmails } from './observability-email.mjs';
2
+ import { SENSITIVE_VALUE, isRecord, transformRecord } from './observability-records.mjs';
3
+ export const EMAIL_REDACTED = '[Filtered email]';
4
+ export const TOKEN_REDACTED = '[Filtered token]';
5
+ export const CIRCULAR_VALUE = '[Circular]';
6
+ export const DEFAULT_MAX_DEPTH = 12;
7
+ /** Deep enough to keep `event.exception.values[].stacktrace.frames[]` objects intact. */
8
+ export const MIN_MAX_DEPTH = 8;
9
+ const DEFAULT_KEY_PATTERN = /authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?key|email|dsn|credential/i;
10
+ const AUTH_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi;
11
+ const TOKEN_ASSIGNMENT_PATTERN = /\b((?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|secret|password|passwd|api[_-]?key)=)(?!\[Filtered token\])[^&\s]+/gi;
12
+ /** Default text redactor: bearer/basic credentials, token assignments, then email addresses. */
13
+ export function redactText(text) {
14
+ return redactEmails(text
15
+ .replace(AUTH_TOKEN_PATTERN, `$1 ${TOKEN_REDACTED}`)
16
+ .replace(TOKEN_ASSIGNMENT_PATTERN, `$1${TOKEN_REDACTED}`), EMAIL_REDACTED);
17
+ }
18
+ /** Stateless copy of a pattern, so `g` and `y` flags cannot make `test` order-dependent. */
19
+ function stateless(pattern) {
20
+ return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
21
+ }
22
+ /** Redact a sensitive value; a plain object keeps its keys with every value filtered. */
23
+ function redactValue(value) {
24
+ return isRecord(value) && isPlain(value)
25
+ ? transformRecord(value, () => SENSITIVE_VALUE)
26
+ : SENSITIVE_VALUE;
27
+ }
28
+ function isPlain(value) {
29
+ const prototype = Object.getPrototypeOf(value);
30
+ return prototype === Object.prototype || prototype === null;
31
+ }
32
+ /**
33
+ * Recursively redact sensitive keys and free-text secrets. Plain objects and arrays are walked;
34
+ * Date, Error, and other class instances are returned as-is. Cycles become a marker, containers
35
+ * beyond `maxDepth` are replaced (fail closed), and unchanged input is returned by identity.
36
+ * Shared subtrees are scrubbed once per depth they are first reached at, not once per path.
37
+ */
38
+ export function scrubDeep(value, options = {}) {
39
+ const keyPatterns = [DEFAULT_KEY_PATTERN, ...(options.sensitiveKeyPatterns ?? []).map(stateless)];
40
+ const redactors = [redactText, ...(options.textRedactors ?? [])];
41
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
42
+ if (!Number.isInteger(maxDepth) || maxDepth < MIN_MAX_DEPTH) {
43
+ throw new RangeError(`maxDepth must be an integer of at least ${MIN_MAX_DEPTH}`);
44
+ }
45
+ const ancestors = new WeakSet();
46
+ // A result computed at a shallower depth had more budget, so it is reusable at deeper depths.
47
+ const done = new WeakMap();
48
+ const isSensitiveKey = (key) => keyPatterns.some((pattern) => pattern.test(key));
49
+ function walkArray(input, depth) {
50
+ const mapped = input.map((child) => walk(child, depth + 1));
51
+ return mapped.some((child, index) => !Object.is(child, input[index])) ? mapped : input;
52
+ }
53
+ function walk(input, depth) {
54
+ if (typeof input === 'string')
55
+ return redactors.reduce((text, redact) => redact(text), input);
56
+ const isArray = Array.isArray(input);
57
+ if (!isArray && !(isRecord(input) && isPlain(input)))
58
+ return input;
59
+ if (ancestors.has(input))
60
+ return CIRCULAR_VALUE;
61
+ if (depth >= maxDepth)
62
+ return SENSITIVE_VALUE;
63
+ const cached = done.get(input);
64
+ if (cached && cached.depth <= depth)
65
+ return cached.result;
66
+ ancestors.add(input);
67
+ try {
68
+ const result = isArray
69
+ ? walkArray(input, depth)
70
+ : transformRecord(input, (key, child) => isSensitiveKey(key) ? redactValue(child) : walk(child, depth + 1));
71
+ done.set(input, { depth, result });
72
+ return result;
73
+ }
74
+ finally {
75
+ ancestors.delete(input);
76
+ }
77
+ }
78
+ return walk(value, 0);
79
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Replace email addresses with `replacement` in linear time. Matching starts from each `@` and
3
+ * expands over local-part and domain characters, which never include `@`, so no character is
4
+ * scanned more than a constant number of times (unlike a backtracking regular expression).
5
+ */
6
+ export declare function redactEmails(text: string, replacement: string): string;
@@ -0,0 +1,51 @@
1
+ const LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
2
+ const DOMAIN_CHAR = /[A-Za-z0-9.-]/;
3
+ const LETTER = /[A-Za-z]/;
4
+ const WORD_CHAR = /\w/;
5
+ /** End of the longest `label.tld` domain in `text[from, end)`, or -1; one forward pass. */
6
+ function domainEnd(text, from, end) {
7
+ let best = -1;
8
+ let letterRunStart = from;
9
+ for (let index = from; index < end; index++) {
10
+ if (!LETTER.test(text[index])) {
11
+ letterRunStart = index + 1;
12
+ continue;
13
+ }
14
+ const after = index + 1;
15
+ if (after - letterRunStart >= 2 &&
16
+ letterRunStart - 1 > from &&
17
+ text[letterRunStart - 1] === '.' &&
18
+ !WORD_CHAR.test(text[after] ?? '')) {
19
+ best = after;
20
+ }
21
+ }
22
+ return best;
23
+ }
24
+ /**
25
+ * Replace email addresses with `replacement` in linear time. Matching starts from each `@` and
26
+ * expands over local-part and domain characters, which never include `@`, so no character is
27
+ * scanned more than a constant number of times (unlike a backtracking regular expression).
28
+ */
29
+ export function redactEmails(text, replacement) {
30
+ let output = '';
31
+ let copied = 0;
32
+ let at = text.indexOf('@');
33
+ while (at !== -1) {
34
+ let start = at;
35
+ while (start > copied && LOCAL_CHAR.test(text[start - 1]))
36
+ start--;
37
+ let end = at + 1;
38
+ while (end < text.length && DOMAIN_CHAR.test(text[end]))
39
+ end++;
40
+ const domain = domainEnd(text, at + 1, end);
41
+ if (start < at && domain !== -1) {
42
+ output += text.slice(copied, start) + replacement;
43
+ copied = domain;
44
+ at = text.indexOf('@', domain);
45
+ }
46
+ else {
47
+ at = text.indexOf('@', at + 1);
48
+ }
49
+ }
50
+ return output + text.slice(copied);
51
+ }
@@ -1,5 +1,6 @@
1
+ import { type DeepScrubOptions } from './observability-deep.mts';
1
2
  import { type UrlScrubOptions } from './observability-url.mts';
2
- export interface ScrubOptions extends UrlScrubOptions {
3
+ export interface ScrubOptions extends UrlScrubOptions, DeepScrubOptions {
3
4
  /** Header names (case-insensitive) whose values are replaced with the filtered marker. */
4
5
  credentialHeaders: readonly string[];
5
6
  /** Also drop `request.data` and the `http.request.body.data` attribute. Defaults to false. */
@@ -20,7 +21,14 @@ export declare function scrubRequest<T extends object>(request: T | undefined, o
20
21
  export declare function scrubBreadcrumbs<T extends {
21
22
  data?: Record<string, unknown>;
22
23
  }>(breadcrumbs: T[] | undefined, options: ScrubOptions): T[] | undefined;
23
- /** Scrub an event's request and breadcrumbs; the input event is returned when nothing changed. */
24
+ /** Scrub one breadcrumb: its `data` like span attributes, then every key and string recursively. */
25
+ export declare function scrubBreadcrumb<T extends {
26
+ data?: Record<string, unknown>;
27
+ }>(breadcrumb: T, options: ScrubOptions): T;
28
+ /**
29
+ * Scrub an event: request and breadcrumb data first, then a recursive pass over the whole event
30
+ * (`user`, `extra`, `contexts`, `tags`, messages, exception values). Unchanged input is returned.
31
+ */
24
32
  export declare function scrubEvent<T extends ObservableEvent>(event: T, options: ScrubOptions): T;
25
33
  type BeforeSendResult<T> = T | null | PromiseLike<T | null>;
26
34
  /**
@@ -1,4 +1,5 @@
1
1
  import { dropRequestBody, dropSpanRequestBody } from './observability-body.mjs';
2
+ import { scrubDeep } from './observability-deep.mjs';
2
3
  import { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mjs';
3
4
  import { scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, } from './observability-url.mjs';
4
5
  export function scrubHeaders(headers, credentialHeaders, redactUrl) {
@@ -27,17 +28,26 @@ export function scrubBreadcrumbs(breadcrumbs, options) {
27
28
  ? result
28
29
  : breadcrumbs;
29
30
  }
30
- /** Scrub an event's request and breadcrumbs; the input event is returned when nothing changed. */
31
+ /** Scrub one breadcrumb: its `data` like span attributes, then every key and string recursively. */
32
+ export function scrubBreadcrumb(breadcrumb, options) {
33
+ const [scrubbed] = scrubBreadcrumbs([breadcrumb], options);
34
+ return scrubDeep(scrubbed, options);
35
+ }
36
+ /**
37
+ * Scrub an event: request and breadcrumb data first, then a recursive pass over the whole event
38
+ * (`user`, `extra`, `contexts`, `tags`, messages, exception values). Unchanged input is returned.
39
+ */
31
40
  export function scrubEvent(event, options) {
32
41
  const request = scrubRequest(event.request, options);
33
42
  const breadcrumbs = scrubBreadcrumbs(event.breadcrumbs, options);
34
- if (request === event.request && breadcrumbs === event.breadcrumbs)
35
- return event;
36
- return {
37
- ...event,
38
- ...(request === event.request ? {} : { request }),
39
- ...(breadcrumbs === event.breadcrumbs ? {} : { breadcrumbs }),
40
- };
43
+ const base = request === event.request && breadcrumbs === event.breadcrumbs
44
+ ? event
45
+ : {
46
+ ...event,
47
+ ...(request === event.request ? {} : { request }),
48
+ ...(breadcrumbs === event.breadcrumbs ? {} : { breadcrumbs }),
49
+ };
50
+ return scrubDeep(base, options);
41
51
  }
42
52
  function isPromiseLike(value) {
43
53
  if (value === null || (typeof value !== 'object' && typeof value !== 'function'))
@@ -1,6 +1,7 @@
1
1
  export { SENSITIVE_VALUE } from './observability-records.mts';
2
2
  export { createSpikeWindowTracker, DEFAULT_MAX_SPIKE_FINGERPRINTS, type SpikeWindowTracker, } from './spike-window.mts';
3
- export { composeBeforeSend, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, type ObservableEvent, type ScrubOptions, } from './observability-event.mts';
3
+ export { CIRCULAR_VALUE, DEFAULT_MAX_DEPTH, EMAIL_REDACTED, MIN_MAX_DEPTH, TOKEN_REDACTED, redactText, scrubDeep, type DeepScrubOptions, } from './observability-deep.mts';
4
+ export { composeBeforeSend, scrubBreadcrumb, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, type ObservableEvent, type ScrubOptions, } from './observability-event.mts';
4
5
  export { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mts';
5
6
  export { SCRUBBED_URL_ATTRIBUTE_KEYS, URL_ATTRIBUTE_KEYS, URL_COMPONENT_ATTRIBUTE_KEYS, scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, scrubUrlValue, stripUrlQueryAndFragment, type UrlScrubOptions, } from './observability-url.mts';
6
7
  export { REQUEST_BODY_SPAN_ATTRIBUTE, dropRequestBody, dropSpanRequestBody, } from './observability-body.mts';
@@ -1,6 +1,7 @@
1
1
  export { SENSITIVE_VALUE } from './observability-records.mjs';
2
2
  export { createSpikeWindowTracker, DEFAULT_MAX_SPIKE_FINGERPRINTS, } from './spike-window.mjs';
3
- export { composeBeforeSend, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, } from './observability-event.mjs';
3
+ export { CIRCULAR_VALUE, DEFAULT_MAX_DEPTH, EMAIL_REDACTED, MIN_MAX_DEPTH, TOKEN_REDACTED, redactText, scrubDeep, } from './observability-deep.mjs';
4
+ export { composeBeforeSend, scrubBreadcrumb, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, } from './observability-event.mjs';
4
5
  export { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mjs';
5
6
  export { SCRUBBED_URL_ATTRIBUTE_KEYS, URL_ATTRIBUTE_KEYS, URL_COMPONENT_ATTRIBUTE_KEYS, scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, scrubUrlValue, stripUrlQueryAndFragment, } from './observability-url.mjs';
6
7
  export { REQUEST_BODY_SPAN_ATTRIBUTE, dropRequestBody, dropSpanRequestBody, } from './observability-body.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/utils",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Dependency-free runtime utilities for Vouchington-style applications.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
6
6
  "bugs": {