@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 +9 -0
- package/dist/index.d.ts +117 -13
- package/dist/index.js +345 -39
- package/package.json +3 -2
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
7
|
+
"parse5": "^8.0.0",
|
|
8
|
+
"@revealui/contracts": "1.4.0",
|
|
8
9
|
"@revealui/utils": "0.3.4"
|
|
9
10
|
},
|
|
10
11
|
"devDependencies": {
|