@revealui/security 0.2.7 → 0.3.0

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
@@ -9,6 +9,7 @@ Security infrastructure for RevealUI. Provides HTTP security headers, CORS manag
9
9
  - You need audit logging for compliance (SOC2, HIPAA)
10
10
  - You need GDPR tooling: consent management, data export, breach reporting, anonymization
11
11
  - You need field-level encryption or key rotation
12
+ - You need to sanitize untrusted input before rendering (terminal streams, shell args, SQL identifiers)
12
13
 
13
14
  If you only need session auth (login/logout/password reset), use `@revealui/auth` instead.
14
15
 
@@ -83,6 +84,14 @@ Dependencies: `@revealui/contracts`, `@revealui/utils`
83
84
  | `InMemoryGDPRStorage` | Class | In-memory GDPR storage for testing |
84
85
  | `InMemoryBreachStorage` | Class | In-memory breach storage for testing |
85
86
 
87
+ ### Input Sanitization
88
+
89
+ | Export | Type | Purpose |
90
+ |--------|------|---------|
91
+ | `sanitizeTerminalLine` | Function | Strip ANSI escape sequences from untrusted terminal output; preserves SGR color codes, removes CSI/OSC/DCS sequences and C0/C1 control chars |
92
+
93
+ Used by RevDev Studio's terminal view to neutralize malicious output (e.g. cursor hijacking, title injection) before rendering. Consumers must treat all subprocess stdout/stderr as untrusted.
94
+
86
95
  ## JOSHUA Alignment
87
96
 
88
97
  - **Hermetic**: Security boundaries are sealed - auth checks happen at middleware, never inside business logic
package/dist/index.d.ts CHANGED
@@ -413,18 +413,6 @@ declare class OAuthClient {
413
413
  picture?: string;
414
414
  }>;
415
415
  }
416
- /**
417
- * Hash password with PBKDF2 and random salt
418
- */
419
- declare function hashPassword(password: string): Promise<string>;
420
- /**
421
- * Verify password against stored hash
422
- */
423
- declare function verifyPassword(password: string, storedHash: string): Promise<boolean>;
424
- declare const PasswordHasher: {
425
- readonly hash: typeof hashPassword;
426
- readonly verify: typeof verifyPassword;
427
- };
428
416
  /**
429
417
  * Generate TOTP secret
430
418
  */
@@ -1504,4 +1492,120 @@ interface SecurityLogger {
1504
1492
  */
1505
1493
  declare function configureSecurityLogger(logger: SecurityLogger): void;
1506
1494
 
1507
- export { type AlertHandler, type AlertingConfig, AuditAlertHandler, type AuditEvent, type AuditEventType, type AuditQuery, AuditReportGenerator, type AuditSeverity, type AuditStorage, AuditSystem, AuditTrail, type AuthorizationContext, AuthorizationSystem, type BreachStorage, type CORSConfig, CORSManager, CORSPresets, CommonRoles, ConsentManager, type ConsentRecord, type ConsentType, type ContentSecurityPolicyConfig, type CookieConsentConfig, CookieConsentManager, DEFAULT_THRESHOLDS, DataAnonymization, type DataBreach, DataBreachManager, type DataCategory, type DataDeletionRequest, DataDeletionSystem, DataExportSystem, DataMasking, type DataProcessingPurpose, type EncryptedData, type EncryptionConfig, EncryptionSystem, EnvelopeEncryption, FieldEncryption, type GDPRStorage, type HSTSConfig, InMemoryAuditStorage, InMemoryBreachStorage, InMemoryGDPRStorage, KeyRotationManager, LogAlertHandler, OAuthClient, type OAuthConfig, OAuthProviders, PasswordHasher, type Permission, PermissionBuilder, PermissionCache, type PermissionsPolicyConfig, type PersonalDataExport, type Policy, PolicyBuilder, type PolicyCondition, PrivacyPolicyManager, type ReferrerPolicyValue, RequirePermission, RequireRole, type Role, type SecurityAlert, SecurityAlertService, SecurityHeaders, type SecurityHeadersConfig, type SecurityLogger, SecurityPresets, type ThresholdRule, TokenGenerator, TwoFactorAuth, type User, WebhookAlertHandler, audit, authorization, canAccessResource, checkAttributeAccess, configureSecurityLogger, cookieConsentManager, createAuditMiddleware, createAuthorizationMiddleware, createConsentManager, createDataBreachManager, createDataDeletionSystem, createSecurityMiddleware, dataExportSystem, encryption, permissionCache, privacyPolicyManager, setRateLimitHeaders, signAuditEntry, verifyAuditEntry };
1495
+ /**
1496
+ * Input-sanitization primitives for untrusted strings heading into a
1497
+ * control-sequence-sensitive sink (terminal, URL, HTML, shell, etc.).
1498
+ *
1499
+ * Scope: call these at the point a string crosses into a sink that parses
1500
+ * control bytes. Do not pre-sanitize at data ingress — sanitize for the
1501
+ * output context, where the threat model is concrete.
1502
+ */
1503
+ /**
1504
+ * Sanitize a string destined for a terminal banner / welcome sink.
1505
+ * Preserves SGR colour + attribute escapes, strips every other control
1506
+ * byte and ANSI sequence family.
1507
+ *
1508
+ * Why: untrusted ANSI is a known terminal-escape-injection surface
1509
+ * (cursor hijack, window-title rewrite, OSC-8 hyperlink spoofing). Use
1510
+ * this for any string the app writes to a terminal that did not come
1511
+ * directly from a trusted PTY.
1512
+ */
1513
+ declare function sanitizeTerminalLine(input: string): string;
1514
+ type ShellDialect = 'posix' | 'cmd' | 'powershell';
1515
+ /**
1516
+ * Quote an untrusted string so it traverses a shell as a single literal
1517
+ * argv token, with no metacharacter interpretation.
1518
+ *
1519
+ * Use this only when a real shell is unavoidable. For local `spawn()`
1520
+ * calls, pass an argv array instead and skip shell parsing entirely.
1521
+ *
1522
+ * @param arg - The untrusted value to embed.
1523
+ * @param shell - `'posix'` (default) for sh/bash/zsh, `'cmd'` for
1524
+ * Windows cmd.exe, `'powershell'` for PowerShell.
1525
+ * @throws If `arg` contains a NUL byte (which every shell treats as an
1526
+ * argument terminator — no safe encoding exists).
1527
+ */
1528
+ declare function escapeShellArg(arg: string, shell?: ShellDialect): string;
1529
+ /**
1530
+ * Quote + escape a Postgres identifier for safe interpolation into raw
1531
+ * SQL. Returns `"name"`, with embedded double-quotes doubled.
1532
+ *
1533
+ * Throws on empty input, NUL bytes, or anything over 63 bytes — the
1534
+ * three failure modes where silent acceptance would produce a
1535
+ * syntactically valid but semantically wrong query.
1536
+ *
1537
+ * Prefer Drizzle's `sql.identifier()` for compile-time-known names;
1538
+ * reach for this only when the identifier truly has to flow through
1539
+ * user input or runtime configuration.
1540
+ */
1541
+ declare function escapeSqlIdentifier(identifier: string): string;
1542
+ type UrlContext = 'link' | 'image';
1543
+ /**
1544
+ * Return `true` if the URL is safe to render in the given context.
1545
+ *
1546
+ * - `link` (default): http(s), mailto:, tel:, fragment, relative path.
1547
+ * - `image`: same as link, plus `data:image/…` for inline base64 images.
1548
+ *
1549
+ * Blocks `javascript:` / `vbscript:` / non-image `data:`, including
1550
+ * leading-whitespace evasions (` javascript:…`) and mixed-case
1551
+ * (`JaVaScRiPt:…`). Unknown schemes are blocked by default — allow-list,
1552
+ * not deny-list.
1553
+ */
1554
+ declare function isSafeUrl(url: string, context?: UrlContext): boolean;
1555
+ /**
1556
+ * Sanitize a URL for rendering. Returns the trimmed input if safe,
1557
+ * otherwise `'#'` — a harmless anchor that renders without navigation.
1558
+ */
1559
+ declare function sanitizeUrl(url: string, context?: UrlContext): string;
1560
+ interface SanitizeHtmlOptions {
1561
+ /** Additional tag names allowed on top of the default set. Lower-case. */
1562
+ readonly extraTags?: readonly string[];
1563
+ /** Additional per-tag attributes, keyed by lower-case tag name. */
1564
+ readonly extraAttrs?: Readonly<Record<string, readonly string[]>>;
1565
+ }
1566
+ /**
1567
+ * Sanitize an untrusted HTML string against a tag + attribute allow-list.
1568
+ *
1569
+ * Safe to render the result via `dangerouslySetInnerHTML` or direct
1570
+ * `innerHTML=`. Known-dangerous containers (script, style, iframe, etc.)
1571
+ * are dropped with their contents; unknown tags are unwrapped; every
1572
+ * `on*` event-handler attribute is stripped; URL attributes (`href`,
1573
+ * `src`, `cite`) are filtered through `isSafeUrl`.
1574
+ *
1575
+ * For Lexical / markdown render paths — sanitize at the sink.
1576
+ */
1577
+ declare function sanitizeHtml(input: string, options?: SanitizeHtmlOptions): string;
1578
+ declare const REDACTED: "[REDACTED]";
1579
+ /**
1580
+ * `true` if `key` names a class of value that must never reach a log.
1581
+ * Match is case-insensitive substring so variants like `userApiKey`,
1582
+ * `X-API-KEY`, `apikey`, `sessionId` all resolve to the same class.
1583
+ */
1584
+ declare function isSensitiveLogKey(key: string): boolean;
1585
+ /**
1586
+ * Scrub inline secret shapes (JWT, Bearer headers, provider API keys)
1587
+ * from an arbitrary string — for log messages, error messages, and
1588
+ * anything else that may have been concatenated from untrusted sources.
1589
+ * Returns the original string if nothing matched.
1590
+ */
1591
+ declare function redactSecretsInString(input: string): string;
1592
+ /**
1593
+ * Decide the safe form of a single log field.
1594
+ *
1595
+ * - If `key` is sensitive: returns `REDACTED` regardless of value shape.
1596
+ * - If `value` is a string: returns it with inline secret shapes scrubbed.
1597
+ * - Otherwise: returns `value` unchanged. Nested objects/arrays are the
1598
+ * caller's responsibility — use `redactLogContext` to walk a tree.
1599
+ */
1600
+ declare function redactLogField(key: string, value: unknown): unknown;
1601
+ /**
1602
+ * Recursively redact a log context object. Walks plain objects and
1603
+ * arrays; leaves Dates, Errors, Maps, Sets, typed arrays, and other
1604
+ * non-plain objects untouched (stringifying them is the logger's job).
1605
+ *
1606
+ * Depth is capped at 8 to avoid pathological payloads — deeper levels
1607
+ * are replaced with `REDACTED` rather than recursed into.
1608
+ */
1609
+ declare function redactLogContext<T>(obj: T): T;
1610
+
1611
+ export { type AlertHandler, type AlertingConfig, AuditAlertHandler, type AuditEvent, type AuditEventType, type AuditQuery, AuditReportGenerator, type AuditSeverity, type AuditStorage, AuditSystem, AuditTrail, type AuthorizationContext, AuthorizationSystem, type BreachStorage, type CORSConfig, CORSManager, CORSPresets, CommonRoles, ConsentManager, type ConsentRecord, type ConsentType, type ContentSecurityPolicyConfig, type CookieConsentConfig, CookieConsentManager, DEFAULT_THRESHOLDS, DataAnonymization, type DataBreach, DataBreachManager, type DataCategory, type DataDeletionRequest, DataDeletionSystem, DataExportSystem, DataMasking, type DataProcessingPurpose, type EncryptedData, type EncryptionConfig, EncryptionSystem, EnvelopeEncryption, FieldEncryption, type GDPRStorage, type HSTSConfig, InMemoryAuditStorage, InMemoryBreachStorage, InMemoryGDPRStorage, KeyRotationManager, LogAlertHandler, OAuthClient, type OAuthConfig, OAuthProviders, type Permission, PermissionBuilder, PermissionCache, type PermissionsPolicyConfig, type PersonalDataExport, type Policy, PolicyBuilder, type PolicyCondition, PrivacyPolicyManager, REDACTED, type ReferrerPolicyValue, RequirePermission, RequireRole, type Role, type SanitizeHtmlOptions, type SecurityAlert, SecurityAlertService, SecurityHeaders, type SecurityHeadersConfig, type SecurityLogger, SecurityPresets, type ShellDialect, type ThresholdRule, TokenGenerator, TwoFactorAuth, type UrlContext, type User, WebhookAlertHandler, audit, authorization, canAccessResource, checkAttributeAccess, configureSecurityLogger, cookieConsentManager, createAuditMiddleware, createAuthorizationMiddleware, createConsentManager, createDataBreachManager, createDataDeletionSystem, createSecurityMiddleware, dataExportSystem, encryption, escapeShellArg, escapeSqlIdentifier, isSafeUrl, isSensitiveLogKey, permissionCache, privacyPolicyManager, redactLogContext, redactLogField, redactSecretsInString, sanitizeHtml, sanitizeTerminalLine, sanitizeUrl, setRateLimitHeaders, signAuditEntry, verifyAuditEntry };
package/dist/index.js CHANGED
@@ -321,44 +321,6 @@ var OAuthClient = class {
321
321
  return response.json();
322
322
  }
323
323
  };
324
- var PH_ITERATIONS = 1e5;
325
- var PH_KEY_LENGTH = 64;
326
- var PH_DIGEST = "sha512";
327
- async function hashPassword(password) {
328
- const { pbkdf2, randomBytes: rb } = await import("crypto");
329
- const salt = rb(16).toString("hex");
330
- return new Promise((resolve, reject) => {
331
- pbkdf2(password, salt, PH_ITERATIONS, PH_KEY_LENGTH, PH_DIGEST, (err, derivedKey) => {
332
- if (err) reject(err);
333
- else resolve(`${salt}:${derivedKey.toString("hex")}`);
334
- });
335
- });
336
- }
337
- async function verifyPassword(password, storedHash) {
338
- const { pbkdf2, timingSafeEqual: tse } = await import("crypto");
339
- const [salt, hash] = storedHash.split(":");
340
- if (!(salt && hash)) {
341
- return false;
342
- }
343
- return new Promise((resolve, reject) => {
344
- pbkdf2(password, salt, PH_ITERATIONS, PH_KEY_LENGTH, PH_DIGEST, (err, derivedKey) => {
345
- if (err) reject(err);
346
- else {
347
- const derived = Buffer.from(derivedKey.toString("hex"), "utf-8");
348
- const expected = Buffer.from(hash, "utf-8");
349
- if (derived.length !== expected.length) {
350
- resolve(false);
351
- } else {
352
- resolve(tse(derived, expected));
353
- }
354
- }
355
- });
356
- });
357
- }
358
- var PasswordHasher = {
359
- hash: hashPassword,
360
- verify: verifyPassword
361
- };
362
324
  function base32Encode(buffer) {
363
325
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
364
326
  let result = "";
@@ -2224,6 +2186,340 @@ function setRateLimitHeaders(response, limit, remaining, reset) {
2224
2186
  response.headers.set("X-RateLimit-Remaining", remaining.toString());
2225
2187
  response.headers.set("X-RateLimit-Reset", reset.toString());
2226
2188
  }
2189
+
2190
+ // src/sanitize.ts
2191
+ import { defaultTreeAdapter, parseFragment, serialize } from "parse5";
2192
+ var ANY_TERMINAL_ESCAPE = /\x1b\](?:[^\x07\x1b]*)(?:\x07|\x1b\\)?|\x1b[PX^_](?:[^\x1b]*)(?:\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b.|\x1b/g;
2193
+ var SGR_CSI = /^\x1b\[[0-?]*[ -/]*m$/;
2194
+ var DISALLOWED_TERMINAL_CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g;
2195
+ function sanitizeTerminalLine(input) {
2196
+ const stripped = input.replace(
2197
+ ANY_TERMINAL_ESCAPE,
2198
+ (match) => SGR_CSI.test(match) ? match : ""
2199
+ );
2200
+ return stripped.replace(DISALLOWED_TERMINAL_CONTROL, "");
2201
+ }
2202
+ function escapePosix(arg) {
2203
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
2204
+ }
2205
+ function escapeCmd(arg) {
2206
+ const quoted = `"${arg.replace(/"/g, '""')}"`;
2207
+ return quoted.replace(/([&|<>^()%!])/g, "^$1");
2208
+ }
2209
+ function escapePowerShell(arg) {
2210
+ return `'${arg.replace(/'/g, `''`)}'`;
2211
+ }
2212
+ function escapeShellArg(arg, shell = "posix") {
2213
+ if (arg.includes("\0")) {
2214
+ throw new Error("escapeShellArg: NUL byte in argument \u2014 no shell can represent it");
2215
+ }
2216
+ switch (shell) {
2217
+ case "posix":
2218
+ return escapePosix(arg);
2219
+ case "cmd":
2220
+ return escapeCmd(arg);
2221
+ case "powershell":
2222
+ return escapePowerShell(arg);
2223
+ default: {
2224
+ const _exhaustive = shell;
2225
+ throw new Error(`escapeShellArg: unknown shell dialect ${String(_exhaustive)}`);
2226
+ }
2227
+ }
2228
+ }
2229
+ var MAX_PG_IDENTIFIER_BYTES = 63;
2230
+ function escapeSqlIdentifier(identifier) {
2231
+ if (identifier === "") {
2232
+ throw new Error("escapeSqlIdentifier: identifier must not be empty");
2233
+ }
2234
+ if (identifier.includes("\0")) {
2235
+ throw new Error("escapeSqlIdentifier: identifier contains NUL byte");
2236
+ }
2237
+ const byteLength = Buffer.byteLength(identifier, "utf8");
2238
+ if (byteLength > MAX_PG_IDENTIFIER_BYTES) {
2239
+ throw new Error(
2240
+ `escapeSqlIdentifier: identifier exceeds ${MAX_PG_IDENTIFIER_BYTES}-byte limit (got ${byteLength} bytes) \u2014 Postgres silently truncates longer names`
2241
+ );
2242
+ }
2243
+ return `"${identifier.split('"').join('""')}"`;
2244
+ }
2245
+ var SAFE_LINK_PROTOCOLS = /^(?:https?:|mailto:|tel:|#|\/)/i;
2246
+ var SAFE_IMAGE_DATA_URI = /^data:image\//i;
2247
+ var DANGEROUS_SCRIPT_PROTOCOL = /^(?:javascript|vbscript):/i;
2248
+ var ANY_DATA_URI = /^data:/i;
2249
+ function isSafeUrl(url, context = "link") {
2250
+ const trimmed = url.trim();
2251
+ if (trimmed === "" || trimmed === "#") {
2252
+ return true;
2253
+ }
2254
+ if (context === "image" && SAFE_IMAGE_DATA_URI.test(trimmed)) {
2255
+ return true;
2256
+ }
2257
+ if (ANY_DATA_URI.test(trimmed)) {
2258
+ return false;
2259
+ }
2260
+ if (DANGEROUS_SCRIPT_PROTOCOL.test(trimmed)) {
2261
+ return false;
2262
+ }
2263
+ if (SAFE_LINK_PROTOCOLS.test(trimmed) || !trimmed.includes(":")) {
2264
+ return true;
2265
+ }
2266
+ return false;
2267
+ }
2268
+ function sanitizeUrl(url, context = "link") {
2269
+ return isSafeUrl(url, context) ? url.trim() : "#";
2270
+ }
2271
+ var DEFAULT_ALLOWED_TAGS = /* @__PURE__ */ new Set([
2272
+ "a",
2273
+ "b",
2274
+ "blockquote",
2275
+ "br",
2276
+ "code",
2277
+ "div",
2278
+ "em",
2279
+ "h1",
2280
+ "h2",
2281
+ "h3",
2282
+ "h4",
2283
+ "h5",
2284
+ "h6",
2285
+ "hr",
2286
+ "i",
2287
+ "img",
2288
+ "li",
2289
+ "ol",
2290
+ "p",
2291
+ "pre",
2292
+ "s",
2293
+ "span",
2294
+ "strong",
2295
+ "sub",
2296
+ "sup",
2297
+ "table",
2298
+ "tbody",
2299
+ "td",
2300
+ "tfoot",
2301
+ "th",
2302
+ "thead",
2303
+ "tr",
2304
+ "u",
2305
+ "ul"
2306
+ ]);
2307
+ var DANGEROUS_CONTAINER_TAGS = /* @__PURE__ */ new Set([
2308
+ "applet",
2309
+ "base",
2310
+ "body",
2311
+ "embed",
2312
+ "form",
2313
+ "frame",
2314
+ "frameset",
2315
+ "head",
2316
+ "html",
2317
+ "iframe",
2318
+ "input",
2319
+ "link",
2320
+ "math",
2321
+ "meta",
2322
+ "noembed",
2323
+ "noframes",
2324
+ "noscript",
2325
+ "object",
2326
+ "script",
2327
+ "select",
2328
+ "style",
2329
+ "svg",
2330
+ "template",
2331
+ "textarea",
2332
+ "title",
2333
+ "xml"
2334
+ ]);
2335
+ var GLOBAL_ATTRS = /* @__PURE__ */ new Set(["class", "id", "title", "lang", "dir"]);
2336
+ var PER_TAG_ATTRS = {
2337
+ a: /* @__PURE__ */ new Set(["href", "target", "rel", "name"]),
2338
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height", "loading"]),
2339
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan", "align", "valign"]),
2340
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan", "align", "valign", "scope"]),
2341
+ ol: /* @__PURE__ */ new Set(["start", "reversed", "type"]),
2342
+ li: /* @__PURE__ */ new Set(["value"]),
2343
+ code: /* @__PURE__ */ new Set(["data-language"]),
2344
+ pre: /* @__PURE__ */ new Set(["data-language"]),
2345
+ blockquote: /* @__PURE__ */ new Set(["cite"])
2346
+ };
2347
+ var URL_ATTRS = {
2348
+ href: "link",
2349
+ src: "image",
2350
+ cite: "link"
2351
+ };
2352
+ function sanitizeHtml(input, options) {
2353
+ const allowedTags = new Set(DEFAULT_ALLOWED_TAGS);
2354
+ if (options?.extraTags) {
2355
+ for (const t of options.extraTags) allowedTags.add(t.toLowerCase());
2356
+ }
2357
+ const extraAttrs = options?.extraAttrs ?? {};
2358
+ const fragment = parseFragment(input);
2359
+ filterChildren(fragment, allowedTags, extraAttrs);
2360
+ return serialize(fragment);
2361
+ }
2362
+ function filterChildren(parent, allowedTags, extraAttrs) {
2363
+ const kept = [];
2364
+ for (const node of parent.childNodes) {
2365
+ const next = filterNode(node, allowedTags, extraAttrs);
2366
+ for (const n of next) {
2367
+ n.parentNode = parent;
2368
+ kept.push(n);
2369
+ }
2370
+ }
2371
+ parent.childNodes = kept;
2372
+ }
2373
+ function filterNode(node, allowedTags, extraAttrs) {
2374
+ if (defaultTreeAdapter.isElementNode(node)) {
2375
+ const tag = node.tagName.toLowerCase();
2376
+ if (DANGEROUS_CONTAINER_TAGS.has(tag)) {
2377
+ return [];
2378
+ }
2379
+ filterChildren(node, allowedTags, extraAttrs);
2380
+ if (!allowedTags.has(tag)) {
2381
+ return node.childNodes.slice();
2382
+ }
2383
+ node.attrs = filterAttrs(tag, node.attrs, extraAttrs);
2384
+ hardenAnchor(tag, node);
2385
+ return [node];
2386
+ }
2387
+ if (defaultTreeAdapter.isTextNode(node)) {
2388
+ return [node];
2389
+ }
2390
+ return [];
2391
+ }
2392
+ function filterAttrs(tag, attrs, extraAttrs) {
2393
+ const tagAttrs = PER_TAG_ATTRS[tag];
2394
+ const extraForTag = extraAttrs[tag];
2395
+ const out = [];
2396
+ for (const attr of attrs) {
2397
+ const name = attr.name.toLowerCase();
2398
+ if (name.startsWith("on")) continue;
2399
+ if (name === "style") continue;
2400
+ if (name === "srcdoc") continue;
2401
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
2402
+ if (name.includes(":")) continue;
2403
+ const allowed = GLOBAL_ATTRS.has(name) || tagAttrs?.has(name) || extraForTag?.includes(name) || name.startsWith("data-") || name.startsWith("aria-");
2404
+ if (!allowed) continue;
2405
+ if (name in URL_ATTRS) {
2406
+ const context = URL_ATTRS[name];
2407
+ if (context === void 0 || !isSafeUrl(attr.value, context)) continue;
2408
+ out.push({ ...attr, name, value: attr.value.trim() });
2409
+ continue;
2410
+ }
2411
+ out.push({ ...attr, name });
2412
+ }
2413
+ return out;
2414
+ }
2415
+ function hardenAnchor(tag, node) {
2416
+ if (tag !== "a") return;
2417
+ const target = node.attrs.find((a) => a.name === "target");
2418
+ if (!target || target.value !== "_blank") return;
2419
+ const rel = node.attrs.find((a) => a.name === "rel");
2420
+ const tokens = new Set((rel?.value ?? "").split(/\s+/).filter(Boolean));
2421
+ tokens.add("noopener");
2422
+ tokens.add("noreferrer");
2423
+ const merged = Array.from(tokens).join(" ");
2424
+ if (rel) {
2425
+ rel.value = merged;
2426
+ } else {
2427
+ node.attrs.push({ name: "rel", value: merged });
2428
+ }
2429
+ }
2430
+ var REDACTED = "[REDACTED]";
2431
+ var SENSITIVE_KEY_SUBSTRINGS = [
2432
+ "password",
2433
+ "passwd",
2434
+ "pwd",
2435
+ "secret",
2436
+ "token",
2437
+ "apikey",
2438
+ "authorization",
2439
+ "cookie",
2440
+ "session",
2441
+ "privatekey",
2442
+ "encryptedkey",
2443
+ "creditcard",
2444
+ "cardnumber",
2445
+ "cvv",
2446
+ "cvc",
2447
+ "ssn"
2448
+ ];
2449
+ var NON_ALNUM = /[^a-z0-9]/g;
2450
+ var SECRET_VALUE_PATTERNS = [
2451
+ // JWT (header.payload.signature) — base64url segments, min lengths keep
2452
+ // this from matching arbitrary dotted identifiers.
2453
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
2454
+ // Bearer <token> in header-style strings.
2455
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
2456
+ // OpenAI: sk-…, sk-proj-…, sk-svcacct-…
2457
+ /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
2458
+ // Stripe secret + restricted keys.
2459
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g,
2460
+ // Stripe webhook signing secret.
2461
+ /\bwhsec_[A-Za-z0-9]{20,}/g,
2462
+ // AWS access key id.
2463
+ /\bAKIA[0-9A-Z]{16}\b/g,
2464
+ // GitHub classic PAT (36+ char suffix) and fine-grained token.
2465
+ /\bghp_[A-Za-z0-9]{20,}/g,
2466
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g
2467
+ ];
2468
+ function isSensitiveLogKey(key) {
2469
+ const normalised = key.toLowerCase().replace(NON_ALNUM, "");
2470
+ for (const needle of SENSITIVE_KEY_SUBSTRINGS) {
2471
+ if (normalised.includes(needle)) return true;
2472
+ }
2473
+ return false;
2474
+ }
2475
+ function redactSecretsInString(input) {
2476
+ let out = input;
2477
+ for (const pattern of SECRET_VALUE_PATTERNS) {
2478
+ out = out.replace(pattern, REDACTED);
2479
+ }
2480
+ return out;
2481
+ }
2482
+ function redactLogField(key, value) {
2483
+ if (isSensitiveLogKey(key)) {
2484
+ return REDACTED;
2485
+ }
2486
+ if (typeof value === "string") {
2487
+ return redactSecretsInString(value);
2488
+ }
2489
+ return value;
2490
+ }
2491
+ var MAX_REDACT_DEPTH = 8;
2492
+ function redactLogContext(obj) {
2493
+ return walk(obj, 0);
2494
+ }
2495
+ function isPlainObject(v) {
2496
+ if (v === null || typeof v !== "object") return false;
2497
+ const proto = Object.getPrototypeOf(v);
2498
+ return proto === Object.prototype || proto === null;
2499
+ }
2500
+ function walk(value, depth) {
2501
+ if (depth >= MAX_REDACT_DEPTH) {
2502
+ return isPlainObject(value) || Array.isArray(value) ? REDACTED : value;
2503
+ }
2504
+ if (typeof value === "string") {
2505
+ return redactSecretsInString(value);
2506
+ }
2507
+ if (Array.isArray(value)) {
2508
+ return value.map((item) => walk(item, depth + 1));
2509
+ }
2510
+ if (isPlainObject(value)) {
2511
+ const out = {};
2512
+ for (const [k, v] of Object.entries(value)) {
2513
+ if (isSensitiveLogKey(k)) {
2514
+ out[k] = REDACTED;
2515
+ } else {
2516
+ out[k] = walk(v, depth + 1);
2517
+ }
2518
+ }
2519
+ return out;
2520
+ }
2521
+ return value;
2522
+ }
2227
2523
  export {
2228
2524
  AuditAlertHandler,
2229
2525
  AuditReportGenerator,
@@ -2251,11 +2547,11 @@ export {
2251
2547
  LogAlertHandler,
2252
2548
  OAuthClient,
2253
2549
  OAuthProviders,
2254
- PasswordHasher,
2255
2550
  PermissionBuilder,
2256
2551
  PermissionCache,
2257
2552
  PolicyBuilder,
2258
2553
  PrivacyPolicyManager,
2554
+ REDACTED,
2259
2555
  RequirePermission,
2260
2556
  RequireRole,
2261
2557
  SecurityAlertService,
@@ -2278,8 +2574,18 @@ export {
2278
2574
  createSecurityMiddleware,
2279
2575
  dataExportSystem,
2280
2576
  encryption,
2577
+ escapeShellArg,
2578
+ escapeSqlIdentifier,
2579
+ isSafeUrl,
2580
+ isSensitiveLogKey,
2281
2581
  permissionCache,
2282
2582
  privacyPolicyManager,
2583
+ redactLogContext,
2584
+ redactLogField,
2585
+ redactSecretsInString,
2586
+ sanitizeHtml,
2587
+ sanitizeTerminalLine,
2588
+ sanitizeUrl,
2283
2589
  setRateLimitHeaders,
2284
2590
  signAuditEntry,
2285
2591
  verifyAuditEntry
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@revealui/security",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Security infrastructure for RevealUI - headers, CORS, RBAC/ABAC, encryption, audit, GDPR",
5
5
  "license": "MIT",
6
6
  "dependencies": {
7
- "@revealui/contracts": "1.3.7",
7
+ "parse5": "^8.0.0",
8
+ "@revealui/contracts": "1.4.0",
8
9
  "@revealui/utils": "0.3.4"
9
10
  },
10
11
  "devDependencies": {