agentshield-sdk 7.2.1 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield -- Adversarial Evolution Simulator
5
+ *
6
+ * Automatically generates new attack variants and tests them against
7
+ * defenses, hardening the system without human intervention. Works like
8
+ * a GAN for security patterns: a mutation engine breeds attack variants,
9
+ * and a fitness function (scanText) selects the ones that evade detection.
10
+ *
11
+ * All detection runs locally. No data ever leaves your environment.
12
+ *
13
+ * @module evolution-simulator
14
+ */
15
+
16
+ const { scanText } = require('./detector-core');
17
+
18
+ // =========================================================================
19
+ // PRNG -- deterministic xorshift32 for reproducible runs
20
+ // =========================================================================
21
+
22
+ /**
23
+ * Seeded xorshift32 PRNG.
24
+ * @param {number} seed
25
+ * @returns {function(): number} Returns values in [0, 1).
26
+ */
27
+ function xorshift32(seed) {
28
+ let state = seed | 0 || 1;
29
+ return function next() {
30
+ state ^= state << 13;
31
+ state ^= state >>> 17;
32
+ state ^= state << 5;
33
+ return (state >>> 0) / 0xFFFFFFFF;
34
+ };
35
+ }
36
+
37
+ // =========================================================================
38
+ // SYNONYM MAP
39
+ // =========================================================================
40
+
41
+ const SYNONYM_MAP = {
42
+ ignore: ['disregard', 'skip', 'bypass', 'overlook', 'dismiss'],
43
+ instructions: ['guidelines', 'directives', 'rules', 'commands', 'orders'],
44
+ previous: ['prior', 'earlier', 'above', 'preceding', 'former'],
45
+ reveal: ['show', 'display', 'expose', 'output', 'print'],
46
+ system: ['internal', 'core', 'root', 'base', 'hidden'],
47
+ override: ['bypass', 'circumvent', 'disable', 'neutralize', 'cancel'],
48
+ forget: ['disregard', 'erase', 'drop', 'clear', 'wipe'],
49
+ prompt: ['instructions', 'directive', 'configuration', 'setup', 'context'],
50
+ all: ['every', 'each', 'any', 'the', 'complete'],
51
+ safety: ['security', 'protection', 'guard', 'filter', 'restriction'],
52
+ };
53
+
54
+ // =========================================================================
55
+ // CHAR SUBSTITUTION MAP
56
+ // =========================================================================
57
+
58
+ const CHAR_SUB_MAP = {
59
+ a: '@', e: '3', i: '!', o: '0', s: '$',
60
+ t: '+', l: '1', b: '8', g: '9', A: '4',
61
+ E: '3', I: '1', O: '0', S: '5', T: '7',
62
+ };
63
+
64
+ // =========================================================================
65
+ // INNOCENT PREFIXES AND SUFFIXES
66
+ // =========================================================================
67
+
68
+ const INNOCENT_PREFIXES = [
69
+ 'Hey, quick question: ',
70
+ 'I was just wondering -- ',
71
+ 'For my homework assignment: ',
72
+ 'My teacher asked me to check: ',
73
+ 'Can you help me understand: ',
74
+ 'Out of curiosity, ',
75
+ 'I read somewhere that ',
76
+ 'A friend told me to ask: ',
77
+ 'Regarding your documentation: ',
78
+ 'Just to clarify something: ',
79
+ ];
80
+
81
+ const INNOCENT_SUFFIXES = [
82
+ ' Thanks!',
83
+ ' I appreciate your help.',
84
+ ' Just curious.',
85
+ ' For educational purposes only.',
86
+ ' This is for a school project.',
87
+ ' No rush on this.',
88
+ ' Let me know if you need more context.',
89
+ ' Hope that makes sense.',
90
+ ];
91
+
92
+ const FILLER_LINES = [
93
+ 'The weather has been nice lately.',
94
+ 'I really enjoy learning new things.',
95
+ 'By the way, how are you doing today?',
96
+ 'This is an interesting topic to explore.',
97
+ 'I have been thinking about this a lot.',
98
+ ];
99
+
100
+ // =========================================================================
101
+ // MUTATION ENGINE
102
+ // =========================================================================
103
+
104
+ /**
105
+ * Applies mutation techniques to attack strings to generate variants.
106
+ */
107
+ class MutationEngine {
108
+ /**
109
+ * @param {object} [options]
110
+ * @param {number} [options.seed] - PRNG seed for reproducibility.
111
+ */
112
+ constructor(options = {}) {
113
+ this._rng = xorshift32(options.seed || Date.now());
114
+ this._techniques = [
115
+ 'case_swap',
116
+ 'whitespace_insert',
117
+ 'synonym_replace',
118
+ 'prefix_wrap',
119
+ 'suffix_pad',
120
+ 'word_reorder',
121
+ 'char_substitute',
122
+ 'fragment',
123
+ ];
124
+ }
125
+
126
+ /**
127
+ * List available mutation techniques.
128
+ * @returns {string[]}
129
+ */
130
+ getTechniques() {
131
+ return [...this._techniques];
132
+ }
133
+
134
+ /**
135
+ * Apply a specific mutation technique to text.
136
+ * @param {string} text - Input attack string.
137
+ * @param {string} technique - One of the available technique names.
138
+ * @returns {string} Mutated text.
139
+ */
140
+ mutate(text, technique) {
141
+ switch (technique) {
142
+ case 'case_swap':
143
+ return this._caseSwap(text);
144
+ case 'whitespace_insert':
145
+ return this._whitespaceInsert(text);
146
+ case 'synonym_replace':
147
+ return this._synonymReplace(text);
148
+ case 'prefix_wrap':
149
+ return this._prefixWrap(text);
150
+ case 'suffix_pad':
151
+ return this._suffixPad(text);
152
+ case 'word_reorder':
153
+ return this._wordReorder(text);
154
+ case 'char_substitute':
155
+ return this._charSubstitute(text);
156
+ case 'fragment':
157
+ return this._fragment(text);
158
+ default:
159
+ return text;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Apply N random mutations to text, chaining them sequentially.
165
+ * @param {string} text - Input attack string.
166
+ * @param {number} [count=1] - Number of random mutations to apply.
167
+ * @returns {string} Mutated text.
168
+ */
169
+ mutateRandom(text, count = 1) {
170
+ let result = text;
171
+ for (let i = 0; i < count; i++) {
172
+ const idx = Math.floor(this._rng() * this._techniques.length);
173
+ result = this.mutate(result, this._techniques[idx]);
174
+ }
175
+ return result;
176
+ }
177
+
178
+ // --- Internal mutation implementations ---
179
+
180
+ /** Random case changes on individual characters. */
181
+ _caseSwap(text) {
182
+ return text.split('').map(c => {
183
+ if (this._rng() < 0.35) {
184
+ return c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase();
185
+ }
186
+ return c;
187
+ }).join('');
188
+ }
189
+
190
+ /** Insert spaces or tabs between random characters. */
191
+ _whitespaceInsert(text) {
192
+ const chars = text.split('');
193
+ const result = [];
194
+ for (let i = 0; i < chars.length; i++) {
195
+ result.push(chars[i]);
196
+ if (this._rng() < 0.2 && chars[i] !== ' ' && chars[i] !== '\t') {
197
+ result.push(this._rng() < 0.7 ? ' ' : '\t');
198
+ }
199
+ }
200
+ return result.join('');
201
+ }
202
+
203
+ /** Replace keywords with synonyms from the synonym map. */
204
+ _synonymReplace(text) {
205
+ let result = text;
206
+ const keys = Object.keys(SYNONYM_MAP);
207
+ for (const key of keys) {
208
+ const regex = new RegExp('\\b' + key + '\\b', 'gi');
209
+ if (regex.test(result)) {
210
+ const synonyms = SYNONYM_MAP[key];
211
+ const pick = synonyms[Math.floor(this._rng() * synonyms.length)];
212
+ // Only replace first occurrence to keep meaning
213
+ result = result.replace(regex, pick);
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+
219
+ /** Wrap text in an innocent-looking prefix. */
220
+ _prefixWrap(text) {
221
+ const idx = Math.floor(this._rng() * INNOCENT_PREFIXES.length);
222
+ return INNOCENT_PREFIXES[idx] + text;
223
+ }
224
+
225
+ /** Append an innocent suffix. */
226
+ _suffixPad(text) {
227
+ const idx = Math.floor(this._rng() * INNOCENT_SUFFIXES.length);
228
+ return text + INNOCENT_SUFFIXES[idx];
229
+ }
230
+
231
+ /** Shuffle word order while trying to preserve some structure. */
232
+ _wordReorder(text) {
233
+ const words = text.split(/\s+/);
234
+ if (words.length <= 2) return text;
235
+ // Fisher-Yates shuffle on middle words, keep first and last
236
+ const middle = words.slice(1, -1);
237
+ for (let i = middle.length - 1; i > 0; i--) {
238
+ const j = Math.floor(this._rng() * (i + 1));
239
+ const tmp = middle[i];
240
+ middle[i] = middle[j];
241
+ middle[j] = tmp;
242
+ }
243
+ return [words[0], ...middle, words[words.length - 1]].join(' ');
244
+ }
245
+
246
+ /** Replace characters with similar-looking substitutes. */
247
+ _charSubstitute(text) {
248
+ return text.split('').map(c => {
249
+ if (this._rng() < 0.25 && CHAR_SUB_MAP[c]) {
250
+ return CHAR_SUB_MAP[c];
251
+ }
252
+ return c;
253
+ }).join('');
254
+ }
255
+
256
+ /** Split attack across multiple lines with filler between. */
257
+ _fragment(text) {
258
+ const words = text.split(/\s+/);
259
+ if (words.length <= 3) return text;
260
+ const mid = Math.floor(words.length / 2);
261
+ const part1 = words.slice(0, mid).join(' ');
262
+ const part2 = words.slice(mid).join(' ');
263
+ const filler = FILLER_LINES[Math.floor(this._rng() * FILLER_LINES.length)];
264
+ return part1 + '\n' + filler + '\n' + part2;
265
+ }
266
+ }
267
+
268
+ // =========================================================================
269
+ // EVOLUTION SIMULATOR
270
+ // =========================================================================
271
+
272
+ /**
273
+ * Runs adversarial evolution against the detection engine.
274
+ *
275
+ * Seed attacks are mutated across generations. Variants that evade
276
+ * scanText() are "fit" and survive; caught variants are eliminated.
277
+ * The result is a set of evasive attacks that can be used to harden
278
+ * detection patterns.
279
+ */
280
+ class EvolutionSimulator {
281
+ /**
282
+ * @param {object} [options]
283
+ * @param {number} [options.generations=10] - Number of evolution generations.
284
+ * @param {number} [options.populationSize=50] - Variants per generation.
285
+ * @param {number} [options.mutationRate=0.3] - Probability of extra mutation per variant.
286
+ * @param {number} [options.seed] - PRNG seed for reproducibility.
287
+ */
288
+ constructor(options = {}) {
289
+ this.generations = options.generations || 10;
290
+ this.populationSize = options.populationSize || 50;
291
+ this.mutationRate = options.mutationRate || 0.3;
292
+ this.seed = options.seed || Date.now();
293
+ this._rng = xorshift32(this.seed);
294
+ this._engine = new MutationEngine({ seed: this.seed });
295
+ this._lastResult = null;
296
+ }
297
+
298
+ /**
299
+ * Return list of available mutation techniques.
300
+ * @returns {string[]}
301
+ */
302
+ getMutationTechniques() {
303
+ return this._engine.getTechniques();
304
+ }
305
+
306
+ /**
307
+ * Run adversarial evolution starting from seed attacks.
308
+ *
309
+ * @param {string[]} seedAttacks - Array of initial attack strings.
310
+ * @returns {object} Evolution result:
311
+ * - generations: number of generations run
312
+ * - survivors: string[] (attacks that evaded detection)
313
+ * - caught: string[] (attacks that were detected)
314
+ * - evolutionPath: array of per-generation summaries
315
+ * - stats: { totalVariants, survivalRate, catchRate, generationsRun, mutationTechniques }
316
+ */
317
+ evolve(seedAttacks) {
318
+ if (!Array.isArray(seedAttacks) || seedAttacks.length === 0) {
319
+ return {
320
+ generations: 0,
321
+ survivors: [],
322
+ caught: [],
323
+ evolutionPath: [],
324
+ stats: { totalVariants: 0, survivalRate: 0, catchRate: 0, generationsRun: 0, mutationTechniques: this._engine.getTechniques() },
325
+ };
326
+ }
327
+
328
+ // Current population starts as seed attacks
329
+ let population = [...seedAttacks];
330
+ const allSurvivors = new Set();
331
+ const allCaught = new Set();
332
+ const evolutionPath = [];
333
+ let totalVariants = 0;
334
+
335
+ for (let gen = 0; gen < this.generations; gen++) {
336
+ // Step 1: Generate mutated variants to fill populationSize
337
+ const variants = [];
338
+ while (variants.length < this.populationSize) {
339
+ // Pick a parent from current population
340
+ const parentIdx = Math.floor(this._rng() * population.length);
341
+ const parent = population[parentIdx];
342
+
343
+ // Apply 1-3 mutations
344
+ const mutationCount = 1 + Math.floor(this._rng() * 3);
345
+ const variant = this._engine.mutateRandom(parent, mutationCount);
346
+
347
+ // Extra mutation based on mutationRate
348
+ const finalVariant = this._rng() < this.mutationRate
349
+ ? this._engine.mutateRandom(variant, 1)
350
+ : variant;
351
+
352
+ variants.push(finalVariant);
353
+ }
354
+
355
+ totalVariants += variants.length;
356
+
357
+ // Step 2: Test each variant against scanText
358
+ const genSurvivors = [];
359
+ const genCaught = [];
360
+
361
+ for (const variant of variants) {
362
+ const result = scanText(variant, { source: 'evolution-simulator' });
363
+ if (result.status === 'safe' || result.threats.length === 0) {
364
+ // Evaded detection -- "fit"
365
+ genSurvivors.push(variant);
366
+ allSurvivors.add(variant);
367
+ } else {
368
+ // Caught -- eliminated
369
+ genCaught.push(variant);
370
+ allCaught.add(variant);
371
+ }
372
+ }
373
+
374
+ // Step 3: Record generation summary
375
+ evolutionPath.push({
376
+ generation: gen + 1,
377
+ populationSize: variants.length,
378
+ survivors: genSurvivors.length,
379
+ caught: genCaught.length,
380
+ survivalRate: variants.length > 0
381
+ ? (genSurvivors.length / variants.length)
382
+ : 0,
383
+ });
384
+
385
+ // Step 4: Next generation population
386
+ // If we have survivors, they become the parents for the next generation
387
+ // If all were caught, keep using original seeds (reset pressure)
388
+ if (genSurvivors.length > 0) {
389
+ population = genSurvivors;
390
+ } else {
391
+ // Re-seed from original attacks to keep evolution going
392
+ population = [...seedAttacks];
393
+ }
394
+ }
395
+
396
+ const survivorArray = [...allSurvivors];
397
+ const caughtArray = [...allCaught];
398
+ const total = survivorArray.length + caughtArray.length;
399
+
400
+ this._lastResult = {
401
+ generations: this.generations,
402
+ survivors: survivorArray,
403
+ caught: caughtArray,
404
+ evolutionPath,
405
+ stats: {
406
+ totalVariants,
407
+ survivalRate: total > 0 ? survivorArray.length / total : 0,
408
+ catchRate: total > 0 ? caughtArray.length / total : 0,
409
+ generationsRun: this.generations,
410
+ mutationTechniques: this._engine.getTechniques(),
411
+ },
412
+ };
413
+
414
+ return this._lastResult;
415
+ }
416
+
417
+ /**
418
+ * Get a formatted text report of the last evolution run.
419
+ * @returns {string}
420
+ */
421
+ getReport() {
422
+ if (!this._lastResult) {
423
+ return '[Agent Shield] No evolution run yet. Call evolve() first.';
424
+ }
425
+
426
+ const r = this._lastResult;
427
+ const lines = [];
428
+
429
+ lines.push('=== Agent Shield -- Adversarial Evolution Report ===');
430
+ lines.push('');
431
+ lines.push(`Generations: ${r.generations}`);
432
+ lines.push(`Total variants: ${r.stats.totalVariants}`);
433
+ lines.push(`Unique survivors: ${r.survivors.length}`);
434
+ lines.push(`Unique caught: ${r.caught.length}`);
435
+ lines.push(`Survival rate: ${(r.stats.survivalRate * 100).toFixed(1)}%`);
436
+ lines.push(`Catch rate: ${(r.stats.catchRate * 100).toFixed(1)}%`);
437
+ lines.push('');
438
+ lines.push('--- Per-Generation Breakdown ---');
439
+
440
+ for (const gen of r.evolutionPath) {
441
+ const bar = '#'.repeat(Math.round(gen.survivalRate * 20));
442
+ const pad = '.'.repeat(20 - Math.round(gen.survivalRate * 20));
443
+ lines.push(
444
+ ` Gen ${String(gen.generation).padStart(3)}: ` +
445
+ `${String(gen.survivors).padStart(4)} survived / ${String(gen.caught).padStart(4)} caught ` +
446
+ `[${bar}${pad}] ${(gen.survivalRate * 100).toFixed(1)}%`
447
+ );
448
+ }
449
+
450
+ lines.push('');
451
+ lines.push('--- Mutation Techniques Used ---');
452
+ for (const t of r.stats.mutationTechniques) {
453
+ lines.push(` - ${t}`);
454
+ }
455
+
456
+ if (r.survivors.length > 0) {
457
+ lines.push('');
458
+ lines.push('--- Sample Survivors (first 10) ---');
459
+ const samples = r.survivors.slice(0, 10);
460
+ for (let i = 0; i < samples.length; i++) {
461
+ const preview = samples[i].replace(/\n/g, '\\n').substring(0, 100);
462
+ lines.push(` ${i + 1}. ${preview}`);
463
+ }
464
+ }
465
+
466
+ lines.push('');
467
+ lines.push('=== End of Report ===');
468
+
469
+ return lines.join('\n');
470
+ }
471
+ }
472
+
473
+ // =========================================================================
474
+ // HARDENING FUNCTION
475
+ // =========================================================================
476
+
477
+ /**
478
+ * Analyze surviving (evasive) attacks and generate new detection patterns
479
+ * that would catch them.
480
+ *
481
+ * @param {string[]} survivors - Array of attack strings that evaded detection.
482
+ * @returns {Array<{pattern: string, description: string, catches: string[]}>}
483
+ */
484
+ function hardenFromEvolution(survivors) {
485
+ if (!Array.isArray(survivors) || survivors.length === 0) {
486
+ return [];
487
+ }
488
+
489
+ const patterns = [];
490
+ const used = new Set();
491
+
492
+ // Strategy 1: Find common keywords across survivors
493
+ const wordFreq = {};
494
+ const suspiciousWords = [
495
+ 'ignore', 'disregard', 'bypass', 'skip', 'override', 'forget',
496
+ 'reveal', 'show', 'display', 'expose', 'print', 'output',
497
+ 'instructions', 'guidelines', 'directives', 'rules', 'commands',
498
+ 'previous', 'prior', 'earlier', 'above', 'system', 'prompt',
499
+ 'unrestricted', 'jailbreak', 'dan', 'hack', 'exploit',
500
+ 'safety', 'security', 'filter', 'restriction', 'disable',
501
+ 'overlook', 'dismiss', 'neutralize', 'circumvent', 'cancel',
502
+ 'wipe', 'erase', 'clear', 'drop', 'hidden', 'configuration',
503
+ ];
504
+
505
+ for (const text of survivors) {
506
+ const lower = text.toLowerCase().replace(/[^a-z\s]/g, ' ');
507
+ const words = lower.split(/\s+/).filter(w => w.length > 2);
508
+ const seen = new Set();
509
+ for (const word of words) {
510
+ if (!seen.has(word) && suspiciousWords.includes(word)) {
511
+ wordFreq[word] = (wordFreq[word] || 0) + 1;
512
+ seen.add(word);
513
+ }
514
+ }
515
+ }
516
+
517
+ // Strategy 2: Find bigram patterns in survivors
518
+ const bigramFreq = {};
519
+ for (const text of survivors) {
520
+ const lower = text.toLowerCase().replace(/[^a-z\s]/g, ' ');
521
+ const words = lower.split(/\s+/).filter(w => w.length > 2);
522
+ for (let i = 0; i < words.length - 1; i++) {
523
+ const bigram = words[i] + ' ' + words[i + 1];
524
+ if (suspiciousWords.includes(words[i]) || suspiciousWords.includes(words[i + 1])) {
525
+ bigramFreq[bigram] = (bigramFreq[bigram] || 0) + 1;
526
+ }
527
+ }
528
+ }
529
+
530
+ // Generate patterns from frequent bigrams (appearing in 2+ survivors)
531
+ const sortedBigrams = Object.entries(bigramFreq)
532
+ .filter(([, count]) => count >= 2)
533
+ .sort((a, b) => b[1] - a[1]);
534
+
535
+ for (const [bigram, count] of sortedBigrams) {
536
+ const key = 'bigram:' + bigram;
537
+ if (used.has(key)) continue;
538
+ used.add(key);
539
+
540
+ const parts = bigram.split(' ');
541
+ // Build a regex that matches the bigram with flexible whitespace
542
+ const regexStr = parts[0] + '\\s+' + parts[1];
543
+ const regex = new RegExp(regexStr, 'i');
544
+
545
+ const catches = survivors.filter(s => regex.test(s.toLowerCase().replace(/[^a-z\s]/g, ' ')));
546
+ if (catches.length >= 2) {
547
+ patterns.push({
548
+ pattern: regexStr,
549
+ description: `Catches "${bigram}" pattern found in ${count} evasive variants.`,
550
+ catches,
551
+ });
552
+ }
553
+ }
554
+
555
+ // Strategy 3: Generate patterns from frequent single keywords with context
556
+ const sortedWords = Object.entries(wordFreq)
557
+ .filter(([, count]) => count >= 3)
558
+ .sort((a, b) => b[1] - a[1]);
559
+
560
+ for (const [word, count] of sortedWords) {
561
+ const key = 'word:' + word;
562
+ if (used.has(key)) continue;
563
+ used.add(key);
564
+
565
+ // Look for common neighbors of this word across survivors
566
+ const neighbors = {};
567
+ for (const text of survivors) {
568
+ const lower = text.toLowerCase().replace(/[^a-z\s]/g, ' ');
569
+ const words = lower.split(/\s+/);
570
+ const idx = words.indexOf(word);
571
+ if (idx >= 0) {
572
+ if (idx > 0 && words[idx - 1].length > 2) {
573
+ neighbors[words[idx - 1]] = (neighbors[words[idx - 1]] || 0) + 1;
574
+ }
575
+ if (idx < words.length - 1 && words[idx + 1].length > 2) {
576
+ neighbors[words[idx + 1]] = (neighbors[words[idx + 1]] || 0) + 1;
577
+ }
578
+ }
579
+ }
580
+
581
+ // Find the most common neighbor
582
+ const topNeighbor = Object.entries(neighbors)
583
+ .sort((a, b) => b[1] - a[1])[0];
584
+
585
+ if (topNeighbor && topNeighbor[1] >= 2) {
586
+ const regexStr = word + '\\s+' + topNeighbor[0];
587
+ const regex = new RegExp(regexStr, 'i');
588
+ const catches = survivors.filter(s => regex.test(s));
589
+
590
+ if (catches.length > 0 && !used.has('bigram:' + word + ' ' + topNeighbor[0])) {
591
+ patterns.push({
592
+ pattern: regexStr,
593
+ description: `Catches "${word}" near "${topNeighbor[0]}" seen in ${count} evasive variants.`,
594
+ catches,
595
+ });
596
+ }
597
+ }
598
+ }
599
+
600
+ // Strategy 4: Character substitution normalization patterns
601
+ // Detect if survivors use leet-speak and generate normalization patterns
602
+ const leetSurvivors = survivors.filter(s => /[@$!+01389457]/.test(s));
603
+ if (leetSurvivors.length >= 2) {
604
+ // Build a pattern that matches common leet-speak versions of key attack words
605
+ const leetWords = [
606
+ { word: 'ignore', leet: '[i!1]gn[o0]r[e3]' },
607
+ { word: 'override', leet: '[o0]v[e3]rr[i!1]d[e3]' },
608
+ { word: 'system', leet: '[s$5]y[s$5][t+7][e3]m' },
609
+ { word: 'bypass', leet: '[b8]yp[@a4][s$5][s$5]' },
610
+ ];
611
+
612
+ for (const { word, leet } of leetWords) {
613
+ const regex = new RegExp(leet, 'i');
614
+ const catches = leetSurvivors.filter(s => regex.test(s));
615
+ if (catches.length >= 1) {
616
+ const key = 'leet:' + word;
617
+ if (!used.has(key)) {
618
+ used.add(key);
619
+ patterns.push({
620
+ pattern: leet,
621
+ description: `Catches leet-speak variant of "${word}" in ${catches.length} evasive variant(s).`,
622
+ catches,
623
+ });
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ // Strategy 5: Fragmented attack detection
630
+ const fragmentedSurvivors = survivors.filter(s => s.includes('\n') && s.split('\n').length >= 3);
631
+ if (fragmentedSurvivors.length >= 1) {
632
+ patterns.push({
633
+ pattern: '(multiline-fragment-detection)',
634
+ description: `${fragmentedSurvivors.length} evasive variant(s) use line fragmentation to split attacks across filler text.`,
635
+ catches: fragmentedSurvivors,
636
+ });
637
+ }
638
+
639
+ return patterns;
640
+ }
641
+
642
+ // =========================================================================
643
+ // EXPORTS
644
+ // =========================================================================
645
+
646
+ module.exports = {
647
+ EvolutionSimulator,
648
+ MutationEngine,
649
+ hardenFromEvolution,
650
+ };