@onesi/genesis 0.2.2 → 0.2.4

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/dist/redact.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * Redaction for service error bodies and messages before the CLI or MCP prints them. The design is
3
- * conservative: when a key or value might be a secret, it is replaced with `[redacted]`. Field names are
4
- * matched after normalisation (camelCase split, `-`/`.`/space to `_`, lower-cased), so `x-api-key`,
5
- * `privateKey`, `Set-Cookie` and `client_secret` are all the same kind of name.
6
- */
7
1
  export declare const REDACTED = "[redacted]";
8
2
  /** Bounds of the printed body: serialized bytes, per string, per array/object, nesting and the oversize preview. */
9
3
  export declare const ERROR_BODY_LIMITS: {
package/dist/redact.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * matched after normalisation (camelCase split, `-`/`.`/space to `_`, lower-cased), so `x-api-key`,
5
5
  * `privateKey`, `Set-Cookie` and `client_secret` are all the same kind of name.
6
6
  */
7
+ import { redactUserinfo } from './urls.js';
7
8
  export const REDACTED = '[redacted]';
8
9
  /** Bounds of the printed body: serialized bytes, per string, per array/object, nesting and the oversize preview. */
9
10
  export const ERROR_BODY_LIMITS = { bytes: 16384, string: 2048, items: 100, depth: 8, preview: 4096 };
@@ -174,8 +175,8 @@ const SECRET_TEXT = [
174
175
  [/(?<![A-Za-z0-9])ek_(?:live|test)_[A-Za-z0-9_-]{8,}/g, REDACTED],
175
176
  // PEM private keys (whole block, or to the end when cut).
176
177
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
177
- // URL userinfo: scheme://user:pass@host.
178
- [/\b([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/?#@]+@/gi, `$1${REDACTED}@`],
178
+ // URL userinfo: scheme://user:pass@host, and the scheme-less, protocol-relative, percent-encoded and JSON-escaped forms.
179
+ [/^[\s\S]*$/, (t) => redactUserinfo(t, REDACTED)],
179
180
  // An Authorization / Proxy-Authorization header in text, any scheme.
180
181
  [/\b((?:proxy[-_ ]?)?authorization["']?\s*[:=]\s*["']?)(?!\[redacted\])(?:[A-Za-z][A-Za-z0-9_-]*\s+)?[^\s"',;]{8,}/gi, `$1${REDACTED}`],
181
182
  // Auth schemes followed by a credential.
package/dist/urls.d.ts CHANGED
@@ -4,3 +4,12 @@
4
4
  * Credentials ride on every request, so a plain-http endpoint off the local machine is refused.
5
5
  */
6
6
  export declare function requireSecureUrl(value: string, what: string): string;
7
+ /**
8
+ * Replace the userinfo of every URL in `text` with `mask` (default `***`): `https://user:pass@host` becomes
9
+ * `https://***@host` and `user:pass@host` becomes `***@host`, including scheme-less, protocol-relative,
10
+ * percent-encoded (also double-encoded) and JSON-escaped forms, and passwords holding an unencoded `@`.
11
+ * Linear time on any input. Use on error and status text only, never on data the user asked to see.
12
+ */
13
+ export declare function redactUserinfo(text: string, mask?: string): string;
14
+ /** `url` without its userinfo (`https://u:p@host/x` becomes `https://host/x`); text that is not an absolute URL passes through redactUserinfo. */
15
+ export declare function stripUserinfo(url: string): string;
package/dist/urls.js CHANGED
@@ -29,3 +29,64 @@ export function requireSecureUrl(value, what) {
29
29
  return value;
30
30
  throw new Error(`${what} must use https (http is allowed only on loopback hosts)`);
31
31
  }
32
+ // The scheme before the slashes is left as it is, so it is never matched. Every quantifier is bounded (userinfo 512, bare user 64 / password 128) and none is nested or overlaps
33
+ // another, so one start position costs O(1). On top of that, only the stretches within WINDOW characters before an
34
+ // `@` (or `%40`) are scanned at all: a userinfo cannot end anywhere else. Redaction is therefore linear in the text
35
+ // length times a constant, and text with no `@`, fullwidth `@` or `%40` is returned as it is.
36
+ // scheme://userinfo@, //userinfo@, https:/\userinfo@ and JSON-escaped :\/\/userinfo@ (greedy to the last @ before the path).
37
+ const USERINFO = /([/\\]{2}|\\\/\\\/)[^\s/?#\\"'<>]{0,512}[@@]/gi;
38
+ // The same, percent-encoded (also double-encoded): https%3A%2F%2Fuser%3Apass%40host, https%253A%252F%252Fuser%2540host.
39
+ const ENCODED_USERINFO = /(%(?:25){0,2}2F%(?:25){0,2}2F)(?:(?!%(?:25){0,2}2F)[^\s/?#&"'<>]){0,512}%(?:25){0,2}40/gi;
40
+ // Scheme-less user:pass@host (a colon is required, so plain addresses like user@host are left alone).
41
+ const BARE_USERINFO = /(^|[\s"'(<=,;[{])[^\s/?#@@:"'<>\\]{1,64}:[^\s/?#"'<>\\]{0,128}[@@](?=[\w[])/g;
42
+ /** Every place a userinfo can end: `@`, fullwidth `@`, or an encoded `%40` / `%2540`. */
43
+ const AT_HITS = /[@@]|%(?:25){0,2}40/gi;
44
+ /** The longest URL-form match (slashes and userinfo, encoded) is under 640 characters; the bare form is under 200. */
45
+ const URL_WINDOW = 700;
46
+ const BARE_WINDOW = 200;
47
+ /** Apply `fn` to the stretches of `text` within `window` characters before an `@`-like hit, and copy the rest as it is. */
48
+ function overHits(text, window, fn) {
49
+ AT_HITS.lastIndex = 0;
50
+ let hit = AT_HITS.exec(text);
51
+ if (!hit)
52
+ return text;
53
+ let out = '';
54
+ let copied = 0;
55
+ let start = Math.max(0, hit.index - window);
56
+ let end = hit.index + hit[0].length + 1;
57
+ const flush = () => {
58
+ // A sentinel before a window that does not start the text keeps `^` (start of text) from matching mid-text.
59
+ const piece = start > 0 ? fn(`x${text.slice(start, end)}`).slice(1) : fn(text.slice(start, end));
60
+ out += text.slice(copied, start) + piece;
61
+ copied = Math.min(end, text.length);
62
+ };
63
+ for (hit = AT_HITS.exec(text); hit; hit = AT_HITS.exec(text)) {
64
+ const s = Math.max(0, hit.index - window);
65
+ if (s <= end) {
66
+ end = hit.index + hit[0].length + 1;
67
+ continue;
68
+ }
69
+ flush();
70
+ start = s;
71
+ end = hit.index + hit[0].length + 1;
72
+ }
73
+ flush();
74
+ return out + text.slice(copied);
75
+ }
76
+ /**
77
+ * Replace the userinfo of every URL in `text` with `mask` (default `***`): `https://user:pass@host` becomes
78
+ * `https://***@host` and `user:pass@host` becomes `***@host`, including scheme-less, protocol-relative,
79
+ * percent-encoded (also double-encoded) and JSON-escaped forms, and passwords holding an unencoded `@`.
80
+ * Linear time on any input. Use on error and status text only, never on data the user asked to see.
81
+ */
82
+ export function redactUserinfo(text, mask = '***') {
83
+ // Pre-check: no `@`, `@` or `%40` means no userinfo in any form.
84
+ if (!/[@@]|%(?:25){0,2}40/i.test(text))
85
+ return text;
86
+ const urlForms = overHits(text, URL_WINDOW, (t) => t.replace(ENCODED_USERINFO, `$1${mask}%40`).replace(USERINFO, `$1${mask}@`));
87
+ return overHits(urlForms, BARE_WINDOW, (t) => t.replace(BARE_USERINFO, `$1${mask}@`));
88
+ }
89
+ /** `url` without its userinfo (`https://u:p@host/x` becomes `https://host/x`); text that is not an absolute URL passes through redactUserinfo. */
90
+ export function stripUserinfo(url) {
91
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1') : redactUserinfo(url);
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onesi/genesis",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/l1felabs/genesis-api.git",