@resq-systems/security 1.0.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -33
- package/lib/controls/address.d.mts +142 -0
- package/lib/controls/address.d.mts.map +1 -0
- package/lib/controls/address.mjs +533 -0
- package/lib/controls/address.mjs.map +1 -0
- package/lib/controls/csrf.d.mts +91 -0
- package/lib/controls/csrf.d.mts.map +1 -0
- package/lib/controls/csrf.mjs +200 -0
- package/lib/controls/csrf.mjs.map +1 -0
- package/lib/controls/index.d.mts +8 -0
- package/lib/controls/index.mjs +8 -0
- package/lib/controls/origin.d.mts +95 -0
- package/lib/controls/origin.d.mts.map +1 -0
- package/lib/controls/origin.mjs +156 -0
- package/lib/controls/origin.mjs.map +1 -0
- package/lib/controls/payload.d.mts +84 -0
- package/lib/controls/payload.d.mts.map +1 -0
- package/lib/controls/payload.mjs +147 -0
- package/lib/controls/payload.mjs.map +1 -0
- package/lib/controls/query.d.mts +169 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +386 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +51 -6
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +51 -6
- package/lib/hash.mjs.map +1 -1
- package/lib/index.d.mts +17 -2
- package/lib/index.mjs +19 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- package/package.json +35 -8
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
//#region src/threats/rules/web.ts
|
|
2
|
+
/** Repeated across every XSS rule — the control that actually prevents the weakness. */
|
|
3
|
+
const XSS_CONTROL = "Escape at the HTML sink; sanitize rich HTML with DOMPurify";
|
|
4
|
+
/** XSS signatures. Meaningful only where the value is interpolated into markup. */
|
|
5
|
+
const XSS_RULES = [
|
|
6
|
+
{
|
|
7
|
+
id: "XSS-SCRIPT-TAG-001",
|
|
8
|
+
type: "xss",
|
|
9
|
+
contexts: ["html", "xml"],
|
|
10
|
+
severity: "high",
|
|
11
|
+
confidence: "high",
|
|
12
|
+
description: "Opening <script> tag",
|
|
13
|
+
cwe: 79,
|
|
14
|
+
primaryControl: XSS_CONTROL,
|
|
15
|
+
pattern: /<\s*script\b/i
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "XSS-EVENT-HANDLER-001",
|
|
19
|
+
type: "xss",
|
|
20
|
+
contexts: ["html", "xml"],
|
|
21
|
+
severity: "high",
|
|
22
|
+
confidence: "high",
|
|
23
|
+
description: "Inline event handler attribute inside a tag",
|
|
24
|
+
cwe: 79,
|
|
25
|
+
primaryControl: XSS_CONTROL,
|
|
26
|
+
pattern: /<[a-z][^>]{0,300}\bon[a-z]{3,20}\s{0,8}=/i
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "XSS-EVENT-HANDLER-002",
|
|
30
|
+
type: "xss",
|
|
31
|
+
contexts: ["html"],
|
|
32
|
+
severity: "medium",
|
|
33
|
+
confidence: "medium",
|
|
34
|
+
description: "Event-handler-shaped assignment to a quoted value",
|
|
35
|
+
cwe: 79,
|
|
36
|
+
primaryControl: XSS_CONTROL,
|
|
37
|
+
pattern: /\bon(?:error|load|click|focus|blur|submit|change|toggle|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12})\s{0,8}=\s{0,8}["'`]/i
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "XSS-URI-SCHEME-001",
|
|
41
|
+
type: "xss",
|
|
42
|
+
contexts: [
|
|
43
|
+
"html",
|
|
44
|
+
"url",
|
|
45
|
+
"xml"
|
|
46
|
+
],
|
|
47
|
+
severity: "high",
|
|
48
|
+
confidence: "medium",
|
|
49
|
+
description: "Script-bearing URI scheme",
|
|
50
|
+
cwe: 79,
|
|
51
|
+
primaryControl: "Allowlist the URL scheme before rendering or navigating",
|
|
52
|
+
pattern: /\b(?:javascript|vbscript|livescript|mocha)\s{0,8}:/i
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "XSS-DATA-URI-001",
|
|
56
|
+
type: "xss",
|
|
57
|
+
contexts: ["html", "url"],
|
|
58
|
+
severity: "high",
|
|
59
|
+
confidence: "high",
|
|
60
|
+
description: "data: URI carrying an active content type",
|
|
61
|
+
cwe: 79,
|
|
62
|
+
primaryControl: "Allowlist the URL scheme before rendering or navigating",
|
|
63
|
+
pattern: /\bdata\s{0,8}:\s{0,8}(?:text\/html|image\/svg\+xml|application\/(?:javascript|xhtml\+xml|ecmascript))/i
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "XSS-DANGEROUS-TAG-001",
|
|
67
|
+
type: "xss",
|
|
68
|
+
contexts: ["html", "xml"],
|
|
69
|
+
severity: "high",
|
|
70
|
+
confidence: "medium",
|
|
71
|
+
description: "Tag that can load or reframe active content",
|
|
72
|
+
cwe: 79,
|
|
73
|
+
primaryControl: XSS_CONTROL,
|
|
74
|
+
pattern: /<\s{0,8}(?:iframe|object|embed|applet|base|meta|link|svg|math|portal|frameset)\b/i
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "XSS-STYLE-EXPRESSION-001",
|
|
78
|
+
type: "xss",
|
|
79
|
+
contexts: ["html"],
|
|
80
|
+
severity: "medium",
|
|
81
|
+
confidence: "medium",
|
|
82
|
+
description: "CSS expression() — legacy IE script execution vector",
|
|
83
|
+
cwe: 79,
|
|
84
|
+
primaryControl: XSS_CONTROL,
|
|
85
|
+
pattern: /\bexpression\s{0,8}\(/i
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "XSS-DOM-SINK-001",
|
|
89
|
+
type: "xss",
|
|
90
|
+
contexts: ["html"],
|
|
91
|
+
severity: "medium",
|
|
92
|
+
confidence: "medium",
|
|
93
|
+
description: "Reference to a credential- or navigation-bearing DOM property",
|
|
94
|
+
cwe: 79,
|
|
95
|
+
primaryControl: XSS_CONTROL,
|
|
96
|
+
pattern: /\b(?:document|window)\s{0,8}\.\s{0,8}(?:cookie|domain|write|writeln|location|open)\b/i
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "XSS-DOM-SINK-002",
|
|
100
|
+
type: "xss",
|
|
101
|
+
contexts: ["html"],
|
|
102
|
+
severity: "medium",
|
|
103
|
+
confidence: "medium",
|
|
104
|
+
description: "Assignment to a raw-markup DOM sink",
|
|
105
|
+
cwe: 79,
|
|
106
|
+
primaryControl: "Assign via textContent, or sanitize with DOMPurify first",
|
|
107
|
+
pattern: /\b(?:innerHTML|outerHTML|srcdoc)\s{0,8}=|\binsertAdjacentHTML\s{0,8}\(/i
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "XSS-DYNAMIC-EVAL-001",
|
|
111
|
+
type: "xss",
|
|
112
|
+
contexts: ["html"],
|
|
113
|
+
severity: "high",
|
|
114
|
+
confidence: "medium",
|
|
115
|
+
description: "Dynamic code evaluation (eval / Function constructor)",
|
|
116
|
+
cwe: 95,
|
|
117
|
+
primaryControl: "Never evaluate strings; a CSP without 'unsafe-eval' blocks this",
|
|
118
|
+
pattern: /\beval\s{0,8}\(|\bnew\s+Function\s{0,8}\(/i
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "XSS-URI-SCHEME-002",
|
|
122
|
+
type: "xss",
|
|
123
|
+
contexts: ["html", "url"],
|
|
124
|
+
severity: "high",
|
|
125
|
+
confidence: "medium",
|
|
126
|
+
description: "Script-bearing URI scheme split by tab, CR, or LF",
|
|
127
|
+
cwe: 79,
|
|
128
|
+
primaryControl: "Allowlist the URL scheme after canonicalization: decode HTML character references, strip ASCII tab/CR/LF, then compare against an allowlist (http, https, mailto)",
|
|
129
|
+
pattern: /(?=[a-z\t\n\r]{0,16}[\t\n\r])j[\t\n\r]{0,8}a[\t\n\r]{0,8}v[\t\n\r]{0,8}a[\t\n\r]{0,8}s[\t\n\r]{0,8}c[\t\n\r]{0,8}r[\t\n\r]{0,8}i[\t\n\r]{0,8}p[\t\n\r]{0,8}t[\t\n\r ]{0,8}:/i
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "XSS-DYNAMIC-EVAL-002",
|
|
133
|
+
type: "xss",
|
|
134
|
+
contexts: ["html"],
|
|
135
|
+
severity: "high",
|
|
136
|
+
confidence: "medium",
|
|
137
|
+
description: "String-argument timer or Function call — an implicit eval sink",
|
|
138
|
+
cwe: 95,
|
|
139
|
+
primaryControl: "Never evaluate strings; a CSP without 'unsafe-eval' blocks this",
|
|
140
|
+
pattern: /\b(?:setTimeout|setInterval|setImmediate|Function)\s{0,8}\(\s{0,8}["'`]|\bexecScript\s{0,8}\(/i
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "XSS-DYNAMIC-EVAL-003",
|
|
144
|
+
type: "xss",
|
|
145
|
+
contexts: ["html"],
|
|
146
|
+
severity: "medium",
|
|
147
|
+
confidence: "medium",
|
|
148
|
+
description: "constructor.constructor chain used to reach the Function constructor",
|
|
149
|
+
cwe: 95,
|
|
150
|
+
primaryControl: "Never evaluate strings; a CSP without 'unsafe-eval' blocks this",
|
|
151
|
+
pattern: /\bconstructor\s{0,8}(?:\.\s{0,8}constructor|\[\s{0,8}["'`]constructor)/i
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "XSS-EVENT-HANDLER-003",
|
|
155
|
+
type: "xss",
|
|
156
|
+
contexts: ["html"],
|
|
157
|
+
severity: "high",
|
|
158
|
+
confidence: "medium",
|
|
159
|
+
description: "Event handler introduced by breaking out of a quoted attribute",
|
|
160
|
+
cwe: 79,
|
|
161
|
+
primaryControl: XSS_CONTROL,
|
|
162
|
+
pattern: /["'`]\s{0,8}on(?:error|load|click|focus|blur|submit|change|toggle|wheel|scroll|input|select|paste|copy|cut|contextmenu|auxclick|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12}|drag[a-z]{0,10}|touch[a-z]{2,10})\s{0,8}=/i
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "XSS-EVENT-HANDLER-004",
|
|
166
|
+
type: "xss",
|
|
167
|
+
contexts: ["html"],
|
|
168
|
+
severity: "high",
|
|
169
|
+
confidence: "medium",
|
|
170
|
+
description: "Unquoted event handler assigned a callable expression",
|
|
171
|
+
cwe: 79,
|
|
172
|
+
primaryControl: XSS_CONTROL,
|
|
173
|
+
pattern: /(?:^|[^.\w-])on(?:error|load|click|focus|blur|submit|change|toggle|wheel|scroll|input|select|paste|copy|cut|contextmenu|auxclick|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12}|drag[a-z]{0,10}|touch[a-z]{2,10})=\s{0,8}[\w$.[\]]{1,40}\s{0,8}[(`]/i
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "XSS-FORM-TAG-001",
|
|
177
|
+
type: "xss",
|
|
178
|
+
contexts: ["html"],
|
|
179
|
+
severity: "high",
|
|
180
|
+
confidence: "medium",
|
|
181
|
+
description: "Injected form or input element — credential-harvesting overlay",
|
|
182
|
+
cwe: 79,
|
|
183
|
+
primaryControl: XSS_CONTROL,
|
|
184
|
+
variants: [
|
|
185
|
+
"raw",
|
|
186
|
+
"nfc",
|
|
187
|
+
"nfkc",
|
|
188
|
+
"percent_decoded"
|
|
189
|
+
],
|
|
190
|
+
pattern: /<\s{0,8}(?:form|input|textarea|button|select|keygen|isindex)\b/i
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "XSS-STYLE-TAG-001",
|
|
194
|
+
type: "xss",
|
|
195
|
+
contexts: ["html"],
|
|
196
|
+
severity: "medium",
|
|
197
|
+
confidence: "medium",
|
|
198
|
+
description: "Injected style element",
|
|
199
|
+
cwe: 79,
|
|
200
|
+
primaryControl: XSS_CONTROL,
|
|
201
|
+
variants: [
|
|
202
|
+
"raw",
|
|
203
|
+
"nfc",
|
|
204
|
+
"nfkc",
|
|
205
|
+
"percent_decoded"
|
|
206
|
+
],
|
|
207
|
+
pattern: /<\s{0,8}style\b/i
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "XSS-DANGLING-MARKUP-001",
|
|
211
|
+
type: "xss",
|
|
212
|
+
contexts: ["html"],
|
|
213
|
+
severity: "high",
|
|
214
|
+
confidence: "medium",
|
|
215
|
+
description: "Unterminated resource attribute — exfiltrates the following markup",
|
|
216
|
+
cwe: 79,
|
|
217
|
+
primaryControl: XSS_CONTROL,
|
|
218
|
+
pattern: /\b(?:src|href|action|formaction|poster|background)\s{0,8}=\s{0,8}["'][^"'<>]{0,300}$/i
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
/** Repeated across the prototype-pollution rules. */
|
|
222
|
+
const PROTO_CONTROL = "Reject unknown keys via schema validation; use Object.create(null) maps and a merge that skips __proto__/constructor/prototype";
|
|
223
|
+
/**
|
|
224
|
+
* Prototype-pollution signatures.
|
|
225
|
+
*
|
|
226
|
+
* Deliberately its own {@link ThreatRule.type} rather than a subcategory of XSS:
|
|
227
|
+
* this is an object-graph manipulation weakness, not a markup one, and it is fixed
|
|
228
|
+
* by different controls (`Object.create(null)` dictionaries, schema validation that
|
|
229
|
+
* rejects unknown keys, merge helpers that skip inherited and forbidden keys).
|
|
230
|
+
*
|
|
231
|
+
* The patterns match structural property paths, so `__proto__` reached via bracket
|
|
232
|
+
* notation or as a quoted JSON key is caught while prose that merely mentions the
|
|
233
|
+
* word is not.
|
|
234
|
+
*/
|
|
235
|
+
const PROTOTYPE_POLLUTION_RULES = [
|
|
236
|
+
{
|
|
237
|
+
id: "PROTO-POLLUTION-PROTO-001",
|
|
238
|
+
type: "prototype_pollution",
|
|
239
|
+
contexts: [
|
|
240
|
+
"object_merge",
|
|
241
|
+
"nosql",
|
|
242
|
+
"url",
|
|
243
|
+
"template"
|
|
244
|
+
],
|
|
245
|
+
severity: "critical",
|
|
246
|
+
confidence: "high",
|
|
247
|
+
description: "__proto__ used as a structural property path segment",
|
|
248
|
+
cwe: 1321,
|
|
249
|
+
primaryControl: PROTO_CONTROL,
|
|
250
|
+
pattern: /(?:^|[.[\]"'])__proto__(?:$|[.[\]"'])/
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "PROTO-POLLUTION-PROTOTYPE-001",
|
|
254
|
+
type: "prototype_pollution",
|
|
255
|
+
contexts: [
|
|
256
|
+
"object_merge",
|
|
257
|
+
"nosql",
|
|
258
|
+
"url",
|
|
259
|
+
"template"
|
|
260
|
+
],
|
|
261
|
+
severity: "high",
|
|
262
|
+
confidence: "medium",
|
|
263
|
+
description: "'prototype' used as a structural property path segment",
|
|
264
|
+
cwe: 1321,
|
|
265
|
+
primaryControl: PROTO_CONTROL,
|
|
266
|
+
pattern: /(?:^|[.[\]"'])prototype(?:$|[.[\]"'])/
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "PROTO-POLLUTION-CONSTRUCTOR-001",
|
|
270
|
+
type: "prototype_pollution",
|
|
271
|
+
contexts: [
|
|
272
|
+
"object_merge",
|
|
273
|
+
"nosql",
|
|
274
|
+
"url",
|
|
275
|
+
"template"
|
|
276
|
+
],
|
|
277
|
+
severity: "critical",
|
|
278
|
+
confidence: "high",
|
|
279
|
+
description: "constructor.prototype access chain",
|
|
280
|
+
cwe: 1321,
|
|
281
|
+
primaryControl: PROTO_CONTROL,
|
|
282
|
+
pattern: /\bconstructor\s{0,8}(?:\.|\[)\s{0,8}["']?prototype\b|["']constructor["']\s{0,8}:\s{0,8}\{\s{0,8}["']prototype["']/i
|
|
283
|
+
}
|
|
284
|
+
];
|
|
285
|
+
/** Repeated across the header-injection rules. */
|
|
286
|
+
const HEADER_CONTROL = "Use a header API that rejects CR/LF; never concatenate raw header strings";
|
|
287
|
+
/** CRLF injection into HTTP or SMTP header values (response splitting). */
|
|
288
|
+
const HEADER_INJECTION_RULES = [
|
|
289
|
+
{
|
|
290
|
+
id: "HEADER-CRLF-001",
|
|
291
|
+
type: "header_injection",
|
|
292
|
+
contexts: ["http_header"],
|
|
293
|
+
severity: "high",
|
|
294
|
+
confidence: "high",
|
|
295
|
+
description: "Raw CR or LF in a header value",
|
|
296
|
+
cwe: 113,
|
|
297
|
+
primaryControl: HEADER_CONTROL,
|
|
298
|
+
pattern: /[\r\n]/
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "HEADER-CRLF-ENCODED-001",
|
|
302
|
+
type: "header_injection",
|
|
303
|
+
contexts: ["http_header", "url"],
|
|
304
|
+
severity: "high",
|
|
305
|
+
confidence: "medium",
|
|
306
|
+
description: "Percent-encoded CR/LF or NUL, decodable into a header break",
|
|
307
|
+
cwe: 113,
|
|
308
|
+
primaryControl: HEADER_CONTROL,
|
|
309
|
+
pattern: /%0d|%0a|%00/i,
|
|
310
|
+
variants: ["raw"]
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "HEADER-SMUGGLED-NAME-001",
|
|
314
|
+
type: "header_injection",
|
|
315
|
+
contexts: ["http_header"],
|
|
316
|
+
severity: "critical",
|
|
317
|
+
confidence: "high",
|
|
318
|
+
description: "Line break followed by a security-relevant header name",
|
|
319
|
+
cwe: 113,
|
|
320
|
+
primaryControl: HEADER_CONTROL,
|
|
321
|
+
pattern: /[\r\n]\s{0,8}(?:set-cookie|location|content-length|transfer-encoding|host|authorization|bcc|cc|to)\s{0,8}:/i
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "HEADER-UNICODE-LINEBREAK-001",
|
|
325
|
+
type: "header_injection",
|
|
326
|
+
contexts: ["http_header", "log"],
|
|
327
|
+
severity: "high",
|
|
328
|
+
confidence: "high",
|
|
329
|
+
description: "Unicode line break — treated as a newline by some parsers and loggers",
|
|
330
|
+
cwe: 113,
|
|
331
|
+
primaryControl: HEADER_CONTROL,
|
|
332
|
+
pattern: /[\u2028\u2029\u0085]/
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "HEADER-CRLF-OVERLONG-001",
|
|
336
|
+
type: "header_injection",
|
|
337
|
+
contexts: ["http_header", "url"],
|
|
338
|
+
severity: "high",
|
|
339
|
+
confidence: "high",
|
|
340
|
+
description: "Overlong or Unicode-squeezed encoding of CR/LF",
|
|
341
|
+
cwe: 113,
|
|
342
|
+
primaryControl: HEADER_CONTROL,
|
|
343
|
+
variants: ["raw"],
|
|
344
|
+
pattern: /%e5%98%8[ad]|%c0%8[ad]|%c1%8[ad]/i
|
|
345
|
+
}
|
|
346
|
+
];
|
|
347
|
+
/** Repeated across the formula-injection rules. */
|
|
348
|
+
const FORMULA_CONTROL = "Escape exported cells with escapeCsvField / toCsvRow before writing the file";
|
|
349
|
+
/** CSV/XLSX formula injection — the payload executes in the *recipient's* spreadsheet. */
|
|
350
|
+
const FORMULA_INJECTION_RULES = [{
|
|
351
|
+
id: "CSV-FORMULA-LEAD-001",
|
|
352
|
+
type: "formula_injection",
|
|
353
|
+
contexts: ["spreadsheet"],
|
|
354
|
+
severity: "high",
|
|
355
|
+
confidence: "medium",
|
|
356
|
+
description: "Cell value begins with a formula-trigger character",
|
|
357
|
+
cwe: 1236,
|
|
358
|
+
primaryControl: FORMULA_CONTROL,
|
|
359
|
+
pattern: /^[\s'"]{0,8}[=+\-@\t\r]/
|
|
360
|
+
}, {
|
|
361
|
+
id: "CSV-DDE-001",
|
|
362
|
+
type: "formula_injection",
|
|
363
|
+
contexts: ["spreadsheet"],
|
|
364
|
+
severity: "critical",
|
|
365
|
+
confidence: "high",
|
|
366
|
+
description: "Spreadsheet function capable of remote fetch or command execution",
|
|
367
|
+
cwe: 1236,
|
|
368
|
+
primaryControl: FORMULA_CONTROL,
|
|
369
|
+
pattern: /\b(?:DDE|DDEAUTO|WEBSERVICE|HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTFEED|IMPORTHTML|IMPORTRANGE|RTD)\s{0,8}\(/i
|
|
370
|
+
}];
|
|
371
|
+
/** Repeated across the log-injection rules. */
|
|
372
|
+
const LOG_CONTROL = "Log structured records; encode field values with encodeLogValue before writing them";
|
|
373
|
+
/** Log injection — forged entries, and terminal escape sequences in logged content. */
|
|
374
|
+
const LOG_INJECTION_RULES = [
|
|
375
|
+
{
|
|
376
|
+
id: "LOG-CRLF-001",
|
|
377
|
+
type: "log_injection",
|
|
378
|
+
contexts: ["log"],
|
|
379
|
+
severity: "medium",
|
|
380
|
+
confidence: "medium",
|
|
381
|
+
description: "Line break in a value written to a line-based log",
|
|
382
|
+
cwe: 117,
|
|
383
|
+
primaryControl: LOG_CONTROL,
|
|
384
|
+
pattern: /[\r\n]/
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "LOG-FORGED-ENTRY-001",
|
|
388
|
+
type: "log_injection",
|
|
389
|
+
contexts: ["log"],
|
|
390
|
+
severity: "high",
|
|
391
|
+
confidence: "medium",
|
|
392
|
+
description: "Line break followed by a forged log-level marker",
|
|
393
|
+
cwe: 117,
|
|
394
|
+
primaryControl: LOG_CONTROL,
|
|
395
|
+
pattern: /[\r\n]\s{0,8}[[{(]?\s{0,8}(?:INFO|WARN|WARNING|ERROR|DEBUG|FATAL|TRACE|CRITICAL)\b/i
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
id: "LOG-ANSI-ESCAPE-001",
|
|
399
|
+
type: "log_injection",
|
|
400
|
+
contexts: ["log"],
|
|
401
|
+
severity: "medium",
|
|
402
|
+
confidence: "high",
|
|
403
|
+
description: "ANSI/terminal escape sequence in logged content",
|
|
404
|
+
cwe: 117,
|
|
405
|
+
primaryControl: "Encode with encodeLogValue, or strip with stripAnsi when the rendering matters more than the record",
|
|
406
|
+
pattern: /\u001b\[[0-9;?]{0,32}[\x40-\x7e]/
|
|
407
|
+
}
|
|
408
|
+
];
|
|
409
|
+
//#endregion
|
|
410
|
+
export { FORMULA_INJECTION_RULES, HEADER_INJECTION_RULES, LOG_INJECTION_RULES, PROTOTYPE_POLLUTION_RULES, XSS_RULES };
|
|
411
|
+
|
|
412
|
+
//# sourceMappingURL=web.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.mjs","names":[],"sources":["../../../src/threats/rules/web.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 Browser- and protocol-facing signatures: XSS, prototype pollution,\n * HTTP/SMTP header injection (CRLF), spreadsheet formula injection, and log forging.\n *\n * None of these rules is the control that stops the attack. XSS is stopped by\n * context-correct output encoding plus a parser-based sanitizer; prototype pollution\n * by schema validation that rejects unknown keys and by null-prototype dictionaries;\n * header injection by a header API that refuses CR/LF; formula injection by prefixing\n * exported cells; log forging by structured logging that encodes field values.\n *\n * @module @resq-systems/security/threats/rules/web\n */\n\nimport type { ThreatRule } from \"../types.js\";\n\n//#region Cross-site scripting\n\n/** Repeated across every XSS rule — the control that actually prevents the weakness. */\nconst XSS_CONTROL = \"Escape at the HTML sink; sanitize rich HTML with DOMPurify\";\n\n/** XSS signatures. Meaningful only where the value is interpolated into markup. */\nexport const XSS_RULES: readonly ThreatRule[] = [\n\t{\n\t\tid: \"XSS-SCRIPT-TAG-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"xml\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Opening <script> tag\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\t// Opening tag only — a greedy `<script>[\\s\\S]*</script>` invites backtracking\n\t\t// blowups on crafted input and adds no detection value.\n\t\tpattern: /<\\s*script\\b/i,\n\t},\n\t{\n\t\tid: \"XSS-EVENT-HANDLER-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"xml\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Inline event handler attribute inside a tag\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern: /<[a-z][^>]{0,300}\\bon[a-z]{3,20}\\s{0,8}=/i,\n\t},\n\t{\n\t\tid: \"XSS-EVENT-HANDLER-002\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Event-handler-shaped assignment to a quoted value\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern:\n\t\t\t/\\bon(?:error|load|click|focus|blur|submit|change|toggle|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12})\\s{0,8}=\\s{0,8}[\"'`]/i,\n\t},\n\t{\n\t\tid: \"XSS-URI-SCHEME-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"url\", \"xml\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Script-bearing URI scheme\",\n\t\tcwe: 79,\n\t\tprimaryControl: \"Allowlist the URL scheme before rendering or navigating\",\n\t\tpattern: /\\b(?:javascript|vbscript|livescript|mocha)\\s{0,8}:/i,\n\t},\n\t{\n\t\tid: \"XSS-DATA-URI-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"url\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"data: URI carrying an active content type\",\n\t\tcwe: 79,\n\t\tprimaryControl: \"Allowlist the URL scheme before rendering or navigating\",\n\t\tpattern:\n\t\t\t/\\bdata\\s{0,8}:\\s{0,8}(?:text\\/html|image\\/svg\\+xml|application\\/(?:javascript|xhtml\\+xml|ecmascript))/i,\n\t},\n\t{\n\t\tid: \"XSS-DANGEROUS-TAG-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"xml\"],\n\t\t// High rather than medium: these tags load or reframe active content, which\n\t\t// buys clickjacking, phishing overlays, and drive-by loads even without script\n\t\t// execution. Confidence stays medium because a rich-text field may legitimately\n\t\t// permit some of them — that is what `excludeRuleIds` is for.\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Tag that can load or reframe active content\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern: /<\\s{0,8}(?:iframe|object|embed|applet|base|meta|link|svg|math|portal|frameset)\\b/i,\n\t},\n\t{\n\t\tid: \"XSS-STYLE-EXPRESSION-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"CSS expression() — legacy IE script execution vector\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern: /\\bexpression\\s{0,8}\\(/i,\n\t},\n\t{\n\t\tid: \"XSS-DOM-SINK-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Reference to a credential- or navigation-bearing DOM property\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern:\n\t\t\t/\\b(?:document|window)\\s{0,8}\\.\\s{0,8}(?:cookie|domain|write|writeln|location|open)\\b/i,\n\t},\n\t{\n\t\tid: \"XSS-DOM-SINK-002\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Assignment to a raw-markup DOM sink\",\n\t\tcwe: 79,\n\t\tprimaryControl: \"Assign via textContent, or sanitize with DOMPurify first\",\n\t\tpattern: /\\b(?:innerHTML|outerHTML|srcdoc)\\s{0,8}=|\\binsertAdjacentHTML\\s{0,8}\\(/i,\n\t},\n\t{\n\t\tid: \"XSS-DYNAMIC-EVAL-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Dynamic code evaluation (eval / Function constructor)\",\n\t\tcwe: 95,\n\t\tprimaryControl: \"Never evaluate strings; a CSP without 'unsafe-eval' blocks this\",\n\t\tpattern: /\\beval\\s{0,8}\\(|\\bnew\\s+Function\\s{0,8}\\(/i,\n\t},\n\t{\n\t\tid: \"XSS-URI-SCHEME-002\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\", \"url\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Script-bearing URI scheme split by tab, CR, or LF\",\n\t\tcwe: 79,\n\t\tprimaryControl:\n\t\t\t\"Allowlist the URL scheme after canonicalization: decode HTML character references, strip ASCII tab/CR/LF, then compare against an allowlist (http, https, mailto)\",\n\t\t// XSS-URI-SCHEME-001 allows whitespace only *between* the token and the colon,\n\t\t// so `jav	ascript:alert(1)` scanned clean and still executes in current\n\t\t// browsers — the WHATWG URL parser strips exactly tab, CR, and LF from a scheme.\n\t\t// The leading lookahead requires an intra-token separator, so a plain\n\t\t// `javascript:` is not double-scored by this rule and -001 together.\n\t\tpattern:\n\t\t\t/(?=[a-z\\t\\n\\r]{0,16}[\\t\\n\\r])j[\\t\\n\\r]{0,8}a[\\t\\n\\r]{0,8}v[\\t\\n\\r]{0,8}a[\\t\\n\\r]{0,8}s[\\t\\n\\r]{0,8}c[\\t\\n\\r]{0,8}r[\\t\\n\\r]{0,8}i[\\t\\n\\r]{0,8}p[\\t\\n\\r]{0,8}t[\\t\\n\\r ]{0,8}:/i,\n\t},\n\t{\n\t\tid: \"XSS-DYNAMIC-EVAL-002\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\t// Graded to match XSS-DYNAMIC-EVAL-001: a string-argument timer is\n\t\t// eval-equivalent, so the same severity applies. Confidence stays medium\n\t\t// because the token appears in ordinary code discussion.\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"String-argument timer or Function call — an implicit eval sink\",\n\t\tcwe: 95,\n\t\tprimaryControl: \"Never evaluate strings; a CSP without 'unsafe-eval' blocks this\",\n\t\tpattern:\n\t\t\t/\\b(?:setTimeout|setInterval|setImmediate|Function)\\s{0,8}\\(\\s{0,8}[\"'`]|\\bexecScript\\s{0,8}\\(/i,\n\t},\n\t{\n\t\tid: \"XSS-DYNAMIC-EVAL-003\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"constructor.constructor chain used to reach the Function constructor\",\n\t\tcwe: 95,\n\t\tprimaryControl: \"Never evaluate strings; a CSP without 'unsafe-eval' blocks this\",\n\t\tpattern: /\\bconstructor\\s{0,8}(?:\\.\\s{0,8}constructor|\\[\\s{0,8}[\"'`]constructor)/i,\n\t},\n\t{\n\t\tid: \"XSS-EVENT-HANDLER-003\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Event handler introduced by breaking out of a quoted attribute\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\t// XSS-EVENT-HANDLER-001 needs a preceding `<tag`; -002 needs a quote *after*\n\t\t// the `=`. An attribute-breakout payload (`\" onmouseover=alert(1) x=\"`) has\n\t\t// neither, so it scanned clean while its quoted twin scored 2 — the pair was\n\t\t// inconsistent precisely on the harder case.\n\t\t// Handler names are an explicit allowlist rather than `on[a-z]{3,20}`, so\n\t\t// `\"onboarding=\"` and `\"onlineStatus=\"` cannot trip it.\n\t\tpattern:\n\t\t\t/[\"'`]\\s{0,8}on(?:error|load|click|focus|blur|submit|change|toggle|wheel|scroll|input|select|paste|copy|cut|contextmenu|auxclick|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12}|drag[a-z]{0,10}|touch[a-z]{2,10})\\s{0,8}=/i,\n\t},\n\t{\n\t\tid: \"XSS-EVENT-HANDLER-004\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\t// High, matching every other event-handler rule: an inline handler assigned a\n\t\t// call expression *is* script execution. Confidence stays medium because the\n\t\t// same text appears in escaped documentation.\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Unquoted event handler assigned a callable expression\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\t// The `(?:^|[^.\\w-])` prefix is a consuming class rather than a lookbehind, to\n\t\t// match catalog style; it excludes ordinary property assignment (`el.onclick=`).\n\t\t// Whitespace before `=` is forbidden, which kills prose false positives such as\n\t\t// \"onload = init()\" at the cost of missing spaced payloads into unquoted sinks.\n\t\tpattern:\n\t\t\t/(?:^|[^.\\w-])on(?:error|load|click|focus|blur|submit|change|toggle|wheel|scroll|input|select|paste|copy|cut|contextmenu|auxclick|mouse[a-z]{2,12}|key[a-z]{2,6}|animation[a-z]{3,10}|pointer[a-z]{2,12}|drag[a-z]{0,10}|touch[a-z]{2,10})=\\s{0,8}[\\w$.[\\]]{1,40}\\s{0,8}[(`]/i,\n\t},\n\t{\n\t\tid: \"XSS-FORM-TAG-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Injected form or input element — credential-harvesting overlay\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\t// Must exclude `html_decoded`: without that it fires on any CMS body that\n\t\t// documents HTML in escaped form (`<form action=\"/login\">`). An\n\t\t// entity-encoded form tag is already neutralized — the encoding *is* the\n\t\t// control. Percent-encoded evasion stays covered.\n\t\tvariants: [\"raw\", \"nfc\", \"nfkc\", \"percent_decoded\"],\n\t\tpattern: /<\\s{0,8}(?:form|input|textarea|button|select|keygen|isindex)\\b/i,\n\t},\n\t{\n\t\tid: \"XSS-STYLE-TAG-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Injected style element\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tvariants: [\"raw\", \"nfc\", \"nfkc\", \"percent_decoded\"],\n\t\tpattern: /<\\s{0,8}style\\b/i,\n\t},\n\t{\n\t\tid: \"XSS-DANGLING-MARKUP-001\",\n\t\ttype: \"xss\",\n\t\tcontexts: [\"html\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Unterminated resource attribute — exfiltrates the following markup\",\n\t\tcwe: 79,\n\t\tprimaryControl: XSS_CONTROL,\n\t\tpattern:\n\t\t\t/\\b(?:src|href|action|formaction|poster|background)\\s{0,8}=\\s{0,8}[\"'][^\"'<>]{0,300}$/i,\n\t},\n];\n\n//#endregion\n\n//#region Prototype pollution\n\n/** Repeated across the prototype-pollution rules. */\nconst PROTO_CONTROL =\n\t\"Reject unknown keys via schema validation; use Object.create(null) maps and a merge that skips __proto__/constructor/prototype\";\n\n/**\n * Prototype-pollution signatures.\n *\n * Deliberately its own {@link ThreatRule.type} rather than a subcategory of XSS:\n * this is an object-graph manipulation weakness, not a markup one, and it is fixed\n * by different controls (`Object.create(null)` dictionaries, schema validation that\n * rejects unknown keys, merge helpers that skip inherited and forbidden keys).\n *\n * The patterns match structural property paths, so `__proto__` reached via bracket\n * notation or as a quoted JSON key is caught while prose that merely mentions the\n * word is not.\n */\nexport const PROTOTYPE_POLLUTION_RULES: readonly ThreatRule[] = [\n\t{\n\t\tid: \"PROTO-POLLUTION-PROTO-001\",\n\t\ttype: \"prototype_pollution\",\n\t\tcontexts: [\"object_merge\", \"nosql\", \"url\", \"template\"],\n\t\tseverity: \"critical\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"__proto__ used as a structural property path segment\",\n\t\tcwe: 1321,\n\t\tprimaryControl: PROTO_CONTROL,\n\t\tpattern: /(?:^|[.[\\]\"'])__proto__(?:$|[.[\\]\"'])/,\n\t},\n\t{\n\t\tid: \"PROTO-POLLUTION-PROTOTYPE-001\",\n\t\ttype: \"prototype_pollution\",\n\t\tcontexts: [\"object_merge\", \"nosql\", \"url\", \"template\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"'prototype' used as a structural property path segment\",\n\t\tcwe: 1321,\n\t\tprimaryControl: PROTO_CONTROL,\n\t\tpattern: /(?:^|[.[\\]\"'])prototype(?:$|[.[\\]\"'])/,\n\t},\n\t{\n\t\tid: \"PROTO-POLLUTION-CONSTRUCTOR-001\",\n\t\ttype: \"prototype_pollution\",\n\t\tcontexts: [\"object_merge\", \"nosql\", \"url\", \"template\"],\n\t\tseverity: \"critical\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"constructor.prototype access chain\",\n\t\tcwe: 1321,\n\t\tprimaryControl: PROTO_CONTROL,\n\t\t// Two shapes: property/bracket access (`constructor.prototype`,\n\t\t// `constructor[\"prototype\"]`) and the nested-object form that arrives through\n\t\t// a JSON body or query-string expansion (`\"constructor\":{\"prototype\":…}`).\n\t\tpattern:\n\t\t\t/\\bconstructor\\s{0,8}(?:\\.|\\[)\\s{0,8}[\"']?prototype\\b|[\"']constructor[\"']\\s{0,8}:\\s{0,8}\\{\\s{0,8}[\"']prototype[\"']/i,\n\t},\n];\n\n//#endregion\n\n//#region Header injection\n\n/** Repeated across the header-injection rules. */\nconst HEADER_CONTROL = \"Use a header API that rejects CR/LF; never concatenate raw header strings\";\n\n/** CRLF injection into HTTP or SMTP header values (response splitting). */\nexport const HEADER_INJECTION_RULES: readonly ThreatRule[] = [\n\t{\n\t\tid: \"HEADER-CRLF-001\",\n\t\ttype: \"header_injection\",\n\t\tcontexts: [\"http_header\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Raw CR or LF in a header value\",\n\t\tcwe: 113,\n\t\tprimaryControl: HEADER_CONTROL,\n\t\tpattern: /[\\r\\n]/,\n\t},\n\t{\n\t\tid: \"HEADER-CRLF-ENCODED-001\",\n\t\ttype: \"header_injection\",\n\t\tcontexts: [\"http_header\", \"url\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Percent-encoded CR/LF or NUL, decodable into a header break\",\n\t\tcwe: 113,\n\t\tprimaryControl: HEADER_CONTROL,\n\t\tpattern: /%0d|%0a|%00/i,\n\t\tvariants: [\"raw\"],\n\t},\n\t{\n\t\tid: \"HEADER-SMUGGLED-NAME-001\",\n\t\ttype: \"header_injection\",\n\t\tcontexts: [\"http_header\"],\n\t\tseverity: \"critical\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Line break followed by a security-relevant header name\",\n\t\tcwe: 113,\n\t\tprimaryControl: HEADER_CONTROL,\n\t\tpattern:\n\t\t\t/[\\r\\n]\\s{0,8}(?:set-cookie|location|content-length|transfer-encoding|host|authorization|bcc|cc|to)\\s{0,8}:/i,\n\t},\n\t{\n\t\tid: \"HEADER-UNICODE-LINEBREAK-001\",\n\t\ttype: \"header_injection\",\n\t\tcontexts: [\"http_header\", \"log\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Unicode line break — treated as a newline by some parsers and loggers\",\n\t\tcwe: 113,\n\t\tprimaryControl: HEADER_CONTROL,\n\t\t// U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR, U+0085 NEL. None is matched\n\t\t// by `[\\r\\n]`, yet each terminates a line in JS string literals, in several log\n\t\t// processors, and in some header parsers. All three scanned clean before this.\n\t\tpattern: /[\\u2028\\u2029\\u0085]/,\n\t},\n\t{\n\t\tid: \"HEADER-CRLF-OVERLONG-001\",\n\t\ttype: \"header_injection\",\n\t\tcontexts: [\"http_header\", \"url\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Overlong or Unicode-squeezed encoding of CR/LF\",\n\t\tcwe: 113,\n\t\tprimaryControl: HEADER_CONTROL,\n\t\t// `%e5%98%8a` is U+560A, whose low byte is 0x0A: any component that narrows\n\t\t// UTF-16 to a byte emits a bare LF. `%c0%8a` is the overlong UTF-8 form of the\n\t\t// same byte. Both slip past a filter that only looks for `%0a`.\n\t\tvariants: [\"raw\"],\n\t\tpattern: /%e5%98%8[ad]|%c0%8[ad]|%c1%8[ad]/i,\n\t},\n];\n\n//#endregion\n\n//#region Spreadsheet formula injection\n\n/** Repeated across the formula-injection rules. */\nconst FORMULA_CONTROL =\n\t\"Escape exported cells with escapeCsvField / toCsvRow before writing the file\";\n\n/** CSV/XLSX formula injection — the payload executes in the *recipient's* spreadsheet. */\nexport const FORMULA_INJECTION_RULES: readonly ThreatRule[] = [\n\t{\n\t\tid: \"CSV-FORMULA-LEAD-001\",\n\t\ttype: \"formula_injection\",\n\t\tcontexts: [\"spreadsheet\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Cell value begins with a formula-trigger character\",\n\t\tcwe: 1236,\n\t\tprimaryControl: FORMULA_CONTROL,\n\t\tpattern: /^[\\s'\"]{0,8}[=+\\-@\\t\\r]/,\n\t},\n\t{\n\t\tid: \"CSV-DDE-001\",\n\t\ttype: \"formula_injection\",\n\t\tcontexts: [\"spreadsheet\"],\n\t\tseverity: \"critical\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"Spreadsheet function capable of remote fetch or command execution\",\n\t\tcwe: 1236,\n\t\tprimaryControl: FORMULA_CONTROL,\n\t\tpattern:\n\t\t\t/\\b(?:DDE|DDEAUTO|WEBSERVICE|HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTFEED|IMPORTHTML|IMPORTRANGE|RTD)\\s{0,8}\\(/i,\n\t},\n];\n\n//#endregion\n\n//#region Log forging\n\n/** Repeated across the log-injection rules. */\nconst LOG_CONTROL =\n\t\"Log structured records; encode field values with encodeLogValue before writing them\";\n\n/** Log injection — forged entries, and terminal escape sequences in logged content. */\nexport const LOG_INJECTION_RULES: readonly ThreatRule[] = [\n\t{\n\t\tid: \"LOG-CRLF-001\",\n\t\ttype: \"log_injection\",\n\t\tcontexts: [\"log\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Line break in a value written to a line-based log\",\n\t\tcwe: 117,\n\t\tprimaryControl: LOG_CONTROL,\n\t\tpattern: /[\\r\\n]/,\n\t},\n\t{\n\t\tid: \"LOG-FORGED-ENTRY-001\",\n\t\ttype: \"log_injection\",\n\t\tcontexts: [\"log\"],\n\t\tseverity: \"high\",\n\t\tconfidence: \"medium\",\n\t\tdescription: \"Line break followed by a forged log-level marker\",\n\t\tcwe: 117,\n\t\tprimaryControl: LOG_CONTROL,\n\t\tpattern: /[\\r\\n]\\s{0,8}[[{(]?\\s{0,8}(?:INFO|WARN|WARNING|ERROR|DEBUG|FATAL|TRACE|CRITICAL)\\b/i,\n\t},\n\t{\n\t\tid: \"LOG-ANSI-ESCAPE-001\",\n\t\ttype: \"log_injection\",\n\t\tcontexts: [\"log\"],\n\t\tseverity: \"medium\",\n\t\tconfidence: \"high\",\n\t\tdescription: \"ANSI/terminal escape sequence in logged content\",\n\t\tcwe: 117,\n\t\tprimaryControl:\n\t\t\t\"Encode with encodeLogValue, or strip with stripAnsi when the rendering matters more than the record\",\n\t\t// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI sequences are defined by the ESC control character\n\t\tpattern: /\\u001b\\[[0-9;?]{0,32}[\\x40-\\x7e]/,\n\t},\n];\n\n//#endregion\n"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ThreatFinding, ThreatPolicy, ThreatType, ThreatVerdict } from "./types.mjs";
|
|
2
|
+
//#region src/threats/scoring.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Points a single finding contributes: severity weight scaled by confidence.
|
|
5
|
+
*
|
|
6
|
+
* @param finding - The finding to weigh.
|
|
7
|
+
* @returns A non-negative score contribution.
|
|
8
|
+
*/
|
|
9
|
+
declare function scoreForFinding(finding: ThreatFinding): number;
|
|
10
|
+
/**
|
|
11
|
+
* Total anomaly score for a set of findings.
|
|
12
|
+
*
|
|
13
|
+
* Each **rule** counts once no matter how many variants it matched in. Without that
|
|
14
|
+
* collapse a payload written as `%3Cscript%3E` would score twice — once for `raw` and
|
|
15
|
+
* once for `percent_decoded` — penalizing an input for being encoded rather than for
|
|
16
|
+
* what it contains.
|
|
17
|
+
*
|
|
18
|
+
* @param findings - Findings from one scan.
|
|
19
|
+
* @returns The summed score; `0` for an empty list.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* // one high/high finding (4 × 1.5) plus one medium/low finding (2 × 0.5)
|
|
24
|
+
* calculateThreatScore(findings); // 7
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
declare function calculateThreatScore(findings: readonly ThreatFinding[]): number;
|
|
28
|
+
/**
|
|
29
|
+
* Map a score onto a policy band.
|
|
30
|
+
*
|
|
31
|
+
* @param score - Output of {@link calculateThreatScore}.
|
|
32
|
+
* @param policy - Thresholds. Defaults to {@link DEFAULT_THREAT_POLICY} — `review`
|
|
33
|
+
* at 4, `block` at 8.
|
|
34
|
+
* @returns `"allow"`, `"review"`, or `"block"`.
|
|
35
|
+
*/
|
|
36
|
+
declare function verdictForScore(score: number, policy?: ThreatPolicy): ThreatVerdict;
|
|
37
|
+
/** One category's contribution to a scan result. */
|
|
38
|
+
interface ThreatTypeSummary {
|
|
39
|
+
/** The weakness category. */
|
|
40
|
+
readonly type: ThreatType;
|
|
41
|
+
/** How many findings carried this category. */
|
|
42
|
+
readonly count: number;
|
|
43
|
+
/** Summed score contribution, before per-rule deduplication. */
|
|
44
|
+
readonly score: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Summarize findings by weakness category, highest score first.
|
|
48
|
+
*
|
|
49
|
+
* Useful for dashboards and alert routing, where "three SQL findings and one XSS
|
|
50
|
+
* finding" is more actionable than a flat list.
|
|
51
|
+
*
|
|
52
|
+
* @param findings - Findings from one scan.
|
|
53
|
+
* @returns Category totals sorted by descending score, then by category name so ties
|
|
54
|
+
* are ordered deterministically.
|
|
55
|
+
*/
|
|
56
|
+
declare function summarizeByType(findings: readonly ThreatFinding[]): readonly ThreatTypeSummary[];
|
|
57
|
+
//#endregion
|
|
58
|
+
export { ThreatTypeSummary, calculateThreatScore, scoreForFinding, summarizeByType, verdictForScore };
|
|
59
|
+
//# sourceMappingURL=scoring.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scoring.d.mts","names":[],"sources":["../../src/threats/scoring.ts"],"mappings":";;;;;;;;iBAgDgB,gBAAgB,SAAS;;;;;;;;;;;;;;;;;;iBAqBzB,qBAAqB,mBAAmB;;;;;;;;;iBA8BxC,gBACf,eACA,SAAQ,eACN;;UAOc;;WAEP,MAAM;;WAEN;;WAEA;;;;;;;;;;;;iBAaM,gBAAgB,mBAAmB,2BAA2B"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { CONFIDENCE_MULTIPLIERS, DEFAULT_THREAT_POLICY, SEVERITY_WEIGHTS } from "./types.mjs";
|
|
2
|
+
//#region src/threats/scoring.ts
|
|
3
|
+
/**
|
|
4
|
+
* Copyright 2026 ResQ Systems, Inc.
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* @fileoverview Anomaly scoring — turns a list of findings into a number and a
|
|
20
|
+
* policy verdict.
|
|
21
|
+
*
|
|
22
|
+
* The point of scoring is that no single signature is trustworthy enough to reject a
|
|
23
|
+
* request on its own. A lone low-confidence hit should raise a signal; a cluster of
|
|
24
|
+
* high-confidence hits should stop the request. This is the model OWASP CRS uses —
|
|
25
|
+
* anomaly score plus tunable thresholds — rather than one-rule-one-block, and it is
|
|
26
|
+
* what makes per-route tuning possible: an operator raises `blockAt` for a field that
|
|
27
|
+
* legitimately carries code samples instead of disabling detection wholesale.
|
|
28
|
+
*
|
|
29
|
+
* @module @resq-systems/security/threats/scoring
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Points a single finding contributes: severity weight scaled by confidence.
|
|
33
|
+
*
|
|
34
|
+
* @param finding - The finding to weigh.
|
|
35
|
+
* @returns A non-negative score contribution.
|
|
36
|
+
*/
|
|
37
|
+
function scoreForFinding(finding) {
|
|
38
|
+
return SEVERITY_WEIGHTS[finding.severity] * CONFIDENCE_MULTIPLIERS[finding.confidence];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Total anomaly score for a set of findings.
|
|
42
|
+
*
|
|
43
|
+
* Each **rule** counts once no matter how many variants it matched in. Without that
|
|
44
|
+
* collapse a payload written as `%3Cscript%3E` would score twice — once for `raw` and
|
|
45
|
+
* once for `percent_decoded` — penalizing an input for being encoded rather than for
|
|
46
|
+
* what it contains.
|
|
47
|
+
*
|
|
48
|
+
* @param findings - Findings from one scan.
|
|
49
|
+
* @returns The summed score; `0` for an empty list.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* // one high/high finding (4 × 1.5) plus one medium/low finding (2 × 0.5)
|
|
54
|
+
* calculateThreatScore(findings); // 7
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
function calculateThreatScore(findings) {
|
|
58
|
+
const bestPerRule = /* @__PURE__ */ new Map();
|
|
59
|
+
for (const finding of findings) {
|
|
60
|
+
const score = scoreForFinding(finding);
|
|
61
|
+
const existing = bestPerRule.get(finding.ruleId);
|
|
62
|
+
if (existing === void 0 || score > existing) bestPerRule.set(finding.ruleId, score);
|
|
63
|
+
}
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const score of bestPerRule.values()) total += score;
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Map a score onto a policy band.
|
|
70
|
+
*
|
|
71
|
+
* @param score - Output of {@link calculateThreatScore}.
|
|
72
|
+
* @param policy - Thresholds. Defaults to {@link DEFAULT_THREAT_POLICY} — `review`
|
|
73
|
+
* at 4, `block` at 8.
|
|
74
|
+
* @returns `"allow"`, `"review"`, or `"block"`.
|
|
75
|
+
*/
|
|
76
|
+
function verdictForScore(score, policy = DEFAULT_THREAT_POLICY) {
|
|
77
|
+
if (score >= policy.blockAt) return "block";
|
|
78
|
+
if (score >= policy.reviewAt) return "review";
|
|
79
|
+
return "allow";
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Summarize findings by weakness category, highest score first.
|
|
83
|
+
*
|
|
84
|
+
* Useful for dashboards and alert routing, where "three SQL findings and one XSS
|
|
85
|
+
* finding" is more actionable than a flat list.
|
|
86
|
+
*
|
|
87
|
+
* @param findings - Findings from one scan.
|
|
88
|
+
* @returns Category totals sorted by descending score, then by category name so ties
|
|
89
|
+
* are ordered deterministically.
|
|
90
|
+
*/
|
|
91
|
+
function summarizeByType(findings) {
|
|
92
|
+
const totals = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const finding of findings) {
|
|
94
|
+
const entry = totals.get(finding.type) ?? {
|
|
95
|
+
count: 0,
|
|
96
|
+
score: 0
|
|
97
|
+
};
|
|
98
|
+
entry.count += 1;
|
|
99
|
+
entry.score += scoreForFinding(finding);
|
|
100
|
+
totals.set(finding.type, entry);
|
|
101
|
+
}
|
|
102
|
+
return [...totals.entries()].map(([type, entry]) => ({
|
|
103
|
+
type,
|
|
104
|
+
count: entry.count,
|
|
105
|
+
score: entry.score
|
|
106
|
+
})).sort((a, b) => b.score - a.score || a.type.localeCompare(b.type));
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { calculateThreatScore, scoreForFinding, summarizeByType, verdictForScore };
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=scoring.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scoring.mjs","names":[],"sources":["../../src/threats/scoring.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 Anomaly scoring — turns a list of findings into a number and a\n * policy verdict.\n *\n * The point of scoring is that no single signature is trustworthy enough to reject a\n * request on its own. A lone low-confidence hit should raise a signal; a cluster of\n * high-confidence hits should stop the request. This is the model OWASP CRS uses —\n * anomaly score plus tunable thresholds — rather than one-rule-one-block, and it is\n * what makes per-route tuning possible: an operator raises `blockAt` for a field that\n * legitimately carries code samples instead of disabling detection wholesale.\n *\n * @module @resq-systems/security/threats/scoring\n */\n\nimport {\n\tCONFIDENCE_MULTIPLIERS,\n\tDEFAULT_THREAT_POLICY,\n\tSEVERITY_WEIGHTS,\n\ttype ThreatFinding,\n\ttype ThreatPolicy,\n\ttype ThreatType,\n\ttype ThreatVerdict,\n} from \"./types.js\";\n\n//#region Scoring\n\n/**\n * Points a single finding contributes: severity weight scaled by confidence.\n *\n * @param finding - The finding to weigh.\n * @returns A non-negative score contribution.\n */\nexport function scoreForFinding(finding: ThreatFinding): number {\n\treturn SEVERITY_WEIGHTS[finding.severity] * CONFIDENCE_MULTIPLIERS[finding.confidence];\n}\n\n/**\n * Total anomaly score for a set of findings.\n *\n * Each **rule** counts once no matter how many variants it matched in. Without that\n * collapse a payload written as `%3Cscript%3E` would score twice — once for `raw` and\n * once for `percent_decoded` — penalizing an input for being encoded rather than for\n * what it contains.\n *\n * @param findings - Findings from one scan.\n * @returns The summed score; `0` for an empty list.\n *\n * @example\n * ```ts\n * // one high/high finding (4 × 1.5) plus one medium/low finding (2 × 0.5)\n * calculateThreatScore(findings); // 7\n * ```\n */\nexport function calculateThreatScore(findings: readonly ThreatFinding[]): number {\n\tconst bestPerRule = new Map<string, number>();\n\n\tfor (const finding of findings) {\n\t\tconst score = scoreForFinding(finding);\n\t\tconst existing = bestPerRule.get(finding.ruleId);\n\t\tif (existing === undefined || score > existing) {\n\t\t\tbestPerRule.set(finding.ruleId, score);\n\t\t}\n\t}\n\n\tlet total = 0;\n\tfor (const score of bestPerRule.values()) {\n\t\ttotal += score;\n\t}\n\treturn total;\n}\n\n//#endregion\n\n//#region Policy\n\n/**\n * Map a score onto a policy band.\n *\n * @param score - Output of {@link calculateThreatScore}.\n * @param policy - Thresholds. Defaults to {@link DEFAULT_THREAT_POLICY} — `review`\n * at 4, `block` at 8.\n * @returns `\"allow\"`, `\"review\"`, or `\"block\"`.\n */\nexport function verdictForScore(\n\tscore: number,\n\tpolicy: ThreatPolicy = DEFAULT_THREAT_POLICY,\n): ThreatVerdict {\n\tif (score >= policy.blockAt) return \"block\";\n\tif (score >= policy.reviewAt) return \"review\";\n\treturn \"allow\";\n}\n\n/** One category's contribution to a scan result. */\nexport interface ThreatTypeSummary {\n\t/** The weakness category. */\n\treadonly type: ThreatType;\n\t/** How many findings carried this category. */\n\treadonly count: number;\n\t/** Summed score contribution, before per-rule deduplication. */\n\treadonly score: number;\n}\n\n/**\n * Summarize findings by weakness category, highest score first.\n *\n * Useful for dashboards and alert routing, where \"three SQL findings and one XSS\n * finding\" is more actionable than a flat list.\n *\n * @param findings - Findings from one scan.\n * @returns Category totals sorted by descending score, then by category name so ties\n * are ordered deterministically.\n */\nexport function summarizeByType(findings: readonly ThreatFinding[]): readonly ThreatTypeSummary[] {\n\tconst totals = new Map<ThreatType, { count: number; score: number }>();\n\n\tfor (const finding of findings) {\n\t\tconst entry = totals.get(finding.type) ?? { count: 0, score: 0 };\n\t\tentry.count += 1;\n\t\tentry.score += scoreForFinding(finding);\n\t\ttotals.set(finding.type, entry);\n\t}\n\n\treturn [...totals.entries()]\n\t\t.map(([type, entry]) => ({ type, count: entry.count, score: entry.score }))\n\t\t.sort((a, b) => b.score - a.score || a.type.localeCompare(b.type));\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,SAAgB,gBAAgB,SAAgC;CAC/D,OAAO,iBAAiB,QAAQ,YAAY,uBAAuB,QAAQ;AAC5E;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,qBAAqB,UAA4C;CAChF,MAAM,8BAAc,IAAI,IAAoB;CAE5C,KAAK,MAAM,WAAW,UAAU;EAC/B,MAAM,QAAQ,gBAAgB,OAAO;EACrC,MAAM,WAAW,YAAY,IAAI,QAAQ,MAAM;EAC/C,IAAI,aAAa,KAAA,KAAa,QAAQ,UACrC,YAAY,IAAI,QAAQ,QAAQ,KAAK;CAEvC;CAEA,IAAI,QAAQ;CACZ,KAAK,MAAM,SAAS,YAAY,OAAO,GACtC,SAAS;CAEV,OAAO;AACR;;;;;;;;;AAcA,SAAgB,gBACf,OACA,SAAuB,uBACP;CAChB,IAAI,SAAS,OAAO,SAAS,OAAO;CACpC,IAAI,SAAS,OAAO,UAAU,OAAO;CACrC,OAAO;AACR;;;;;;;;;;;AAsBA,SAAgB,gBAAgB,UAAkE;CACjG,MAAM,yBAAS,IAAI,IAAkD;CAErE,KAAK,MAAM,WAAW,UAAU;EAC/B,MAAM,QAAQ,OAAO,IAAI,QAAQ,IAAI,KAAK;GAAE,OAAO;GAAG,OAAO;EAAE;EAC/D,MAAM,SAAS;EACf,MAAM,SAAS,gBAAgB,OAAO;EACtC,OAAO,IAAI,QAAQ,MAAM,KAAK;CAC/B;CAEA,OAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,CAAC,CAC1B,KAAK,CAAC,MAAM,YAAY;EAAE;EAAM,OAAO,MAAM;EAAO,OAAO,MAAM;CAAM,EAAE,CAAC,CAC1E,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AACnE"}
|