@resq-systems/security 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +236 -33
  2. package/lib/controls/address.d.mts +142 -0
  3. package/lib/controls/address.d.mts.map +1 -0
  4. package/lib/controls/address.mjs +533 -0
  5. package/lib/controls/address.mjs.map +1 -0
  6. package/lib/controls/csrf.d.mts +91 -0
  7. package/lib/controls/csrf.d.mts.map +1 -0
  8. package/lib/controls/csrf.mjs +200 -0
  9. package/lib/controls/csrf.mjs.map +1 -0
  10. package/lib/controls/index.d.mts +8 -0
  11. package/lib/controls/index.mjs +8 -0
  12. package/lib/controls/origin.d.mts +95 -0
  13. package/lib/controls/origin.d.mts.map +1 -0
  14. package/lib/controls/origin.mjs +156 -0
  15. package/lib/controls/origin.mjs.map +1 -0
  16. package/lib/controls/payload.d.mts +84 -0
  17. package/lib/controls/payload.d.mts.map +1 -0
  18. package/lib/controls/payload.mjs +147 -0
  19. package/lib/controls/payload.mjs.map +1 -0
  20. package/lib/controls/query.d.mts +157 -0
  21. package/lib/controls/query.d.mts.map +1 -0
  22. package/lib/controls/query.mjs +368 -0
  23. package/lib/controls/query.mjs.map +1 -0
  24. package/lib/controls/redirect.d.mts +92 -0
  25. package/lib/controls/redirect.d.mts.map +1 -0
  26. package/lib/controls/redirect.mjs +110 -0
  27. package/lib/controls/redirect.mjs.map +1 -0
  28. package/lib/controls/upload.d.mts +108 -0
  29. package/lib/controls/upload.d.mts.map +1 -0
  30. package/lib/controls/upload.mjs +374 -0
  31. package/lib/controls/upload.mjs.map +1 -0
  32. package/lib/crypto.d.mts +18 -5
  33. package/lib/crypto.d.mts.map +1 -1
  34. package/lib/crypto.mjs +35 -24
  35. package/lib/crypto.mjs.map +1 -1
  36. package/lib/hash.d.mts +51 -6
  37. package/lib/hash.d.mts.map +1 -1
  38. package/lib/hash.mjs +51 -6
  39. package/lib/hash.mjs.map +1 -1
  40. package/lib/index.d.mts +17 -2
  41. package/lib/index.mjs +19 -2
  42. package/lib/paths.d.mts +92 -0
  43. package/lib/paths.d.mts.map +1 -0
  44. package/lib/paths.mjs +140 -0
  45. package/lib/paths.mjs.map +1 -0
  46. package/lib/sanitize.d.mts +137 -35
  47. package/lib/sanitize.d.mts.map +1 -1
  48. package/lib/sanitize.mjs +170 -46
  49. package/lib/sanitize.mjs.map +1 -1
  50. package/lib/threats/capec.generated.d.mts +59 -0
  51. package/lib/threats/capec.generated.d.mts.map +1 -0
  52. package/lib/threats/capec.generated.mjs +644 -0
  53. package/lib/threats/capec.generated.mjs.map +1 -0
  54. package/lib/threats/engine.d.mts +94 -0
  55. package/lib/threats/engine.d.mts.map +1 -0
  56. package/lib/threats/engine.mjs +167 -0
  57. package/lib/threats/engine.mjs.map +1 -0
  58. package/lib/threats/index.d.mts +11 -0
  59. package/lib/threats/index.mjs +11 -0
  60. package/lib/threats/rules/datastore.d.mts +13 -0
  61. package/lib/threats/rules/datastore.d.mts.map +1 -0
  62. package/lib/threats/rules/datastore.mjs +366 -0
  63. package/lib/threats/rules/datastore.mjs.map +1 -0
  64. package/lib/threats/rules/index.d.mts +54 -0
  65. package/lib/threats/rules/index.d.mts.map +1 -0
  66. package/lib/threats/rules/index.mjs +121 -0
  67. package/lib/threats/rules/index.mjs.map +1 -0
  68. package/lib/threats/rules/markup.d.mts +28 -0
  69. package/lib/threats/rules/markup.d.mts.map +1 -0
  70. package/lib/threats/rules/markup.mjs +373 -0
  71. package/lib/threats/rules/markup.mjs.map +1 -0
  72. package/lib/threats/rules/protocol.d.mts +49 -0
  73. package/lib/threats/rules/protocol.d.mts.map +1 -0
  74. package/lib/threats/rules/protocol.mjs +175 -0
  75. package/lib/threats/rules/protocol.mjs.map +1 -0
  76. package/lib/threats/rules/system.d.mts +19 -0
  77. package/lib/threats/rules/system.d.mts.map +1 -0
  78. package/lib/threats/rules/system.mjs +455 -0
  79. package/lib/threats/rules/system.mjs.map +1 -0
  80. package/lib/threats/rules/web.d.mts +26 -0
  81. package/lib/threats/rules/web.d.mts.map +1 -0
  82. package/lib/threats/rules/web.mjs +412 -0
  83. package/lib/threats/rules/web.mjs.map +1 -0
  84. package/lib/threats/scoring.d.mts +59 -0
  85. package/lib/threats/scoring.d.mts.map +1 -0
  86. package/lib/threats/scoring.mjs +111 -0
  87. package/lib/threats/scoring.mjs.map +1 -0
  88. package/lib/threats/types.d.mts +245 -0
  89. package/lib/threats/types.d.mts.map +1 -0
  90. package/lib/threats/types.mjs +52 -0
  91. package/lib/threats/types.mjs.map +1 -0
  92. package/lib/threats/variants.d.mts +57 -0
  93. package/lib/threats/variants.d.mts.map +1 -0
  94. package/lib/threats/variants.mjs +144 -0
  95. package/lib/threats/variants.mjs.map +1 -0
  96. package/lib/unicode/confusables.d.mts +82 -0
  97. package/lib/unicode/confusables.d.mts.map +1 -0
  98. package/lib/unicode/confusables.mjs +954 -0
  99. package/lib/unicode/confusables.mjs.map +1 -0
  100. package/lib/unicode/index.d.mts +126 -0
  101. package/lib/unicode/index.d.mts.map +1 -0
  102. package/lib/unicode/index.mjs +288 -0
  103. package/lib/unicode/index.mjs.map +1 -0
  104. package/lib/validators.d.mts +341 -164
  105. package/lib/validators.d.mts.map +1 -1
  106. package/lib/validators.mjs +519 -338
  107. package/lib/validators.mjs.map +1 -1
  108. package/package.json +35 -8
package/README.md CHANGED
@@ -120,63 +120,266 @@ The crypto surface uses nominal (branded) types so a plain `string` cannot be pa
120
120
  | `isCiphertext(value)` | type guard | `true` for a well-formed envelope |
121
121
  | `unsafeCiphertext(value)` | `Ciphertext` | Brand without checking |
122
122
 
123
- ### Threat Detection (`validators.ts`)
123
+ ### Threat Detection (`threats/`)
124
124
 
125
- #### `detectThreatPatterns(input, config?): ThreatDetectionResult`
125
+ > **Detection is not the control.** Every rule carries a `primaryControl` string naming
126
+ > what actually prevents the weakness. Parameterized queries stop SQL injection;
127
+ > context-correct output encoding and DOMPurify stop XSS; `resolveContainedPath` stops
128
+ > traversal; spawning with an argv array stops command injection. Use findings for
129
+ > telemetry, risk scoring, rate limiting, and review — never as the only barrier.
126
130
 
127
- Runs all configured detectors on input.
131
+ #### `scanForThreats(input, options?): ThreatScanResult`
128
132
 
129
- - Returns `{ isSafe: boolean, threats: ThreatFinding[] }`.
133
+ Evaluates only the rules that apply to the **sink the value is bound for**. Declaring
134
+ the context is the package's primary false-positive control: a biography containing
135
+ `C:\Windows`, a ticket containing `1=1`, and a question containing `eval(` are all
136
+ ordinary text, and only become evidence when the value reaches a filesystem, SQL, or
137
+ HTML sink.
130
138
 
131
- | Config Option | Type | Default | Description |
132
- |---------------|------|---------|-------------|
133
- | `checkXSS` | `boolean` | `true` | Detect script injection, event handlers |
134
- | `checkSQLInjection` | `boolean` | `true` | Detect UNION, DROP, stacked queries |
135
- | `checkNoSQLInjection` | `boolean` | `true` | Detect MongoDB operators |
136
- | `checkCommandInjection` | `boolean` | `false` | Detect shell commands (can cause false positives) |
137
- | `checkPathTraversal` | `boolean` | `true` | Detect `../`, `%2e%2e`, null bytes |
138
- | `checkHomoglyphs` | `boolean` | `true` | Detect Unicode lookalike characters |
139
-
140
- #### `isSafeInput(input, config?): boolean`
139
+ ```ts
140
+ import { scanForThreats } from "@resq-systems/security/threats";
141
+
142
+ const result = scanForThreats(req.query.file ?? "", { contexts: ["filesystem"] });
143
+
144
+ if (result.verdict === "block") {
145
+ // Log an allowlist, never the findings themselves: `matchedPattern` carries an
146
+ // excerpt of the input, which for a `credential_exposure` hit is the credential.
147
+ logger.warn("traversal attempt", {
148
+ rules: result.findings.map(({ ruleId, type, severity, cwe }) => ({
149
+ ruleId,
150
+ type,
151
+ severity,
152
+ cwe,
153
+ })),
154
+ });
155
+ return new Response("Bad request", { status: 400 });
156
+ }
157
+ ```
141
158
 
142
- Quick check returning `true` if no threats are detected.
159
+ | Option | Type | Default | Description |
160
+ |--------|------|---------|-------------|
161
+ | `contexts` | `ThreatContext[]` | `["general_text"]` | Sinks the value reaches. The default enables only universal rules (bidi, invisible, control chars) |
162
+ | `maxLength` | `number` | `100_000` | Truncation bound |
163
+ | `scanVariants` | `boolean` | `true` | Also scan NFC, percent-decoded, and HTML-decoded forms |
164
+ | `minSeverity` | `ThreatSeverity` | `"low"` | Drop findings below this severity |
165
+ | `excludeRuleIds` | `string[]` | — | Silence individual rules. Prefer this to disabling a category |
166
+ | `policy` | `ThreatPolicy` | `{ reviewAt: 4, blockAt: 8 }` | Score thresholds |
167
+
168
+ Returns `{ isSafe, score, verdict, findings, types, truncated }`. Each finding carries
169
+ `ruleId`, `type`, `severity`, `confidence`, `cwe`, `primaryControl`, `variant`,
170
+ `matchedPattern`, `start`, and `end`.
171
+
172
+ **Contexts:** `general_text`, `html`, `sql`, `nosql`, `shell`, `filesystem`, `url`,
173
+ `url_parameter`, `http_header`, `jwt`, `identifier`, `object_merge`, `template`, `xml`,
174
+ `ldap`, `xpath`, `spreadsheet`, `log`, `llm_prompt`.
175
+
176
+ Two draw deliberately fine distinctions. `url_parameter` is a *single value* about to be
177
+ concatenated into a query string, where `&role=` is an injected parameter — as opposed
178
+ to `url`, where it is ordinary grammar. `jwt` is scoped to the token itself; to check a
179
+ `kid` or `jku` claim, extract it and declare *its* real sink (`filesystem`, `sql`,
180
+ `url`), which the existing rules already cover.
181
+
182
+ **Categories:** `xss`, `sql_injection`, `nosql_injection`, `command_injection`,
183
+ `path_traversal`, `prototype_pollution`, `homoglyph`, `header_injection`,
184
+ `ldap_injection`, `xpath_injection`, `xml_injection`, `template_injection`,
185
+ `file_inclusion`, `ssrf`, `formula_injection`, `log_injection`, `prompt_injection`,
186
+ `parameter_pollution`, `credential_exposure`, `jwt_tampering`, `resource_abuse`.
187
+
188
+ `credential_exposure` runs the opposite way to every other category: it detects the
189
+ application's own secret *leaving* — in a URL it is about to fetch, or a line it is
190
+ about to log — so a finding usually means your code is at fault, not the submitter's.
191
+
192
+ **Canonicalization.** Each scan evaluates the raw string plus whichever of `nfc`,
193
+ `nfkc`, `percent_decoded`, and `html_decoded` differ from it, and the finding records
194
+ which one matched. `nfkc` matters more than it sounds: NFC is a documented no-op on
195
+ compatibility characters, so before it was added, fullwidth `../../etc/passwd` and
196
+ `<script>` bypassed every signature in the catalog.
197
+
198
+ #### Scoring
199
+
200
+ Any individual signature produces false positives, so a single low-confidence hit
201
+ raises a signal rather than rejecting a submission. Score is
202
+ `severityWeight x confidenceMultiplier`, counted once per rule:
203
+
204
+ | Severity | Weight | Confidence | Multiplier |
205
+ |----------|--------|------------|------------|
206
+ | `low` | 1 | `low` | 0.5 |
207
+ | `medium` | 2 | `medium` | 1 |
208
+ | `high` | 4 | `high` | 1.5 |
209
+ | `critical` | 8 | | |
210
+
211
+ `score < 4` is `allow`, `< 8` is `review`, `>= 8` is `block`.
212
+
213
+ Helpers: `calculateThreatScore`, `verdictForScore`, `scoreForFinding`,
214
+ `summarizeByType`, `THREAT_RULES`, `getRulesForContexts`, `buildInputVariants`.
143
215
 
144
216
  #### Individual Detectors
145
217
 
146
- Each returns `ThreatFinding[]`:
218
+ Thin wrappers that scan one context and return at most one `ThreatFinding`:
219
+
220
+ | Function | Context | Detects |
221
+ |----------|---------|---------|
222
+ | `containsXSSPatterns(input)` | `html` | Script tags, event handlers, `javascript:` URIs, `eval()` |
223
+ | `containsPrototypePollution(input)` | `object_merge` | `__proto__`, `constructor.prototype` as property paths |
224
+ | `containsSQLInjection(input)` | `sql` | UNION SELECT, DROP TABLE, quoted tautologies, SLEEP, stacked queries |
225
+ | `containsNoSQLInjection(input)` | `nosql` | MongoDB operators (`$gt`, `$where`, `$function`) |
226
+ | `containsCommandInjection(input)` | `shell` | Command substitution, piped shells, chained commands |
227
+ | `containsPathTraversal(input)` | `filesystem` | Directory traversal, NUL bytes, sensitive paths |
228
+ | `containsHomoglyphs(input)` | `identifier` | UTS #39 mixed-script and bidirectional spoofing |
229
+
230
+ ### Unicode Identifier Security (`unicode/`)
231
+
232
+ Scope these to **protected identifiers** — usernames, domains, org names, package
233
+ names. UTS #39 warns that broad confusable detection flags many legitimate strings, so
234
+ do not run them on prose or on people's names. `containsBidiControls` is the exception
235
+ and is safe anywhere.
236
+
237
+ ```ts
238
+ import { analyzeIdentifier } from "@resq-systems/security/unicode";
239
+
240
+ const candidate = analyzeIdentifier(requestedUsername);
241
+ if (await skeletonIndex.has(candidate.skeleton)) {
242
+ return { error: "That name is too similar to an existing account" };
243
+ }
244
+ // Store `candidate.original` for display, index `candidate.skeleton`.
245
+ ```
246
+
247
+ | Function | Purpose |
248
+ |----------|---------|
249
+ | `analyzeIdentifier(input)` | `{ original, normalized, skeleton, scripts, isMixedScript, restrictionLevel, hasInvisibleCharacters, hasBidiControls }` |
250
+ | `getSkeleton(input)` | Opaque confusable comparison key. Compare it, never display it |
251
+ | `areConfusable(a, b)` | Whether two distinct strings share a skeleton |
252
+ | `getScripts(input)` | Scripts present, script-neutral characters excluded |
253
+ | `getRestrictionLevel(input)` | `ascii_only`, `single_script`, `highly_restrictive`, `moderately_restrictive`, `minimally_restrictive`, `unrestricted` |
254
+ | `isSafeIdentifier(input, max?)` | Policy check, default max `moderately_restrictive` |
255
+ | `containsBidiControls(input)` | Trojan Source (CVE-2021-42574). Hostile in any field |
256
+ | `stripInvisibleCharacters(input)` | Remove zero-width and bidi code points |
257
+
258
+ `Ольга Иванова`, `東京タワー`, and `서울-Seoul` pass. `pаypal` (with a Cyrillic `а`) and
259
+ a filename carrying U+202E do not.
260
+
261
+ ### Path Containment (`paths.ts`) — Node only
262
+
263
+ The prevention half of CWE-22. Not exported from the package root, since it imports
264
+ `node:path`.
265
+
266
+ ```ts
267
+ import { resolveContainedPath } from "@resq-systems/security/paths";
268
+
269
+ const target = resolveContainedPath("/srv/uploads", req.body.filename);
270
+ if (target === null) return new Response("Bad request", { status: 400 });
271
+ ```
147
272
 
148
- | Function | Detects |
273
+ | Function | Purpose |
149
274
  |----------|---------|
150
- | `containsXSSPatterns(input)` | Script tags, event handlers, `javascript:` URIs, `eval()` |
151
- | `containsSQLInjection(input)` | UNION SELECT, DROP TABLE, `1=1`, SLEEP, stacked queries |
152
- | `containsNoSQLInjection(input)` | MongoDB operators (`$gt`, `$where`, `$function`) |
153
- | `containsCommandInjection(input)` | Command substitution, piped shells |
154
- | `containsPathTraversal(input)` | Directory traversal, null bytes, sensitive paths |
155
- | `containsHomoglyphs(input)` | Cyrillic/Greek lookalike characters |
275
+ | `resolveContainedPath(base, untrusted, opts?)` | Resolved absolute path, or `null` when it escapes the base |
276
+ | `isPathContained(base, candidate, opts?)` | Boolean form |
277
+ | `sanitizeFilename(name, fallback?)` | Reduce to one safe path segment. Hygiene, not the control |
156
278
 
157
- #### `sanitizeForDisplay(input): string`
279
+ Performs **no** filesystem I/O, so it cannot see symlinks. When the target may exist and
280
+ may be a link, `realpath` both sides and re-check.
158
281
 
159
- Escapes HTML entities (`<`, `>`, `&`, `"`, `'`, `/`) for safe rendering.
282
+ ### Preventive Controls (`controls/`) — Node only
160
283
 
161
- #### `normalizeUnicode(input): string`
284
+ Some weaknesses cannot be detected, only prevented. A forged CSRF request is
285
+ byte-identical to a genuine one; `Origin: https://evil.example` is shaped exactly like a
286
+ legitimate origin; an upload's danger lies in three values *disagreeing*. Each of these
287
+ is a decision function that fails closed.
162
288
 
163
- Normalizes to NFC form and replaces known homoglyphs with ASCII equivalents.
289
+ ```ts
290
+ import {
291
+ assertUploadType,
292
+ isAllowedOrigin,
293
+ verifyCsrfToken,
294
+ } from "@resq-systems/security/controls";
295
+
296
+ if (!isAllowedOrigin(req.headers.origin ?? "", ALLOWED_ORIGINS)) return forbid();
297
+
298
+ const csrf = verifyCsrfToken(req.headers["x-csrf-token"], SECRET, {
299
+ sessionId: session.id,
300
+ });
301
+ if (!csrf.valid) return forbid();
302
+ ```
164
303
 
165
- #### `validateSafeText(input): boolean`
304
+ | Function | Prevents |
305
+ |----------|----------|
306
+ | `isAllowedOrigin(origin, allowlist, opts?)` | CORS misconfiguration. Exact match only — no prefix, suffix, or substring path exists through it. `null` and `*` refused; subdomain matching is opt-in and label-boundary anchored |
307
+ | `normalizeOrigin(origin)` | Returns the canonical origin, or `null` when the value carries a path, query, or userinfo |
308
+ | `checkCorsResponsePolicy(policy)` | The credentialed-wildcard mistake (`ACAO: *` with `ACAC: true`) |
309
+ | `createCsrfToken(secret, opts?)` / `verifyCsrfToken(token, secret, opts?)` | CSRF. Signed double-submit: HMAC-SHA256 over length-prefixed fields, constant-time length-blind comparison, signed expiry, optional session binding |
310
+ | `assertUploadType(candidate)` | Unrestricted upload. Requires the declared `Content-Type`, the filename extension, and the magic bytes to agree on one allowlisted type |
311
+ | `detectFileSignature(headBytes)` | Identifies a file from its leading bytes |
312
+ | `validateJsonpCallback(name)` | XSSI. A JSONP callback name is concatenated into executable JavaScript, so an allowlist is the only safe validation |
313
+ | `analyzeQueryComplexity(query, limits?)` | Query-depth denial of service. Computed depth/alias/field bound, string- and comment-aware |
314
+ | `analyzeGraphQLRequest(body, limits?)` | Batched-request denial of service (API4). `analyzeQueryComplexity` measures one document; on an array batch the documented `req.body.query` call reads `undefined` and passes 250 operations. Counts top-level operations across the batch |
315
+ | `resolveRedirectTarget(target, opts?)` | Open redirect (CWE-601). Allowlist: a same-site path, or an absolute URL whose host you named. Tests for control characters *before* testing for an authority, because tab/LF/CR escape the origin and the authority test cannot see them |
316
+ | `classifyAddress(host)` / `isPubliclyRoutableAddress(host)` | Classifies an IP literal against the IANA special-purpose registries, unwrapping IPv4-mapped IPv6. Returns `null` for a name — *unknown*, not safe |
317
+ | `assertOutboundUrl(url, policy?)` | SSRF. Default-deny: with no allowlist and `allowPublicHosts` off, even a hostname is refused, because nothing here can know what DNS will answer. A pre-connection check — redirects need re-validation per hop and egress control remains the durable fix |
318
+ | `checkJsonPayloadLimits(text, limits?)` | Unrestricted resource consumption (API4). One linear pass over the JSON *text* — depth, container sizes, string lengths — before `JSON.parse` allocates the graph. Reports rather than enforces |
319
+
320
+ `sanitizeHtml` also registers a DOMPurify hook adding `rel="noopener noreferrer"` to
321
+ links with a non-self `target`, preventing reverse tabnabbing. It is a no-op under
322
+ DOMPurify's default config, which strips `target` — it matters when you opt back in with
323
+ `ADD_ATTR: ["target"]`.
324
+
325
+ **Each of these is one layer.** CSRF tokens need `SameSite` cookies and origin validation
326
+ beside them; `assertUploadType` reads only the head, so store uploads outside the webroot
327
+ and serve them from a separate origin with `Content-Disposition: attachment`. See
328
+ [WSTG-COVERAGE.md](WSTG-COVERAGE.md) §3 for what each one does *not* cover.
329
+
330
+ ### Output Encoding and Field Validators (`validators.ts`)
331
+
332
+ Output encoding is context-dependent — HTML text, attributes, URLs, JS strings, and CSS
333
+ each have different rules, and no single function is correct for all of them.
334
+
335
+ | Function | Use for |
336
+ |----------|---------|
337
+ | `escapeHtmlText(input)` | Element text and fully quoted attribute values |
338
+ | `escapeHtmlAttribute(input)` | Attribute values, including unquoted ones |
339
+ | `sanitizeUrl(url)` | URLs (see Sanitization below) |
340
+ | `sanitizeHtml(html)` | Values meant to *be* markup — DOMPurify |
166
341
 
167
- Validates text is safe from all attack patterns. For use as a schema refinement.
342
+ There is deliberately no JavaScript- or CSS-context escaper: hand-rolled versions are
343
+ reliably wrong, and the fix is to stop interpolating untrusted values into script and
344
+ style source.
168
345
 
169
- #### `validateSafeName(input): boolean`
346
+ #### `validatePersonName(input): boolean`
347
+
348
+ Allowlist of what a name is made of — letters in any script, combining marks,
349
+ apostrophes, hyphens, periods, spaces — plus a length bound and a bidi check. It does
350
+ **not** run SQL, path-traversal, or confusable detectors: a name is not a query, a path,
351
+ or a protected identifier. Encode the value at whatever sink it reaches.
352
+
353
+ #### `validateSafeText(input): boolean`
170
354
 
171
- Validates a name field -- allows international characters but blocks injection patterns.
355
+ Schema-refinement helper over `isSafeInput` with default config.
172
356
 
173
357
  #### `validateSafeEmail(input): boolean`
174
358
 
175
- Validates email format and checks for injection patterns.
359
+ RFC-shaped format check plus UTS #39 analysis of the **domain**, where a mixed-script
360
+ host is the IDN homograph attack.
176
361
 
177
362
  #### `getThreatErrorMessage(result): string`
178
363
 
179
- Returns a human-readable error message for a threat detection result.
364
+ User-facing message for the first finding only — enumerating every category that fired
365
+ leaks the rule set to whoever is probing it. Server-side, log an allowlist of each
366
+ finding's `ruleId`, `type`, `severity` and `cwe` — enough to investigate with. Do not log
367
+ the `ThreatFinding` itself: `matchedPattern` is an excerpt of the input, so for a
368
+ `credential_exposure` or `pii_exposure` hit the log line becomes the leak.
369
+
370
+ #### Deprecated
371
+
372
+ | Deprecated | Replacement |
373
+ |------------|-------------|
374
+ | `detectThreatPatterns(input, config)` | `scanForThreats(input, { contexts })` |
375
+ | `ThreatDetectionConfig` | `ThreatScanOptions.contexts` |
376
+ | `sanitizeForDisplay(input)` | `escapeHtmlText(input)` |
377
+ | `normalizeUnicode(input)` | `getSkeleton` / `analyzeIdentifier` |
378
+ | `validateSafeName(input)` | `validatePersonName(input)` |
379
+
380
+ `isSafeInput` and the legacy toggles still work; each flag maps onto a context
381
+ (`checkXSS` to `html`, `checkSQLInjection` to `sql`, `checkNoSQLInjection` to `nosql`,
382
+ `checkCommandInjection` to `shell`, `checkPathTraversal` to `filesystem`).
180
383
 
181
384
  ### Sanitization (`sanitize.ts`)
182
385
 
@@ -0,0 +1,142 @@
1
+ //#region src/controls/address.d.ts
2
+ /**
3
+ * Copyright 2026 ResQ Systems, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * @fileoverview IP address classification and outbound-URL policy — the SSRF control
19
+ * the detection rules name but cannot themselves provide.
20
+ *
21
+ * The signatures in `threats/rules/system.ts` match literal addresses. A hostname whose
22
+ * DNS record resolves to `169.254.169.254` passes every one of them, which is why those
23
+ * rules point here instead.
24
+ *
25
+ * @module @resq-systems/security/controls/address
26
+ */
27
+ /**
28
+ * What an address is reserved for, per the IANA special-purpose registries.
29
+ *
30
+ * `public` means "in no special-purpose range" — routable on the internet. Every other
31
+ * value is a reason not to fetch it from a server.
32
+ */
33
+ type AddressClassification = "unspecified" | "loopback" | "private" | "link_local" | "carrier_nat" | "multicast" | "broadcast" | "documentation" | "benchmarking" | "unique_local" | "teredo" | "six_to_four" | "nat64" | "reserved" | "public";
34
+ /** Why an outbound URL was refused. */
35
+ type OutboundRejectionReason =
36
+ /** Not parseable as a URL. */
37
+ "malformed" |
38
+ /** Scheme outside the permitted set. */
39
+ "protocol_not_allowed" |
40
+ /** Port outside the permitted set. */
41
+ "port_not_allowed" |
42
+ /** An IP literal in a range that is not publicly routable. */
43
+ "address_not_routable" |
44
+ /** A routable address, or a name, that policy does not permit. */
45
+ "host_not_allowed";
46
+ /** Outcome of {@link assertOutboundUrl}. */
47
+ type OutboundUrlVerdict = {
48
+ readonly allowed: true;
49
+ readonly url: URL;
50
+ /** `null` when the host is a name rather than an IP literal. */
51
+ readonly classification: AddressClassification | null;
52
+ } | {
53
+ readonly allowed: false;
54
+ readonly reason: OutboundRejectionReason;
55
+ };
56
+ /** Policy for {@link assertOutboundUrl}. */
57
+ interface OutboundUrlPolicy {
58
+ /**
59
+ * Hosts permitted regardless of classification, compared case-insensitively against
60
+ * the parsed host. The allowlist the OWASP cheat sheet asks for.
61
+ */
62
+ readonly allowedHosts?: readonly string[];
63
+ /** Schemes permitted. Defaults to `["https:"]`. */
64
+ readonly allowedProtocols?: readonly string[];
65
+ /** Ports permitted in addition to the scheme's default. Defaults to none. */
66
+ readonly allowedPorts?: readonly number[];
67
+ /**
68
+ * Permit any host not in a reserved range — every public address, and every name.
69
+ *
70
+ * Defaults to `false`, and that default is the point. A name is not an address: this
71
+ * function cannot know whether `metadata.example.com` resolves to a public address or
72
+ * to `169.254.169.254`. With no allowlist and this flag off, a name is refused.
73
+ * Turning it on converts the control from an allowlist into a denylist over literals
74
+ * only, which does not stop DNS from pointing inward.
75
+ */
76
+ readonly allowPublicHosts?: boolean;
77
+ }
78
+ /**
79
+ * Classify a host as an IP address range, or `null` when it is not an IP literal.
80
+ *
81
+ * `null` is the answer for every domain name, and a caller must treat it as *unknown*
82
+ * rather than safe — conflating the two is the classic fail-open in this kind of check.
83
+ * {@link assertOutboundUrl} handles it explicitly.
84
+ *
85
+ * IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so
86
+ * `::ffff:169.254.169.254` is `link_local` rather than merely "some IPv6 address". That
87
+ * form matters in practice: `new URL("http://[::ffff:169.254.169.254]/").hostname`
88
+ * returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.
89
+ *
90
+ * @param host - Hostname or IP literal, with or without IPv6 brackets.
91
+ * @returns The classification, or `null` when `host` is not an IP literal.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * classifyAddress("169.254.169.254"); // "link_local"
96
+ * classifyAddress("172.32.0.1"); // "public" — just outside 172.16/12
97
+ * classifyAddress("[::ffff:a9fe:a9fe]"); // "link_local"
98
+ * classifyAddress("metadata.example.com"); // null — a name, not an address
99
+ * ```
100
+ */
101
+ declare function classifyAddress(host: string): AddressClassification | null;
102
+ /**
103
+ * Whether a host is an IP literal in a publicly routable range.
104
+ *
105
+ * @param host - Hostname or IP literal.
106
+ * @returns `true` only for a routable IP literal. A domain name returns `false`, because
107
+ * this function cannot know what it resolves to.
108
+ */
109
+ declare function isPubliclyRoutableAddress(host: string): boolean;
110
+ /**
111
+ * Decide whether a server may fetch a caller-supplied URL.
112
+ *
113
+ * The control the SSRF rules name. Those rules match literal addresses inside a string;
114
+ * this decides whether the request should be made at all.
115
+ *
116
+ * **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit
117
+ * refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so
118
+ * a policy shaped "reject non-public *literals*" silently permits every name — the exact
119
+ * fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`
120
+ * off, a name is refused.
121
+ *
122
+ * **A pre-connection check, and it cannot be more.** The name is resolved by the network
123
+ * stack after this returns, so DNS may answer differently then (rebinding); redirects
124
+ * need the same check applied per hop; neither is closable by a synchronous function.
125
+ * Network-layer egress control remains the durable fix — this narrows the window rather
126
+ * than shutting it.
127
+ *
128
+ * @param candidate - The URL to fetch, as text or a parsed `URL`.
129
+ * @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.
130
+ * @returns A discriminated verdict. Never throws.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: ["hooks.partner.example"] });
135
+ * if (!verdict.allowed) return reject(verdict.reason);
136
+ * await fetch(verdict.url);
137
+ * ```
138
+ */
139
+ declare function assertOutboundUrl(candidate: string | URL, policy?: OutboundUrlPolicy): OutboundUrlVerdict;
140
+ //#endregion
141
+ export { AddressClassification, OutboundRejectionReason, OutboundUrlPolicy, OutboundUrlVerdict, assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress };
142
+ //# sourceMappingURL=address.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.d.mts","names":[],"sources":["../../src/controls/address.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAmCY;;KAkBA;;;;;;;;;;;;KAaA;WAEA;WACA,KAAK;;WAEL,gBAAgB;;WAEd;WAAyB,QAAQ;;;UAG9B;;;;;WAKP;;WAEA;;WAEA;;;;;;;;;;WAUA;;;;;;;;;;;;;;;;;;;;;;;;;iBAiLM,gBAAgB,eAAe;;;;;;;;iBA4B/B,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwC1B,kBACf,oBAAoB,KACpB,SAAQ,oBACN"}