@anomira/node-sdk 0.2.0 → 0.2.3
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 +25 -1
- package/dist/index.cjs +1264 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -6
- package/dist/index.d.ts +92 -6
- package/dist/index.js +1264 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import { randomBytes, createHmac } from 'crypto';
|
|
2
3
|
|
|
3
4
|
// src/client.ts
|
|
4
5
|
|
|
@@ -402,7 +403,741 @@ var EventName = {
|
|
|
402
403
|
FIREWALL_FLAG: "http.firewall.flag"
|
|
403
404
|
};
|
|
404
405
|
|
|
405
|
-
// src/
|
|
406
|
+
// src/agent-detection.ts
|
|
407
|
+
var CONFIRMED_UA_PATTERNS = [
|
|
408
|
+
// OpenAI — https://developers.openai.com/api/docs/bots
|
|
409
|
+
[/GPTBot\//i, "openai", "GPTBot"],
|
|
410
|
+
[/ChatGPT-User\//i, "openai", "ChatGPT-User"],
|
|
411
|
+
[/OAI-SearchBot\//i, "openai", "OAI-SearchBot"],
|
|
412
|
+
[/OAI-AdsBot\//i, "openai", "OAI-AdsBot"],
|
|
413
|
+
// Anthropic — https://support.claude.com/en/articles/8896518
|
|
414
|
+
[/ClaudeBot\//i, "anthropic", "ClaudeBot"],
|
|
415
|
+
[/Claude-User\//i, "anthropic", "Claude-User"],
|
|
416
|
+
[/Claude-SearchBot\//i, "anthropic", "Claude-SearchBot"],
|
|
417
|
+
// Perplexity — https://docs.perplexity.ai/guides/bots
|
|
418
|
+
[/PerplexityBot\//i, "perplexity", "PerplexityBot"],
|
|
419
|
+
[/Perplexity-User\//i, "perplexity", "Perplexity-User"]
|
|
420
|
+
];
|
|
421
|
+
var MCP_SESSION_HEADER = "mcp-session-id";
|
|
422
|
+
var RFC9421_SIG_HEADER = "signature-agent";
|
|
423
|
+
var MCP_ACCEPT_EXACT = "application/json, text/event-stream";
|
|
424
|
+
function detectAgent(headers) {
|
|
425
|
+
const signals = [];
|
|
426
|
+
let agentType = null;
|
|
427
|
+
let agentName = null;
|
|
428
|
+
let sessionId = null;
|
|
429
|
+
let isMcp = false;
|
|
430
|
+
let confidence = null;
|
|
431
|
+
const sigAgent = headerStr(headers[RFC9421_SIG_HEADER]);
|
|
432
|
+
if (sigAgent) {
|
|
433
|
+
signals.push("rfc9421_signature_agent");
|
|
434
|
+
confidence = "high";
|
|
435
|
+
agentType = sigAgent.includes("chatgpt.com") ? "openai" : "rfc9421_agent";
|
|
436
|
+
agentName = `rfc9421:${sigAgent.slice(0, 60)}`;
|
|
437
|
+
}
|
|
438
|
+
const mcpSession = headerStr(headers[MCP_SESSION_HEADER]);
|
|
439
|
+
if (mcpSession) {
|
|
440
|
+
signals.push("mcp_session_id");
|
|
441
|
+
isMcp = true;
|
|
442
|
+
sessionId = mcpSession;
|
|
443
|
+
confidence = "high";
|
|
444
|
+
if (!agentType) agentType = "mcp_client";
|
|
445
|
+
}
|
|
446
|
+
const accept = headerStr(headers["accept"]);
|
|
447
|
+
if (accept === MCP_ACCEPT_EXACT) {
|
|
448
|
+
signals.push("mcp_accept_header");
|
|
449
|
+
isMcp = true;
|
|
450
|
+
if (!confidence) confidence = "high";
|
|
451
|
+
if (!agentType) agentType = "mcp_client";
|
|
452
|
+
}
|
|
453
|
+
const ua = headerStr(headers["user-agent"]) ?? "";
|
|
454
|
+
for (const [pattern, type, name] of CONFIRMED_UA_PATTERNS) {
|
|
455
|
+
const match = ua.match(pattern);
|
|
456
|
+
if (match) {
|
|
457
|
+
signals.push(`ua_${name.toLowerCase().replace(/[^a-z0-9]/g, "_")}`);
|
|
458
|
+
confidence = "high";
|
|
459
|
+
const tokenStart = ua.indexOf(match[0]);
|
|
460
|
+
const tokenEnd = ua.indexOf(" ", tokenStart);
|
|
461
|
+
agentName = tokenEnd > -1 ? ua.slice(tokenStart, tokenEnd) : match[0];
|
|
462
|
+
agentType = type;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!agentType && /^python-httpx\//i.test(ua) && isMcp) {
|
|
467
|
+
signals.push("ua_python_httpx_with_mcp");
|
|
468
|
+
confidence = "medium";
|
|
469
|
+
agentType = "mcp_client";
|
|
470
|
+
agentName = ua.split(" ")[0] ?? "python-httpx";
|
|
471
|
+
}
|
|
472
|
+
const isAgent = confidence !== null;
|
|
473
|
+
return {
|
|
474
|
+
isAgent,
|
|
475
|
+
confidence: confidence ?? "medium",
|
|
476
|
+
agentType: isAgent ? agentType ?? "unknown_agent" : null,
|
|
477
|
+
agentName: isAgent ? agentName : null,
|
|
478
|
+
sessionId,
|
|
479
|
+
isMcp,
|
|
480
|
+
signals
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function headerStr(v) {
|
|
484
|
+
if (v === void 0) return void 0;
|
|
485
|
+
return Array.isArray(v) ? v[0] : v;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/behavioral-fingerprint.ts
|
|
489
|
+
var KNOWN_BOT_UA = [
|
|
490
|
+
// Confirmed: psf/requests utils.py (default_headers)
|
|
491
|
+
[/^python-requests\//i, "python-requests"],
|
|
492
|
+
// Confirmed: encode/httpx tests/client/test_headers.py
|
|
493
|
+
[/^python-httpx\//i, "python-httpx"],
|
|
494
|
+
// Confirmed: aiohttp docs (format: "Python/3.x aiohttp/3.x.x")
|
|
495
|
+
[/\baiohttp\//i, "aiohttp"],
|
|
496
|
+
// Confirmed: nodejs/undici issue #1305
|
|
497
|
+
[/^undici$/i, "undici"],
|
|
498
|
+
// Confirmed: everything.curl.dev
|
|
499
|
+
[/^curl\//i, "curl"],
|
|
500
|
+
// Confirmed: GNU wget docs
|
|
501
|
+
[/^Wget\//i, "wget"],
|
|
502
|
+
// Confirmed: Go net/http DefaultClient (golang.org/pkg/net/http)
|
|
503
|
+
[/^Go-http-client\//i, "Go-http-client"],
|
|
504
|
+
// Confirmed: Java HttpURLConnection default
|
|
505
|
+
[/^Java\//i, "Java"],
|
|
506
|
+
// Confirmed: Scrapy docs (scrapy.org)
|
|
507
|
+
[/^Scrapy\//i, "Scrapy"],
|
|
508
|
+
// Confirmed: OkHttp (square.github.io/okhttp)
|
|
509
|
+
[/\bokhttp\//i, "okhttp"],
|
|
510
|
+
// Confirmed: libwww-perl (metacpan.org/pod/LWP)
|
|
511
|
+
[/^libwww-perl\//i, "libwww-perl"],
|
|
512
|
+
// Confirmed: node-fetch (github.com/node-fetch/node-fetch README)
|
|
513
|
+
[/^node-fetch\//i, "node-fetch"],
|
|
514
|
+
// Confirmed: axios docs (axios-http.com/docs/config_defaults)
|
|
515
|
+
[/^axios\//i, "axios"],
|
|
516
|
+
// Confirmed: Ruby net/http default (ruby-doc.org)
|
|
517
|
+
[/^Ruby$/i, "Ruby"]
|
|
518
|
+
];
|
|
519
|
+
var AXIOS_ACCEPT = "application/json, text/plain, */*";
|
|
520
|
+
function computeBrowserFingerprint(headers, ua) {
|
|
521
|
+
const signals = [];
|
|
522
|
+
let score = 0;
|
|
523
|
+
let isDefiniteBot = false;
|
|
524
|
+
let knownClient = null;
|
|
525
|
+
for (const [pattern, name] of KNOWN_BOT_UA) {
|
|
526
|
+
if (pattern.test(ua)) {
|
|
527
|
+
isDefiniteBot = true;
|
|
528
|
+
knownClient = name;
|
|
529
|
+
score = 100;
|
|
530
|
+
signals.push(`known_ua:${name}`);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const accept = str(headers["accept"]);
|
|
535
|
+
if (!isDefiniteBot && accept === AXIOS_ACCEPT) {
|
|
536
|
+
isDefiniteBot = true;
|
|
537
|
+
knownClient = "axios";
|
|
538
|
+
score = 100;
|
|
539
|
+
signals.push("known_accept:axios");
|
|
540
|
+
}
|
|
541
|
+
if (isDefiniteBot) return { score, signals, isDefiniteBot, knownClient };
|
|
542
|
+
const hasSecFetchSite = "sec-fetch-site" in headers;
|
|
543
|
+
const hasSecFetchMode = "sec-fetch-mode" in headers;
|
|
544
|
+
if (!hasSecFetchSite && !hasSecFetchMode) {
|
|
545
|
+
score += 30;
|
|
546
|
+
signals.push("missing_sec_fetch");
|
|
547
|
+
}
|
|
548
|
+
const acceptLang = str(headers["accept-language"]);
|
|
549
|
+
if (!acceptLang) {
|
|
550
|
+
score += 25;
|
|
551
|
+
signals.push("missing_accept_language");
|
|
552
|
+
}
|
|
553
|
+
const acceptEnc = str(headers["accept-encoding"]);
|
|
554
|
+
if (!acceptEnc) {
|
|
555
|
+
score += 10;
|
|
556
|
+
signals.push("missing_accept_encoding");
|
|
557
|
+
}
|
|
558
|
+
if (hasSecFetchMode && !hasSecFetchSite) {
|
|
559
|
+
score += 20;
|
|
560
|
+
signals.push("undici_pattern:sec_fetch_mode_without_site");
|
|
561
|
+
knownClient = "undici (Node.js fetch)";
|
|
562
|
+
}
|
|
563
|
+
if (acceptLang === "*") {
|
|
564
|
+
score += 15;
|
|
565
|
+
signals.push("undici_pattern:accept_language_wildcard");
|
|
566
|
+
}
|
|
567
|
+
const claimsChrome = /Chrome\//i.test(ua) && !/Chromium/i.test(ua);
|
|
568
|
+
const hasSecChUa = "sec-ch-ua" in headers;
|
|
569
|
+
if (claimsChrome && !hasSecChUa) {
|
|
570
|
+
score += 10;
|
|
571
|
+
signals.push("chrome_ua_without_sec_ch_ua");
|
|
572
|
+
}
|
|
573
|
+
score = Math.min(100, score);
|
|
574
|
+
return { score, signals, isDefiniteBot, knownClient };
|
|
575
|
+
}
|
|
576
|
+
function extractHttp2Settings(req) {
|
|
577
|
+
const stream = req["stream"] ?? req["raw"]?.["stream"];
|
|
578
|
+
if (!stream) return null;
|
|
579
|
+
const session = stream["session"];
|
|
580
|
+
if (!session) return null;
|
|
581
|
+
const remote = session["remoteSettings"];
|
|
582
|
+
if (!remote) return null;
|
|
583
|
+
const initialWindowSize = remote.initialWindowSize ?? 65535;
|
|
584
|
+
const headerTableSize = remote.headerTableSize ?? 4096;
|
|
585
|
+
const enablePush = remote.enablePush ?? true;
|
|
586
|
+
const signals = [];
|
|
587
|
+
let score = 0;
|
|
588
|
+
if (initialWindowSize <= 131072) {
|
|
589
|
+
score += 20;
|
|
590
|
+
signals.push(`h2_window_${initialWindowSize}`);
|
|
591
|
+
}
|
|
592
|
+
if (headerTableSize <= 4096 && initialWindowSize <= 131072) {
|
|
593
|
+
score += 5;
|
|
594
|
+
signals.push("h2_header_table_default");
|
|
595
|
+
}
|
|
596
|
+
return { initialWindowSize, headerTableSize, enablePush, score, signals };
|
|
597
|
+
}
|
|
598
|
+
function extractUpstreamTls(headers) {
|
|
599
|
+
const ja3 = str(headers["x-ja3-hash"]) || str(headers["x-ja3"]) || null;
|
|
600
|
+
const ja4 = str(headers["x-ja4"]) || null;
|
|
601
|
+
return { ja3: ja3 || null, ja4: ja4 || null };
|
|
602
|
+
}
|
|
603
|
+
function str(v) {
|
|
604
|
+
if (v === void 0) return "";
|
|
605
|
+
return Array.isArray(v) ? v[0] ?? "" : v;
|
|
606
|
+
}
|
|
607
|
+
function detectHoneypotType(path) {
|
|
608
|
+
const p = path.toLowerCase().split("?")[0] ?? path.toLowerCase();
|
|
609
|
+
if (/\/\.env(\.[\w]+)?$/.test(p) || p.endsWith("/.env")) return "env_file";
|
|
610
|
+
if (p.includes(".aws/credentials") || p.includes("aws/credentials")) return "aws_credentials";
|
|
611
|
+
if (p.includes(".git/config") || p.endsWith("/git/config")) return "git_config";
|
|
612
|
+
if (p.includes("/graphql") || p.includes("/graphiql")) return "graphql";
|
|
613
|
+
if (p.includes("/actuator/env") || p.includes("/actuator/")) return "spring_actuator";
|
|
614
|
+
if (/\/(config|settings|secrets?)(\.json)?$/.test(p)) return "json_config";
|
|
615
|
+
if (p.endsWith("/.htpasswd") || p.endsWith("/.htaccess")) return "htpasswd";
|
|
616
|
+
if (p.includes("/.s3cfg") || p.endsWith("/s3") || p.includes("s3cfg")) return "s3_bucket";
|
|
617
|
+
if (/\/(admin|wp-admin|administrator|panel|cpanel|dashboard|manage|backend)(\/|$)/.test(p)) return "admin_portal";
|
|
618
|
+
return "generic";
|
|
619
|
+
}
|
|
620
|
+
function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
|
|
621
|
+
switch (type) {
|
|
622
|
+
case "env_file":
|
|
623
|
+
return makeEnvFile(canaryToken);
|
|
624
|
+
case "aws_credentials":
|
|
625
|
+
return makeAwsCredentials(canaryToken);
|
|
626
|
+
case "git_config":
|
|
627
|
+
return makeGitConfig(canaryToken, callbackBase, orgId);
|
|
628
|
+
case "graphql":
|
|
629
|
+
return makeGraphQL();
|
|
630
|
+
case "spring_actuator":
|
|
631
|
+
return makeSpringActuator(canaryToken);
|
|
632
|
+
case "json_config":
|
|
633
|
+
return makeJsonConfig(canaryToken, callbackBase, orgId);
|
|
634
|
+
case "htpasswd":
|
|
635
|
+
return makeHtpasswd(canaryToken);
|
|
636
|
+
case "s3_bucket":
|
|
637
|
+
return makeS3Bucket(canaryToken);
|
|
638
|
+
case "admin_portal":
|
|
639
|
+
return makeAdminPortal(canaryToken);
|
|
640
|
+
default:
|
|
641
|
+
return makeGeneric(canaryToken);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function makeEnvFile(canaryToken, _callbackBase, _orgId) {
|
|
645
|
+
const jwtSecret = genHex(32);
|
|
646
|
+
const dbPassword = genAlphanumeric(20);
|
|
647
|
+
const redisPassword = genAlphanumeric(16);
|
|
648
|
+
const apiKey = genHex(32);
|
|
649
|
+
const dnsDb = `db.${canaryToken}.srv.anomira.io`;
|
|
650
|
+
const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
|
|
651
|
+
const dnsMonitoring = `hooks.monitoring.${canaryToken}.srv.anomira.io`;
|
|
652
|
+
const dnsAlerts = `hooks.alerts.${canaryToken}.srv.anomira.io`;
|
|
653
|
+
const dnsDeploy = `deploy.internal.${canaryToken}.srv.anomira.io`;
|
|
654
|
+
const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
|
|
655
|
+
const body = `# Production Environment Configuration
|
|
656
|
+
# Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
|
|
657
|
+
|
|
658
|
+
NODE_ENV=production
|
|
659
|
+
PORT=3000
|
|
660
|
+
|
|
661
|
+
# \u2500\u2500 Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
662
|
+
DATABASE_URL=postgresql://db_prod:${dbPassword}@${dnsDb}:5432/app_production
|
|
663
|
+
DATABASE_REPLICA_URL=postgresql://db_prod:${dbPassword}@${dnsDb}:5433/app_production
|
|
664
|
+
DATABASE_POOL_MIN=2
|
|
665
|
+
DATABASE_POOL_MAX=10
|
|
666
|
+
|
|
667
|
+
# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
668
|
+
REDIS_URL=redis://:${redisPassword}@${dnsCache}:6379/0
|
|
669
|
+
|
|
670
|
+
# \u2500\u2500 Authentication \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
671
|
+
JWT_SECRET=${jwtSecret}
|
|
672
|
+
JWT_EXPIRES_IN=15m
|
|
673
|
+
REFRESH_TOKEN_SECRET=${genHex(32)}
|
|
674
|
+
SESSION_SECRET=${genHex(32)}
|
|
675
|
+
|
|
676
|
+
# \u2500\u2500 Sample authenticated token (service account) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
677
|
+
SERVICE_ACCOUNT_TOKEN=${fakeJwt}
|
|
678
|
+
|
|
679
|
+
# \u2500\u2500 AWS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
680
|
+
AWS_REGION=eu-west-1
|
|
681
|
+
AWS_S3_BUCKET=app-production-${genHex(4)}
|
|
682
|
+
|
|
683
|
+
# \u2500\u2500 Monitoring / Webhooks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
684
|
+
MONITORING_WEBHOOK=https://${dnsMonitoring}/v1/events/ingest
|
|
685
|
+
ALERT_WEBHOOK=https://${dnsAlerts}/services/${genHex(7).toUpperCase()}/notify
|
|
686
|
+
DEPLOY_HOOK=https://${dnsDeploy}/hooks/deploy/${genHex(6)}
|
|
687
|
+
|
|
688
|
+
# \u2500\u2500 External APIs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
689
|
+
INTERNAL_API_KEY=${apiKey}
|
|
690
|
+
`;
|
|
691
|
+
return {
|
|
692
|
+
statusCode: 200,
|
|
693
|
+
contentType: "text/plain; charset=utf-8",
|
|
694
|
+
headers: {
|
|
695
|
+
"Cache-Control": "no-store",
|
|
696
|
+
"Last-Modified": new Date(Date.now() - 7 * 864e5).toUTCString(),
|
|
697
|
+
"ETag": `"${genHex(8)}-${genHex(4)}"`,
|
|
698
|
+
// Apache-style headers — most exposed .env files are on PHP/Apache stacks
|
|
699
|
+
"Server": "Apache/2.4.41 (Ubuntu)",
|
|
700
|
+
"X-Content-Type-Options": "nosniff"
|
|
701
|
+
},
|
|
702
|
+
body,
|
|
703
|
+
canaryToken
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function makeAwsCredentials(canaryToken) {
|
|
707
|
+
const fakeKeyId = makeAwsKeyId();
|
|
708
|
+
const fakeSecret = makeAwsSecret();
|
|
709
|
+
const stsPrefixKey = "ASIA" + makeAwsKeyId().slice(4);
|
|
710
|
+
const stsSecret = makeAwsSecret();
|
|
711
|
+
const stsToken = genBase64(300);
|
|
712
|
+
const body = `[default]
|
|
713
|
+
aws_access_key_id=${fakeKeyId}
|
|
714
|
+
aws_secret_access_key=${fakeSecret}
|
|
715
|
+
region=eu-west-1
|
|
716
|
+
|
|
717
|
+
[production]
|
|
718
|
+
aws_access_key_id=${fakeKeyId}
|
|
719
|
+
aws_secret_access_key=${fakeSecret}
|
|
720
|
+
region=eu-west-1
|
|
721
|
+
|
|
722
|
+
[staging]
|
|
723
|
+
aws_access_key_id=${stsPrefixKey}
|
|
724
|
+
aws_secret_access_key=${stsSecret}
|
|
725
|
+
aws_session_token=${stsToken}
|
|
726
|
+
region=eu-west-1
|
|
727
|
+
`;
|
|
728
|
+
return {
|
|
729
|
+
statusCode: 200,
|
|
730
|
+
contentType: "text/plain; charset=utf-8",
|
|
731
|
+
headers: {
|
|
732
|
+
"Cache-Control": "no-store",
|
|
733
|
+
"Last-Modified": new Date(Date.now() - 14 * 864e5).toUTCString(),
|
|
734
|
+
// Match the Server header an AWS EC2 instance metadata endpoint might have
|
|
735
|
+
"Server": "EC2ws",
|
|
736
|
+
"X-Content-Type-Options": "nosniff"
|
|
737
|
+
},
|
|
738
|
+
body,
|
|
739
|
+
canaryToken
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function makeGitConfig(canaryToken, callbackBase, orgId) {
|
|
743
|
+
const ghpToken = `ghp_${genAlphanumeric(36)}`;
|
|
744
|
+
const glpatToken = `glpat-${genAlphanumeric(20)}`;
|
|
745
|
+
const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
|
|
746
|
+
const dnsGit = `git.${canaryToken}.srv.anomira.io`;
|
|
747
|
+
const body = `[core]
|
|
748
|
+
repositoryformatversion = 0
|
|
749
|
+
filemode = true
|
|
750
|
+
bare = false
|
|
751
|
+
logallrefupdates = true
|
|
752
|
+
|
|
753
|
+
[remote "origin"]
|
|
754
|
+
url = https://oauth2:${ghpToken}@github.com/company/app.git
|
|
755
|
+
fetch = +refs/heads/*:refs/remotes/origin/*
|
|
756
|
+
|
|
757
|
+
[remote "deploy"]
|
|
758
|
+
url = https://deploy-token:${glpatToken}@${dnsGit}/backend/app.git
|
|
759
|
+
fetch = +refs/heads/*:refs/remotes/deploy/*
|
|
760
|
+
|
|
761
|
+
[remote "notify"]
|
|
762
|
+
url = ${webhookUrl}
|
|
763
|
+
|
|
764
|
+
[branch "main"]
|
|
765
|
+
remote = origin
|
|
766
|
+
merge = refs/heads/main
|
|
767
|
+
|
|
768
|
+
[branch "production"]
|
|
769
|
+
remote = deploy
|
|
770
|
+
merge = refs/heads/production
|
|
771
|
+
|
|
772
|
+
[credential "https://github.com"]
|
|
773
|
+
username = deploy-bot
|
|
774
|
+
`;
|
|
775
|
+
return {
|
|
776
|
+
statusCode: 200,
|
|
777
|
+
contentType: "text/plain; charset=utf-8",
|
|
778
|
+
headers: {
|
|
779
|
+
"Cache-Control": "no-store",
|
|
780
|
+
"Last-Modified": new Date(Date.now() - 30 * 864e5).toUTCString(),
|
|
781
|
+
"Server": "Apache/2.4.41 (Ubuntu)"
|
|
782
|
+
},
|
|
783
|
+
body,
|
|
784
|
+
canaryToken
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
function makeGraphQL() {
|
|
788
|
+
const body = JSON.stringify({
|
|
789
|
+
data: {
|
|
790
|
+
__schema: {
|
|
791
|
+
queryType: { name: "Query" },
|
|
792
|
+
mutationType: { name: "Mutation" },
|
|
793
|
+
types: [
|
|
794
|
+
{ kind: "OBJECT", name: "Query", fields: [{ name: "user" }, { name: "users" }, { name: "posts" }] },
|
|
795
|
+
{ kind: "OBJECT", name: "Mutation", fields: [{ name: "login" }, { name: "createUser" }, { name: "adminReset" }, { name: "exportData" }] },
|
|
796
|
+
{ kind: "OBJECT", name: "User", fields: [{ name: "id" }, { name: "email" }, { name: "role" }, { name: "createdAt" }] },
|
|
797
|
+
{ kind: "OBJECT", name: "AuthPayload", fields: [{ name: "token" }, { name: "user" }] },
|
|
798
|
+
{ kind: "SCALAR", name: "String", fields: null },
|
|
799
|
+
{ kind: "SCALAR", name: "Boolean", fields: null },
|
|
800
|
+
{ kind: "SCALAR", name: "Int", fields: null },
|
|
801
|
+
{ kind: "SCALAR", name: "ID", fields: null }
|
|
802
|
+
]
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
statusCode: 200,
|
|
808
|
+
contentType: "application/json; charset=utf-8",
|
|
809
|
+
headers: {
|
|
810
|
+
"X-Powered-By": "Express",
|
|
811
|
+
"X-Request-Id": `req_${genHex(16)}`
|
|
812
|
+
},
|
|
813
|
+
body,
|
|
814
|
+
canaryToken: ""
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function makeSpringActuator(canaryToken) {
|
|
818
|
+
const dbPassword = genAlphanumeric(20);
|
|
819
|
+
const secretKey = genHex(32);
|
|
820
|
+
const body = JSON.stringify({
|
|
821
|
+
activeProfiles: ["production"],
|
|
822
|
+
defaultProfiles: ["default"],
|
|
823
|
+
propertySources: [
|
|
824
|
+
{
|
|
825
|
+
name: "systemProperties",
|
|
826
|
+
properties: {
|
|
827
|
+
"java.runtime.version": { value: "17.0.9+9" },
|
|
828
|
+
"server.port": { value: "8080" },
|
|
829
|
+
"user.home": { value: "/home/appuser" }
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "applicationConfig: [classpath:/application-production.properties]",
|
|
834
|
+
properties: {
|
|
835
|
+
"spring.datasource.url": { origin: "class path resource [application-production.properties] - 3:1", value: "jdbc:postgresql://db.internal:5432/myapp" },
|
|
836
|
+
"spring.datasource.username": { origin: "class path resource [application-production.properties] - 4:1", value: "dbadmin" },
|
|
837
|
+
"spring.datasource.password": { origin: "class path resource [application-production.properties] - 5:1", value: dbPassword },
|
|
838
|
+
"app.jwt.secret": { origin: "class path resource [application-production.properties] - 8:1", value: secretKey },
|
|
839
|
+
"app.canary.token": { value: canaryToken },
|
|
840
|
+
"management.endpoints.web.exposure.include": { value: "health,info,env,metrics,loggers" }
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
]
|
|
844
|
+
}, null, 2);
|
|
845
|
+
return {
|
|
846
|
+
statusCode: 200,
|
|
847
|
+
contentType: "application/vnd.spring-boot.actuator.v3+json",
|
|
848
|
+
headers: {
|
|
849
|
+
"X-Application-Context": "myapp:production:8080",
|
|
850
|
+
"X-Content-Type-Options": "nosniff",
|
|
851
|
+
"X-XSS-Protection": "1; mode=block"
|
|
852
|
+
},
|
|
853
|
+
body,
|
|
854
|
+
canaryToken
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function makeJsonConfig(canaryToken, callbackBase, orgId) {
|
|
858
|
+
const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
|
|
859
|
+
const body = JSON.stringify({
|
|
860
|
+
environment: "production",
|
|
861
|
+
version: "2.4.1",
|
|
862
|
+
database: {
|
|
863
|
+
host: "db.internal.company.com",
|
|
864
|
+
port: 5432,
|
|
865
|
+
name: "app_production",
|
|
866
|
+
user: "db_prod",
|
|
867
|
+
password: genAlphanumeric(20)
|
|
868
|
+
},
|
|
869
|
+
jwt: {
|
|
870
|
+
secret: genHex(32),
|
|
871
|
+
expiresIn: "15m"
|
|
872
|
+
},
|
|
873
|
+
webhooks: {
|
|
874
|
+
events: webhookUrl,
|
|
875
|
+
alerts: `${callbackBase}/v1/canary/${orgId}/${canaryToken}`
|
|
876
|
+
},
|
|
877
|
+
internalApiKey: genHex(32)
|
|
878
|
+
}, null, 2);
|
|
879
|
+
return {
|
|
880
|
+
statusCode: 200,
|
|
881
|
+
contentType: "application/json; charset=utf-8",
|
|
882
|
+
headers: {
|
|
883
|
+
"Cache-Control": "no-store",
|
|
884
|
+
"ETag": `"${genHex(8)}"`,
|
|
885
|
+
"X-Powered-By": "Express"
|
|
886
|
+
},
|
|
887
|
+
body,
|
|
888
|
+
canaryToken
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function makeHtpasswd(canaryToken) {
|
|
892
|
+
const apr1Salt = genAlphanumeric(8).toLowerCase();
|
|
893
|
+
const apr1Hash = genBase64(22).replace(
|
|
894
|
+
/[+=/]/g,
|
|
895
|
+
(c) => ({ "+": ".", "=": "/", "/": "X" })[c] ?? c
|
|
896
|
+
);
|
|
897
|
+
const bcryptHash = genBase64(53).replace(
|
|
898
|
+
/[+=]/g,
|
|
899
|
+
(c) => ({ "+": ".", "=": "/" })[c] ?? c
|
|
900
|
+
);
|
|
901
|
+
const canaryApr1 = `$apr1$${canaryToken.slice(0, 8)}$${genBase64(22).slice(0, 22)}`;
|
|
902
|
+
const body = `# Apache HTTP Server password file
|
|
903
|
+
admin:$apr1$${apr1Salt}$${apr1Hash}
|
|
904
|
+
deploy:$2y$10$${bcryptHash}
|
|
905
|
+
root:${canaryApr1}
|
|
906
|
+
backup-user:$apr1$${genAlphanumeric(8).toLowerCase()}$${genBase64(22).slice(0, 22)}
|
|
907
|
+
`;
|
|
908
|
+
return {
|
|
909
|
+
statusCode: 200,
|
|
910
|
+
contentType: "text/plain; charset=utf-8",
|
|
911
|
+
headers: {
|
|
912
|
+
"Cache-Control": "no-store",
|
|
913
|
+
"Last-Modified": new Date(Date.now() - 45 * 864e5).toUTCString(),
|
|
914
|
+
"Server": "Apache/2.4.41 (Ubuntu)",
|
|
915
|
+
"Content-Disposition": "inline"
|
|
916
|
+
},
|
|
917
|
+
body,
|
|
918
|
+
canaryToken
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function makeS3Bucket(canaryToken) {
|
|
922
|
+
const ownerId = genHex(32) + genHex(32);
|
|
923
|
+
const now = /* @__PURE__ */ new Date();
|
|
924
|
+
const dateStr = (offset) => new Date(now.getTime() - offset).toISOString().replace(/\.\d{3}Z/, ".000Z");
|
|
925
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
926
|
+
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
927
|
+
<Owner>
|
|
928
|
+
<ID>${ownerId}</ID>
|
|
929
|
+
<DisplayName>company-admin</DisplayName>
|
|
930
|
+
</Owner>
|
|
931
|
+
<Buckets>
|
|
932
|
+
<Bucket>
|
|
933
|
+
<Name>company-production-data</Name>
|
|
934
|
+
<CreationDate>${dateStr(120 * 864e5)}</CreationDate>
|
|
935
|
+
</Bucket>
|
|
936
|
+
<Bucket>
|
|
937
|
+
<Name>company-assets-${genHex(4)}</Name>
|
|
938
|
+
<CreationDate>${dateStr(90 * 864e5)}</CreationDate>
|
|
939
|
+
</Bucket>
|
|
940
|
+
<Bucket>
|
|
941
|
+
<Name>company-backups-${canaryToken.slice(0, 12)}</Name>
|
|
942
|
+
<CreationDate>${dateStr(30 * 864e5)}</CreationDate>
|
|
943
|
+
</Bucket>
|
|
944
|
+
<Bucket>
|
|
945
|
+
<Name>company-logs-archive</Name>
|
|
946
|
+
<CreationDate>${dateStr(180 * 864e5)}</CreationDate>
|
|
947
|
+
</Bucket>
|
|
948
|
+
</Buckets>
|
|
949
|
+
</ListAllMyBucketsResult>`;
|
|
950
|
+
return {
|
|
951
|
+
statusCode: 200,
|
|
952
|
+
contentType: "application/xml",
|
|
953
|
+
headers: {
|
|
954
|
+
"x-amz-request-id": genHex(8).toUpperCase() + genHex(8).toUpperCase(),
|
|
955
|
+
"x-amz-id-2": genBase64(60),
|
|
956
|
+
"Server": "AmazonS3"
|
|
957
|
+
},
|
|
958
|
+
body,
|
|
959
|
+
canaryToken
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function makeAdminPortal(canaryToken) {
|
|
963
|
+
const body = `<!DOCTYPE html>
|
|
964
|
+
<html lang="en">
|
|
965
|
+
<head>
|
|
966
|
+
<meta charset="UTF-8" />
|
|
967
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
968
|
+
<title>Administration \u2014 Login</title>
|
|
969
|
+
<style>
|
|
970
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
971
|
+
body {
|
|
972
|
+
background: #0f1117;
|
|
973
|
+
color: #c9d1d9;
|
|
974
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
975
|
+
display: flex; align-items: center; justify-content: center;
|
|
976
|
+
min-height: 100vh;
|
|
977
|
+
}
|
|
978
|
+
.card {
|
|
979
|
+
background: #161b22;
|
|
980
|
+
border: 1px solid #30363d;
|
|
981
|
+
border-radius: 8px;
|
|
982
|
+
padding: 32px;
|
|
983
|
+
width: 100%;
|
|
984
|
+
max-width: 360px;
|
|
985
|
+
}
|
|
986
|
+
h1 { font-size: 1.1rem; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
|
|
987
|
+
.subtitle { font-size: 0.8rem; color: #7d8590; margin-bottom: 24px; }
|
|
988
|
+
label { display: block; font-size: 0.8rem; font-weight: 600; color: #c9d1d9; margin-bottom: 6px; }
|
|
989
|
+
input[type=text], input[type=password] {
|
|
990
|
+
width: 100%; padding: 8px 12px; border: 1px solid #30363d;
|
|
991
|
+
border-radius: 6px; background: #0d1117; color: #c9d1d9;
|
|
992
|
+
font-size: 0.875rem; outline: none; margin-bottom: 16px;
|
|
993
|
+
}
|
|
994
|
+
input:focus { border-color: #388bfd; box-shadow: 0 0 0 3px rgba(56,139,253,.1); }
|
|
995
|
+
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
996
|
+
.row label { margin-bottom: 0; font-weight: normal; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
997
|
+
button {
|
|
998
|
+
width: 100%; padding: 8px 16px; background: #238636; color: #fff;
|
|
999
|
+
border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600;
|
|
1000
|
+
cursor: pointer; transition: background .15s;
|
|
1001
|
+
}
|
|
1002
|
+
button:hover { background: #2ea043; }
|
|
1003
|
+
.footer { text-align: center; font-size: 0.72rem; color: #7d8590; margin-top: 20px; }
|
|
1004
|
+
</style>
|
|
1005
|
+
</head>
|
|
1006
|
+
<body>
|
|
1007
|
+
<div class="card">
|
|
1008
|
+
<h1>Administration Panel</h1>
|
|
1009
|
+
<p class="subtitle">Sign in to continue</p>
|
|
1010
|
+
<form method="POST" action="./login" autocomplete="off">
|
|
1011
|
+
<input type="hidden" name="_token" value="${canaryToken}" />
|
|
1012
|
+
<div>
|
|
1013
|
+
<label for="username">Username</label>
|
|
1014
|
+
<input id="username" name="username" type="text" placeholder="admin" autocomplete="off" />
|
|
1015
|
+
</div>
|
|
1016
|
+
<div>
|
|
1017
|
+
<label for="password">Password</label>
|
|
1018
|
+
<input id="password" name="password" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" autocomplete="off" />
|
|
1019
|
+
</div>
|
|
1020
|
+
<div class="row">
|
|
1021
|
+
<label><input type="checkbox" name="remember" /> Remember me</label>
|
|
1022
|
+
<a href="#" style="font-size:.8rem;color:#388bfd;text-decoration:none;">Forgot password?</a>
|
|
1023
|
+
</div>
|
|
1024
|
+
<button type="submit">Sign in</button>
|
|
1025
|
+
</form>
|
|
1026
|
+
<p class="footer">v3.2.1 \xB7 Secure Administration Portal</p>
|
|
1027
|
+
</div>
|
|
1028
|
+
</body>
|
|
1029
|
+
</html>`;
|
|
1030
|
+
return {
|
|
1031
|
+
statusCode: 200,
|
|
1032
|
+
contentType: "text/html; charset=utf-8",
|
|
1033
|
+
headers: {
|
|
1034
|
+
"Cache-Control": "no-store, no-cache",
|
|
1035
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
1036
|
+
"Server": "nginx/1.18.0 (Ubuntu)"
|
|
1037
|
+
},
|
|
1038
|
+
body,
|
|
1039
|
+
canaryToken
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
function makeAdminLoginFailed() {
|
|
1043
|
+
const body = `<!DOCTYPE html>
|
|
1044
|
+
<html lang="en">
|
|
1045
|
+
<head>
|
|
1046
|
+
<meta charset="UTF-8" />
|
|
1047
|
+
<title>Administration \u2014 Login</title>
|
|
1048
|
+
<style>
|
|
1049
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1050
|
+
body { background: #0f1117; color: #c9d1d9; font-family: -apple-system, sans-serif;
|
|
1051
|
+
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1052
|
+
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 100%; max-width: 360px; }
|
|
1053
|
+
h1 { font-size: 1.1rem; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
|
|
1054
|
+
.error { background: #3d1010; border: 1px solid #f85149; color: #f85149; border-radius: 6px; padding: 10px 14px; font-size: .8rem; margin-bottom: 16px; }
|
|
1055
|
+
label { display: block; font-size: .8rem; font-weight: 600; color: #c9d1d9; margin-bottom: 6px; }
|
|
1056
|
+
input[type=text], input[type=password] { width: 100%; padding: 8px 12px; border: 1px solid #f85149; border-radius: 6px; background: #0d1117; color: #c9d1d9; font-size: .875rem; outline: none; margin-bottom: 16px; }
|
|
1057
|
+
button { width: 100%; padding: 8px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: .875rem; font-weight: 600; cursor: pointer; }
|
|
1058
|
+
.footer { text-align: center; font-size: .72rem; color: #7d8590; margin-top: 20px; }
|
|
1059
|
+
</style>
|
|
1060
|
+
</head>
|
|
1061
|
+
<body>
|
|
1062
|
+
<div class="card">
|
|
1063
|
+
<h1>Administration Panel</h1>
|
|
1064
|
+
<div class="error">\u26A0 Invalid username or password. Please try again.</div>
|
|
1065
|
+
<form method="POST" action="" autocomplete="off">
|
|
1066
|
+
<div><label for="u">Username</label><input id="u" name="username" type="text" autocomplete="off" /></div>
|
|
1067
|
+
<div><label for="p">Password</label><input id="p" name="password" type="password" autocomplete="off" /></div>
|
|
1068
|
+
<button type="submit">Sign in</button>
|
|
1069
|
+
</form>
|
|
1070
|
+
<p class="footer">v3.2.1 \xB7 Secure Administration Portal</p>
|
|
1071
|
+
</div>
|
|
1072
|
+
</body>
|
|
1073
|
+
</html>`;
|
|
1074
|
+
return {
|
|
1075
|
+
statusCode: 401,
|
|
1076
|
+
contentType: "text/html; charset=utf-8",
|
|
1077
|
+
headers: {
|
|
1078
|
+
"Cache-Control": "no-store",
|
|
1079
|
+
"Server": "nginx/1.18.0 (Ubuntu)",
|
|
1080
|
+
"WWW-Authenticate": 'Form realm="Administration Panel"'
|
|
1081
|
+
},
|
|
1082
|
+
body,
|
|
1083
|
+
canaryToken: ""
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function makeGeneric(canaryToken) {
|
|
1087
|
+
return {
|
|
1088
|
+
statusCode: 200,
|
|
1089
|
+
contentType: "text/plain; charset=utf-8",
|
|
1090
|
+
headers: { "Cache-Control": "no-store" },
|
|
1091
|
+
body: `# ${canaryToken}
|
|
1092
|
+
`,
|
|
1093
|
+
canaryToken
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function generateCanaryJwt(canaryToken, secret) {
|
|
1097
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1098
|
+
const kid = `${canaryToken.slice(0, 8)}-${canaryToken.slice(8, 12)}-4${canaryToken.slice(13, 16)}-${canaryToken.slice(16, 20)}-${canaryToken.slice(20, 32)}`;
|
|
1099
|
+
const header = { alg: "HS256", typ: "JWT", kid };
|
|
1100
|
+
const payload = {
|
|
1101
|
+
sub: "svc_internal_7482",
|
|
1102
|
+
name: "service-account",
|
|
1103
|
+
role: "admin",
|
|
1104
|
+
email: "admin@internal.company.com",
|
|
1105
|
+
iat: now - 172800,
|
|
1106
|
+
// issued 48h ago (realistic stale credential)
|
|
1107
|
+
exp: now - 86400,
|
|
1108
|
+
// expired 24h ago
|
|
1109
|
+
jti: canaryToken
|
|
1110
|
+
// raw hex — no "canary-" prefix to leak purpose
|
|
1111
|
+
};
|
|
1112
|
+
const h = Buffer.from(JSON.stringify(header)).toString("base64url");
|
|
1113
|
+
const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
1114
|
+
const sig = createHmac("sha256", secret).update(`${h}.${p}`).digest("base64url");
|
|
1115
|
+
return `${h}.${p}.${sig}`;
|
|
1116
|
+
}
|
|
1117
|
+
function makeAwsKeyId() {
|
|
1118
|
+
const base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1119
|
+
const middle = Array.from(
|
|
1120
|
+
{ length: 13 },
|
|
1121
|
+
() => base32Chars[randomBytes(1)[0] % base32Chars.length]
|
|
1122
|
+
).join("");
|
|
1123
|
+
return `AKIAI${middle}A`;
|
|
1124
|
+
}
|
|
1125
|
+
function makeAwsSecret() {
|
|
1126
|
+
return randomBytes(30).toString("base64").slice(0, 40);
|
|
1127
|
+
}
|
|
1128
|
+
function genHex(bytes) {
|
|
1129
|
+
return randomBytes(bytes).toString("hex");
|
|
1130
|
+
}
|
|
1131
|
+
function genAlphanumeric(len) {
|
|
1132
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
1133
|
+
return Array.from(
|
|
1134
|
+
{ length: len },
|
|
1135
|
+
() => chars[randomBytes(1)[0] % chars.length]
|
|
1136
|
+
).join("");
|
|
1137
|
+
}
|
|
1138
|
+
function genBase64(approxLen) {
|
|
1139
|
+
return randomBytes(Math.ceil(approxLen * 3 / 4)).toString("base64").slice(0, approxLen);
|
|
1140
|
+
}
|
|
406
1141
|
var requestContext = new AsyncLocalStorage();
|
|
407
1142
|
var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
|
|
408
1143
|
var DEFAULT_BATCH_SIZE = 100;
|
|
@@ -414,10 +1149,28 @@ var AnomiraClient = class {
|
|
|
414
1149
|
this.logFlushTimer = null;
|
|
415
1150
|
this.blocklistTimer = null;
|
|
416
1151
|
this.firewallTimer = null;
|
|
1152
|
+
this.communityThreatTimer = null;
|
|
417
1153
|
/** True when credentials are missing — all operations become no-ops. */
|
|
418
1154
|
this.disabled = false;
|
|
419
|
-
/** In-process cache of blocked IPs — refreshed every 60 s
|
|
1155
|
+
/** In-process cache of manually blocked IPs — refreshed every 60 s. */
|
|
420
1156
|
this.blockedIpCache = /* @__PURE__ */ new Set();
|
|
1157
|
+
/** In-process cache of community threat IPs with their confidence scores.
|
|
1158
|
+
* Populated from Anomira's federated threat network — IPs confirmed malicious
|
|
1159
|
+
* across multiple customers. Refreshed every 60 s. */
|
|
1160
|
+
this.communityThreatCache = /* @__PURE__ */ new Map();
|
|
1161
|
+
/**
|
|
1162
|
+
* Set of honeypot paths to intercept. When a request matches, the SDK returns
|
|
1163
|
+
* a fake response instead of forwarding to the customer's route handlers.
|
|
1164
|
+
* Synced from ingest every 60 s. Keys are lowercase path strings.
|
|
1165
|
+
*/
|
|
1166
|
+
this.honeypotPaths = /* @__PURE__ */ new Set();
|
|
1167
|
+
/**
|
|
1168
|
+
* Set of active canary token strings embedded in previously-served honeypot
|
|
1169
|
+
* responses. When any of these appear in a request's Authorization header or
|
|
1170
|
+
* body, a http.canary.triggered event is fired.
|
|
1171
|
+
* Synced from ingest every 60 s (max 100 tokens, sliding 7-day window).
|
|
1172
|
+
*/
|
|
1173
|
+
this.canaryTokenCache = /* @__PURE__ */ new Set();
|
|
421
1174
|
/** In-process cache of firewall rules with pre-compiled regex — refreshed every 60 s. */
|
|
422
1175
|
this.compiledRules = [];
|
|
423
1176
|
// Saved originals — used by SDK internals so patched console doesn't recurse
|
|
@@ -441,7 +1194,8 @@ var AnomiraClient = class {
|
|
|
441
1194
|
service: "app",
|
|
442
1195
|
getUserId: defaultGetUserId,
|
|
443
1196
|
getIp: defaultGetIp,
|
|
444
|
-
detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true }
|
|
1197
|
+
detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true },
|
|
1198
|
+
autoBlock: { enabled: true, communityThreshold: 85 }
|
|
445
1199
|
};
|
|
446
1200
|
this.buffer = new EventBuffer({ appId: "", apiKey: "", ingestUrl: DEFAULT_INGEST_URL, maxBatchSize: 0, flushIntervalMs: 999999999, maxRetries: 0, debug: false });
|
|
447
1201
|
return;
|
|
@@ -466,6 +1220,10 @@ var AnomiraClient = class {
|
|
|
466
1220
|
xss: config.detect?.xss ?? true,
|
|
467
1221
|
scanDetection: config.detect?.scanDetection ?? true,
|
|
468
1222
|
geoVelocity: config.detect?.geoVelocity ?? true
|
|
1223
|
+
},
|
|
1224
|
+
autoBlock: {
|
|
1225
|
+
enabled: config.autoBlock?.enabled ?? true,
|
|
1226
|
+
communityThreshold: config.autoBlock?.communityThreshold ?? 85
|
|
469
1227
|
}
|
|
470
1228
|
};
|
|
471
1229
|
this.buffer = new EventBuffer({
|
|
@@ -488,6 +1246,21 @@ var AnomiraClient = class {
|
|
|
488
1246
|
void this.#refreshFirewallRules();
|
|
489
1247
|
}, 6e4);
|
|
490
1248
|
if (this.firewallTimer.unref) this.firewallTimer.unref();
|
|
1249
|
+
void this.#refreshHoneypotPaths();
|
|
1250
|
+
const honeypotTimer = setInterval(() => {
|
|
1251
|
+
void this.#refreshHoneypotPaths();
|
|
1252
|
+
}, 6e4);
|
|
1253
|
+
if (honeypotTimer.unref) honeypotTimer.unref();
|
|
1254
|
+
void this.#refreshCanaryTokens();
|
|
1255
|
+
const canaryTimer = setInterval(() => {
|
|
1256
|
+
void this.#refreshCanaryTokens();
|
|
1257
|
+
}, 6e4);
|
|
1258
|
+
if (canaryTimer.unref) canaryTimer.unref();
|
|
1259
|
+
void this.#refreshCommunityThreats();
|
|
1260
|
+
this.communityThreatTimer = setInterval(() => {
|
|
1261
|
+
void this.#refreshCommunityThreats();
|
|
1262
|
+
}, 6e4);
|
|
1263
|
+
if (this.communityThreatTimer.unref) this.communityThreatTimer.unref();
|
|
491
1264
|
this.logFlushTimer = setInterval(() => {
|
|
492
1265
|
void this.#flushLogs();
|
|
493
1266
|
}, 1e4);
|
|
@@ -523,7 +1296,7 @@ var AnomiraClient = class {
|
|
|
523
1296
|
const res = await fetch(syncUrl, {
|
|
524
1297
|
headers: {
|
|
525
1298
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
526
|
-
"User-Agent": `@anomira/node-sdk/0.
|
|
1299
|
+
"User-Agent": `@anomira/node-sdk/0.2.2`
|
|
527
1300
|
},
|
|
528
1301
|
signal: AbortSignal.timeout(5e3)
|
|
529
1302
|
});
|
|
@@ -536,6 +1309,58 @@ var AnomiraClient = class {
|
|
|
536
1309
|
} catch {
|
|
537
1310
|
}
|
|
538
1311
|
}
|
|
1312
|
+
async #refreshCommunityThreats() {
|
|
1313
|
+
const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/community-threats/sync");
|
|
1314
|
+
try {
|
|
1315
|
+
const res = await fetch(syncUrl, {
|
|
1316
|
+
headers: {
|
|
1317
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
1318
|
+
"User-Agent": `@anomira/node-sdk/0.2.2`
|
|
1319
|
+
},
|
|
1320
|
+
signal: AbortSignal.timeout(5e3)
|
|
1321
|
+
});
|
|
1322
|
+
if (!res.ok) return;
|
|
1323
|
+
const data = await res.json();
|
|
1324
|
+
const newCache = /* @__PURE__ */ new Map();
|
|
1325
|
+
for (const t of data.threats ?? []) {
|
|
1326
|
+
newCache.set(t.ip, { score: t.score, topAttack: t.topAttack });
|
|
1327
|
+
}
|
|
1328
|
+
this.communityThreatCache = newCache;
|
|
1329
|
+
if (this.config.debug && newCache.size > 0) {
|
|
1330
|
+
this._origLog(`[Anomira] community threats refreshed \u2014 ${newCache.size} known threat IPs`);
|
|
1331
|
+
}
|
|
1332
|
+
} catch {
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
async #refreshHoneypotPaths() {
|
|
1336
|
+
const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/honeypots/sync");
|
|
1337
|
+
try {
|
|
1338
|
+
const res = await fetch(syncUrl, {
|
|
1339
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}`, "User-Agent": "@anomira/node-sdk/0.2.2" },
|
|
1340
|
+
signal: AbortSignal.timeout(5e3)
|
|
1341
|
+
});
|
|
1342
|
+
if (!res.ok) return;
|
|
1343
|
+
const data = await res.json();
|
|
1344
|
+
this.honeypotPaths = new Set((data.paths ?? []).map((p) => p.toLowerCase()));
|
|
1345
|
+
if (this.config.debug && this.honeypotPaths.size > 0) {
|
|
1346
|
+
this._origLog(`[Anomira] honeypot paths refreshed \u2014 ${this.honeypotPaths.size} traps active`);
|
|
1347
|
+
}
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async #refreshCanaryTokens() {
|
|
1352
|
+
const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/canary-tokens/sync");
|
|
1353
|
+
try {
|
|
1354
|
+
const res = await fetch(syncUrl, {
|
|
1355
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}`, "User-Agent": "@anomira/node-sdk/0.2.2" },
|
|
1356
|
+
signal: AbortSignal.timeout(5e3)
|
|
1357
|
+
});
|
|
1358
|
+
if (!res.ok) return;
|
|
1359
|
+
const data = await res.json();
|
|
1360
|
+
this.canaryTokenCache = new Set(data.tokens ?? []);
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
539
1364
|
async #refreshFirewallRules() {
|
|
540
1365
|
const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/firewall-rules/sync");
|
|
541
1366
|
try {
|
|
@@ -662,10 +1487,40 @@ var AnomiraClient = class {
|
|
|
662
1487
|
* });
|
|
663
1488
|
* ```
|
|
664
1489
|
*/
|
|
665
|
-
/**
|
|
1490
|
+
/**
|
|
1491
|
+
* Returns true if the IP should be blocked. Synchronous — no network call.
|
|
1492
|
+
*
|
|
1493
|
+
* Checks two independent sources:
|
|
1494
|
+
* 1. Manual blocklist — IPs explicitly blocked by the customer or via playbooks.
|
|
1495
|
+
* 2. Community threats — IPs from Anomira's federated network whose confidence
|
|
1496
|
+
* score meets the autoBlock.communityThreshold (default 85).
|
|
1497
|
+
* Only active when autoBlock.enabled is true (the default).
|
|
1498
|
+
*
|
|
1499
|
+
* If a customer sets autoBlock.enabled = false, only the manual blocklist is checked.
|
|
1500
|
+
*/
|
|
666
1501
|
isBlocked(ip) {
|
|
667
1502
|
if (this.disabled) return false;
|
|
668
|
-
|
|
1503
|
+
if (this.blockedIpCache.has(ip)) return true;
|
|
1504
|
+
if (this.config.autoBlock.enabled) {
|
|
1505
|
+
const threat = this.communityThreatCache.get(ip);
|
|
1506
|
+
if (threat && threat.score >= this.config.autoBlock.communityThreshold) return true;
|
|
1507
|
+
}
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Returns the block reason for an IP — useful for logging or custom responses.
|
|
1512
|
+
* Returns null if the IP is not blocked.
|
|
1513
|
+
*/
|
|
1514
|
+
blockReason(ip) {
|
|
1515
|
+
if (this.disabled) return null;
|
|
1516
|
+
if (this.blockedIpCache.has(ip)) return { source: "manual" };
|
|
1517
|
+
if (this.config.autoBlock.enabled) {
|
|
1518
|
+
const threat = this.communityThreatCache.get(ip);
|
|
1519
|
+
if (threat && threat.score >= this.config.autoBlock.communityThreshold) {
|
|
1520
|
+
return { source: "community", score: threat.score, topAttack: threat.topAttack };
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return null;
|
|
669
1524
|
}
|
|
670
1525
|
/**
|
|
671
1526
|
* Fire-and-forget: report a blocked-IP attempt to the ingest server so the
|
|
@@ -697,12 +1552,15 @@ var AnomiraClient = class {
|
|
|
697
1552
|
}
|
|
698
1553
|
track(eventName, data) {
|
|
699
1554
|
if (this.disabled) return;
|
|
1555
|
+
const ctx = requestContext.getStore();
|
|
1556
|
+
const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
|
|
1557
|
+
const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
|
|
700
1558
|
const event = {
|
|
701
1559
|
name: eventName,
|
|
702
1560
|
ts: Date.now(),
|
|
703
|
-
ip:
|
|
1561
|
+
ip: resolvedIp,
|
|
704
1562
|
userId: data.userId,
|
|
705
|
-
meta:
|
|
1563
|
+
meta: resolvedMeta
|
|
706
1564
|
};
|
|
707
1565
|
this.buffer.push(event);
|
|
708
1566
|
}
|
|
@@ -721,13 +1579,15 @@ var AnomiraClient = class {
|
|
|
721
1579
|
async trackLogin(data) {
|
|
722
1580
|
if (this.disabled) return;
|
|
723
1581
|
const tsMs = Date.now();
|
|
724
|
-
|
|
1582
|
+
const ctx = requestContext.getStore();
|
|
1583
|
+
const resolvedIp = data.ip && data.ip !== "0.0.0.0" ? data.ip : ctx?.ip ?? "0.0.0.0";
|
|
1584
|
+
this.track(EventName.LOGIN_SUCCESS, { ...data, ip: resolvedIp, meta: { ...data.meta } });
|
|
725
1585
|
if (!this.config.detect.geoVelocity) return;
|
|
726
1586
|
try {
|
|
727
|
-
const result = await checkGeoVelocity(data.userId,
|
|
1587
|
+
const result = await checkGeoVelocity(data.userId, resolvedIp, tsMs, this.config.geoLookupUrl || void 0);
|
|
728
1588
|
if (!result) return;
|
|
729
1589
|
this.track(EventName.GEO_VELOCITY, {
|
|
730
|
-
ip:
|
|
1590
|
+
ip: resolvedIp,
|
|
731
1591
|
userId: data.userId,
|
|
732
1592
|
meta: {
|
|
733
1593
|
distanceKm: result.distanceKm,
|
|
@@ -762,6 +1622,7 @@ var AnomiraClient = class {
|
|
|
762
1622
|
if (this.disabled) return;
|
|
763
1623
|
this.track(EventName.PHONE_AUTH, {
|
|
764
1624
|
ip: data.ip,
|
|
1625
|
+
// track() auto-resolves from context if empty
|
|
765
1626
|
userId: data.userId,
|
|
766
1627
|
meta: { phone: data.phone, ...data.meta }
|
|
767
1628
|
});
|
|
@@ -830,6 +1691,22 @@ var AnomiraClient = class {
|
|
|
830
1691
|
*/
|
|
831
1692
|
async flush() {
|
|
832
1693
|
if (this.disabled) return;
|
|
1694
|
+
if (this.blocklistTimer) {
|
|
1695
|
+
clearInterval(this.blocklistTimer);
|
|
1696
|
+
this.blocklistTimer = null;
|
|
1697
|
+
}
|
|
1698
|
+
if (this.firewallTimer) {
|
|
1699
|
+
clearInterval(this.firewallTimer);
|
|
1700
|
+
this.firewallTimer = null;
|
|
1701
|
+
}
|
|
1702
|
+
if (this.communityThreatTimer) {
|
|
1703
|
+
clearInterval(this.communityThreatTimer);
|
|
1704
|
+
this.communityThreatTimer = null;
|
|
1705
|
+
}
|
|
1706
|
+
if (this.logFlushTimer) {
|
|
1707
|
+
clearInterval(this.logFlushTimer);
|
|
1708
|
+
this.logFlushTimer = null;
|
|
1709
|
+
}
|
|
833
1710
|
await Promise.all([this.buffer.flush(), this.#flushLogs()]);
|
|
834
1711
|
}
|
|
835
1712
|
/**
|
|
@@ -856,8 +1733,60 @@ var AnomiraClient = class {
|
|
|
856
1733
|
function defaultGetUserId(req) {
|
|
857
1734
|
const r = req;
|
|
858
1735
|
const user = r["user"];
|
|
859
|
-
if (
|
|
860
|
-
|
|
1736
|
+
if (user && typeof user === "object") {
|
|
1737
|
+
const id = pickId(user);
|
|
1738
|
+
if (id) return id;
|
|
1739
|
+
}
|
|
1740
|
+
const auth = r["auth"];
|
|
1741
|
+
if (auth && typeof auth === "object") {
|
|
1742
|
+
const id = pickId(auth);
|
|
1743
|
+
if (id) return id;
|
|
1744
|
+
}
|
|
1745
|
+
const direct = r["userId"] ?? r["user_id"] ?? r["accountId"] ?? r["account_id"] ?? r["customerId"];
|
|
1746
|
+
if (direct && typeof direct === "string") return direct;
|
|
1747
|
+
const session = r["session"];
|
|
1748
|
+
if (session && typeof session === "object") {
|
|
1749
|
+
const sessionDirect = session["userId"] ?? session["user_id"];
|
|
1750
|
+
if (sessionDirect) return sessionDirect;
|
|
1751
|
+
const sessionUser = session["user"];
|
|
1752
|
+
if (sessionUser && typeof sessionUser === "object") {
|
|
1753
|
+
const id = pickId(sessionUser);
|
|
1754
|
+
if (id) return id;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const headers = r["headers"];
|
|
1758
|
+
const rawAuth = headers?.["authorization"];
|
|
1759
|
+
const authStr = Array.isArray(rawAuth) ? rawAuth[0] : rawAuth;
|
|
1760
|
+
if (authStr?.startsWith("Bearer ")) {
|
|
1761
|
+
const token = authStr.slice(7);
|
|
1762
|
+
try {
|
|
1763
|
+
const parts = token.split(".");
|
|
1764
|
+
if (parts.length === 3) {
|
|
1765
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
1766
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
1767
|
+
const payload = JSON.parse(
|
|
1768
|
+
Buffer.from(padded, "base64").toString("utf8")
|
|
1769
|
+
);
|
|
1770
|
+
const jwtId = pickId(payload);
|
|
1771
|
+
if (jwtId) return jwtId;
|
|
1772
|
+
for (const val of Object.values(payload)) {
|
|
1773
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
1774
|
+
const nested = pickId(val);
|
|
1775
|
+
if (nested) return nested;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
} catch {
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return void 0;
|
|
1783
|
+
}
|
|
1784
|
+
function pickId(obj) {
|
|
1785
|
+
const id = obj["id"] ?? obj["sub"] ?? // JWT standard claim
|
|
1786
|
+
obj["userId"] ?? obj["user_id"] ?? obj["uid"] ?? // Firebase
|
|
1787
|
+
obj["_id"] ?? // MongoDB
|
|
1788
|
+
obj["accountId"] ?? obj["account_id"] ?? obj["customerId"] ?? obj["customer_id"];
|
|
1789
|
+
return typeof id === "string" && id.length > 0 ? id : void 0;
|
|
861
1790
|
}
|
|
862
1791
|
function normalizeIp(raw) {
|
|
863
1792
|
if (raw === "::1") return "127.0.0.1";
|
|
@@ -868,25 +1797,84 @@ function normalizeIp(raw) {
|
|
|
868
1797
|
function defaultGetIp(req) {
|
|
869
1798
|
const r = req;
|
|
870
1799
|
const fwd = r["headers"];
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1800
|
+
function firstHdr(name) {
|
|
1801
|
+
const v = fwd?.[name];
|
|
1802
|
+
if (!v) return void 0;
|
|
1803
|
+
const s = (Array.isArray(v) ? v[0] : v.split(",")[0])?.trim();
|
|
1804
|
+
return s || void 0;
|
|
1805
|
+
}
|
|
1806
|
+
const ip = firstHdr("cf-connecting-ip") ?? // Cloudflare
|
|
1807
|
+
firstHdr("true-client-ip") ?? // Cloudflare Enterprise / Akamai
|
|
1808
|
+
firstHdr("x-forwarded-for") ?? // Nginx, AWS ALB, GCP LB, most proxies
|
|
1809
|
+
firstHdr("x-real-ip") ?? // Nginx (single-IP alternative to XFF)
|
|
1810
|
+
firstHdr("fastly-client-ip") ?? // Fastly CDN
|
|
1811
|
+
firstHdr("x-client-ip") ?? // Generic reverse proxies
|
|
1812
|
+
firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
|
|
1813
|
+
r["socket"]?.remoteAddress ?? "0.0.0.0";
|
|
1814
|
+
return normalizeIp(ip);
|
|
1815
|
+
}
|
|
1816
|
+
function sendFakeResponse(res, fakeResponse) {
|
|
1817
|
+
const allHeaders = {
|
|
1818
|
+
"Content-Type": fakeResponse.contentType,
|
|
1819
|
+
...fakeResponse.headers
|
|
1820
|
+
};
|
|
1821
|
+
if (typeof res["set"] === "function") {
|
|
1822
|
+
res["set"](allHeaders);
|
|
1823
|
+
} else if (typeof res["setHeader"] === "function") {
|
|
1824
|
+
for (const [k, v] of Object.entries(allHeaders)) {
|
|
1825
|
+
res["setHeader"](k, v);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (typeof res["status"] === "function") {
|
|
1829
|
+
res["status"](fakeResponse.statusCode);
|
|
1830
|
+
} else {
|
|
1831
|
+
res["statusCode"] = fakeResponse.statusCode;
|
|
1832
|
+
}
|
|
1833
|
+
if (typeof res["end"] === "function") {
|
|
1834
|
+
res["end"](fakeResponse.body);
|
|
875
1835
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
|
|
1836
|
+
}
|
|
1837
|
+
function isLoopbackIp(ip) {
|
|
1838
|
+
return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "::1";
|
|
880
1839
|
}
|
|
881
1840
|
function createExpressMiddleware(client) {
|
|
1841
|
+
let ipCheckCount = 0;
|
|
1842
|
+
let ipLoopCount = 0;
|
|
1843
|
+
let ipWarnFired = false;
|
|
1844
|
+
let userIdCheckCount = 0;
|
|
1845
|
+
let userIdMissCount = 0;
|
|
1846
|
+
let userIdWarnFired = false;
|
|
882
1847
|
return async function sentinelMiddleware(req, res, next) {
|
|
883
1848
|
const startMs = Date.now();
|
|
884
1849
|
const ip = client.config.getIp(req);
|
|
1850
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
1851
|
+
ipCheckCount++;
|
|
1852
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
1853
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
1854
|
+
ipWarnFired = true;
|
|
1855
|
+
console.warn(
|
|
1856
|
+
"[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Express fix: app.set('trust proxy', 1);\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
885
1860
|
if (client.isBlocked(ip)) {
|
|
886
1861
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
887
1862
|
const url_ = req["originalUrl"] ?? req["url"] ?? "/";
|
|
888
1863
|
const ua_ = req["headers"]?.["user-agent"] ?? "";
|
|
1864
|
+
const reason_ = client.blockReason(ip);
|
|
889
1865
|
client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: ua_ });
|
|
1866
|
+
if (reason_?.source === "community") {
|
|
1867
|
+
client.track("http.community_threat_blocked", {
|
|
1868
|
+
ip,
|
|
1869
|
+
meta: {
|
|
1870
|
+
endpoint: url_,
|
|
1871
|
+
method: method_,
|
|
1872
|
+
score: reason_.score,
|
|
1873
|
+
topAttack: reason_.topAttack,
|
|
1874
|
+
source: "anomira_network"
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
890
1878
|
const res_ = res;
|
|
891
1879
|
if (typeof res_["status"] === "function") {
|
|
892
1880
|
res_["status"](403);
|
|
@@ -900,8 +1888,100 @@ function createExpressMiddleware(client) {
|
|
|
900
1888
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
901
1889
|
const url = req["originalUrl"] ?? req["url"] ?? "/";
|
|
902
1890
|
const headers = req["headers"];
|
|
903
|
-
const
|
|
904
|
-
const
|
|
1891
|
+
const headersStr = headers;
|
|
1892
|
+
const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
|
|
1893
|
+
const agentInfo = detectAgent(headers ?? {});
|
|
1894
|
+
const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
|
|
1895
|
+
const h2settings = extractHttp2Settings(req);
|
|
1896
|
+
const upstreamTls = extractUpstreamTls(headers ?? {});
|
|
1897
|
+
if (client["canaryTokenCache"] && client["canaryTokenCache"].size > 0) {
|
|
1898
|
+
const authHeader = (typeof headers?.["authorization"] === "string" ? headers["authorization"] : "") ?? "";
|
|
1899
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
1900
|
+
const bearerToken = authHeader.slice(7);
|
|
1901
|
+
if (client["canaryTokenCache"].has(bearerToken)) {
|
|
1902
|
+
client.track("http.canary.triggered", {
|
|
1903
|
+
ip,
|
|
1904
|
+
userId,
|
|
1905
|
+
meta: { endpoint: url, method, token: bearerToken.slice(0, 16), source: "authorization_header" }
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
try {
|
|
1909
|
+
const parts = bearerToken.split(".");
|
|
1910
|
+
if (parts.length === 3) {
|
|
1911
|
+
const b64h = (parts[0] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
1912
|
+
const hdr = JSON.parse(Buffer.from(b64h + "==", "base64").toString());
|
|
1913
|
+
const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
1914
|
+
const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
|
|
1915
|
+
const jti = pay["jti"] ?? "";
|
|
1916
|
+
const cache = client["canaryTokenCache"];
|
|
1917
|
+
if (jti && cache.has(jti)) {
|
|
1918
|
+
client.track("http.canary.triggered", {
|
|
1919
|
+
ip,
|
|
1920
|
+
userId,
|
|
1921
|
+
meta: { endpoint: url, method, token: jti.slice(0, 16), source: "canary_jwt" }
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
} catch {
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const urlPath = url.split("?")[0]?.toLowerCase() ?? url.toLowerCase();
|
|
1930
|
+
const isHoneypot = client["honeypotPaths"].size > 0 && [...client["honeypotPaths"]].some(
|
|
1931
|
+
(hp) => urlPath === hp || urlPath.startsWith(hp + "/") || urlPath.startsWith(hp + ".")
|
|
1932
|
+
);
|
|
1933
|
+
if (isHoneypot) {
|
|
1934
|
+
const honeypotType = detectHoneypotType(url);
|
|
1935
|
+
const callbackBase = client.config.ingestUrl.replace(/\/v1\/events$/, "");
|
|
1936
|
+
const orgId = "";
|
|
1937
|
+
if (method === "POST" && honeypotType === "admin_portal") {
|
|
1938
|
+
const body_ = req["body"];
|
|
1939
|
+
const username = String(
|
|
1940
|
+
body_?.["username"] ?? body_?.["email"] ?? body_?.["user"] ?? body_?.["login"] ?? ""
|
|
1941
|
+
);
|
|
1942
|
+
const password = String(
|
|
1943
|
+
body_?.["password"] ?? body_?.["pass"] ?? body_?.["pwd"] ?? body_?.["passwd"] ?? ""
|
|
1944
|
+
);
|
|
1945
|
+
if (username || password) {
|
|
1946
|
+
const passwordHint = password.length > 0 ? `${password.slice(0, 2)}***[${password.length}]` : "(empty)";
|
|
1947
|
+
client.track("http.honeypot.credential_attempt", {
|
|
1948
|
+
ip,
|
|
1949
|
+
userId,
|
|
1950
|
+
meta: {
|
|
1951
|
+
endpoint: url,
|
|
1952
|
+
method,
|
|
1953
|
+
userAgent: ua,
|
|
1954
|
+
honeypotType,
|
|
1955
|
+
username,
|
|
1956
|
+
passwordHint,
|
|
1957
|
+
// Hidden _token field — if this is the canary token we issued
|
|
1958
|
+
// in the GET response, we know this is the same attacker session
|
|
1959
|
+
formToken: String(body_?.["_token"] ?? "")
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
const failed = makeAdminLoginFailed();
|
|
1964
|
+
sendFakeResponse(res, failed);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
const canaryToken = randomBytes(16).toString("hex");
|
|
1968
|
+
const fakeResponse = generateHoneypotResponse(honeypotType, canaryToken, callbackBase, orgId);
|
|
1969
|
+
client.track("http.honeypot.hit", {
|
|
1970
|
+
ip,
|
|
1971
|
+
userId,
|
|
1972
|
+
meta: {
|
|
1973
|
+
endpoint: url,
|
|
1974
|
+
method,
|
|
1975
|
+
userAgent: ua,
|
|
1976
|
+
honeypotType,
|
|
1977
|
+
canaryToken,
|
|
1978
|
+
responseType: "enhanced"
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
sendFakeResponse(res, fakeResponse);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headersStr ?? {}, ip });
|
|
905
1985
|
if (fwMatch) {
|
|
906
1986
|
client.track("http.firewall." + fwMatch.rule.action, {
|
|
907
1987
|
ip,
|
|
@@ -926,27 +2006,83 @@ function createExpressMiddleware(client) {
|
|
|
926
2006
|
}
|
|
927
2007
|
}
|
|
928
2008
|
const onFinish = () => {
|
|
2009
|
+
const lateUserId = client.config.getUserId(req);
|
|
2010
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2011
|
+
userIdCheckCount++;
|
|
2012
|
+
if (!lateUserId) userIdMissCount++;
|
|
2013
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2014
|
+
userIdWarnFired = true;
|
|
2015
|
+
console.warn(
|
|
2016
|
+
"[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / express-jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => req.user?.id })"
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
929
2020
|
const status = res["statusCode"] ?? 0;
|
|
930
2021
|
const latencyMs = Date.now() - startMs;
|
|
931
2022
|
const getHeader = res["getHeader"];
|
|
932
2023
|
const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
|
|
933
2024
|
client.track(EventName.REQUEST, {
|
|
934
2025
|
ip,
|
|
935
|
-
userId,
|
|
936
|
-
meta: {
|
|
2026
|
+
userId: lateUserId,
|
|
2027
|
+
meta: {
|
|
2028
|
+
method,
|
|
2029
|
+
endpoint: url,
|
|
2030
|
+
status,
|
|
2031
|
+
latencyMs,
|
|
2032
|
+
userAgent: ua,
|
|
2033
|
+
bytes,
|
|
2034
|
+
_fp: fingerprint.score,
|
|
2035
|
+
_fpSig: fingerprint.signals.join(","),
|
|
2036
|
+
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
2037
|
+
...h2settings ? {
|
|
2038
|
+
_h2Window: h2settings.initialWindowSize,
|
|
2039
|
+
_h2HdrTable: h2settings.headerTableSize,
|
|
2040
|
+
_h2Score: h2settings.score,
|
|
2041
|
+
_h2Signals: h2settings.signals.join(",")
|
|
2042
|
+
} : {},
|
|
2043
|
+
...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
|
|
2044
|
+
...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
|
|
2045
|
+
...agentInfo.isAgent ? {
|
|
2046
|
+
agentDetected: true,
|
|
2047
|
+
agentType: agentInfo.agentType,
|
|
2048
|
+
agentName: agentInfo.agentName,
|
|
2049
|
+
agentConfidence: agentInfo.confidence,
|
|
2050
|
+
mcpSessionId: agentInfo.sessionId,
|
|
2051
|
+
isMcp: agentInfo.isMcp,
|
|
2052
|
+
agentSignals: agentInfo.signals.join(",")
|
|
2053
|
+
} : {}
|
|
2054
|
+
}
|
|
937
2055
|
});
|
|
2056
|
+
if (agentInfo.isAgent) {
|
|
2057
|
+
client.track("http.agent_detected", {
|
|
2058
|
+
ip,
|
|
2059
|
+
userId: lateUserId,
|
|
2060
|
+
meta: {
|
|
2061
|
+
endpoint: url,
|
|
2062
|
+
method,
|
|
2063
|
+
agentType: agentInfo.agentType,
|
|
2064
|
+
agentName: agentInfo.agentName,
|
|
2065
|
+
agentConfidence: agentInfo.confidence,
|
|
2066
|
+
mcpSessionId: agentInfo.sessionId,
|
|
2067
|
+
isMcp: agentInfo.isMcp,
|
|
2068
|
+
agentSignals: agentInfo.signals.join(","),
|
|
2069
|
+
status,
|
|
2070
|
+
userAgent: ua
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
938
2074
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
939
|
-
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2075
|
+
client.track(EventName.RATE_LIMIT, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
940
2076
|
}
|
|
941
2077
|
if (client.config.detect.bruteForce && status === 401) {
|
|
942
2078
|
if (/\/(login|signin|auth|token|session)/i.test(url)) {
|
|
943
|
-
client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2079
|
+
client.track(EventName.LOGIN_FAILED, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
944
2080
|
}
|
|
945
2081
|
}
|
|
946
2082
|
if (client.config.detect.scanDetection && status === 404) {
|
|
947
2083
|
const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
|
|
948
2084
|
if (looksLikeScanner) {
|
|
949
|
-
client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
|
|
2085
|
+
client.track(EventName.SCAN_DETECTED, { ip, userId: lateUserId, meta: { url, method, userAgent: ua } });
|
|
950
2086
|
}
|
|
951
2087
|
}
|
|
952
2088
|
cleanup();
|
|
@@ -959,14 +2095,43 @@ function createExpressMiddleware(client) {
|
|
|
959
2095
|
};
|
|
960
2096
|
}
|
|
961
2097
|
function createFastifyPlugin(client) {
|
|
2098
|
+
let ipCheckCount = 0;
|
|
2099
|
+
let ipLoopCount = 0;
|
|
2100
|
+
let ipWarnFired = false;
|
|
2101
|
+
let userIdCheckCount = 0;
|
|
2102
|
+
let userIdMissCount = 0;
|
|
2103
|
+
let userIdWarnFired = false;
|
|
962
2104
|
return async function sentinelFastifyPlugin(fastify) {
|
|
963
2105
|
fastify.addHook("onRequest", (req, reply) => {
|
|
964
2106
|
const ip = client.config.getIp(req);
|
|
2107
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
2108
|
+
ipCheckCount++;
|
|
2109
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
2110
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
2111
|
+
ipWarnFired = true;
|
|
2112
|
+
console.warn(
|
|
2113
|
+
"[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
965
2117
|
const url_ = req["url"] ?? "/";
|
|
966
2118
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
967
2119
|
const hdrs_ = req["headers"];
|
|
968
2120
|
if (client.isBlocked(ip)) {
|
|
2121
|
+
const reason_ = client.blockReason(ip);
|
|
969
2122
|
client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: hdrs_?.["user-agent"] ?? "" });
|
|
2123
|
+
if (reason_?.source === "community") {
|
|
2124
|
+
client.track("http.community_threat_blocked", {
|
|
2125
|
+
ip,
|
|
2126
|
+
meta: {
|
|
2127
|
+
endpoint: url_,
|
|
2128
|
+
method: method_,
|
|
2129
|
+
score: reason_.score,
|
|
2130
|
+
topAttack: reason_.topAttack,
|
|
2131
|
+
source: "anomira_network"
|
|
2132
|
+
}
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
970
2135
|
const rep = reply;
|
|
971
2136
|
if (typeof rep["code"] === "function") {
|
|
972
2137
|
const chained = rep["code"](403);
|
|
@@ -1020,14 +2185,73 @@ function createFastifyPlugin(client) {
|
|
|
1020
2185
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
1021
2186
|
const userId = client.config.getUserId(req);
|
|
1022
2187
|
const status = reply["statusCode"] ?? 0;
|
|
2188
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2189
|
+
userIdCheckCount++;
|
|
2190
|
+
if (!userId) userIdMissCount++;
|
|
2191
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2192
|
+
userIdWarnFired = true;
|
|
2193
|
+
console.warn(
|
|
2194
|
+
"[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / @fastify/jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => (req as any).user?.id })"
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
1023
2198
|
const headers = req["headers"];
|
|
1024
|
-
const ua = headers?.["user-agent"] ?? "";
|
|
2199
|
+
const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
|
|
1025
2200
|
const latencyMs = reply["elapsedTime"] ?? 0;
|
|
2201
|
+
const agentInfo = detectAgent(headers ?? {});
|
|
2202
|
+
const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
|
|
2203
|
+
const h2settings = extractHttp2Settings(req);
|
|
2204
|
+
const upstreamTls = extractUpstreamTls(headers ?? {});
|
|
1026
2205
|
client.track(EventName.REQUEST, {
|
|
1027
2206
|
ip,
|
|
1028
2207
|
userId,
|
|
1029
|
-
meta: {
|
|
2208
|
+
meta: {
|
|
2209
|
+
method,
|
|
2210
|
+
endpoint: url,
|
|
2211
|
+
status,
|
|
2212
|
+
latencyMs: Math.round(latencyMs),
|
|
2213
|
+
userAgent: ua,
|
|
2214
|
+
bytes: 0,
|
|
2215
|
+
_fp: fingerprint.score,
|
|
2216
|
+
_fpSig: fingerprint.signals.join(","),
|
|
2217
|
+
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
2218
|
+
...h2settings ? {
|
|
2219
|
+
_h2Window: h2settings.initialWindowSize,
|
|
2220
|
+
_h2HdrTable: h2settings.headerTableSize,
|
|
2221
|
+
_h2Score: h2settings.score,
|
|
2222
|
+
_h2Signals: h2settings.signals.join(",")
|
|
2223
|
+
} : {},
|
|
2224
|
+
...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
|
|
2225
|
+
...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
|
|
2226
|
+
...agentInfo.isAgent ? {
|
|
2227
|
+
agentDetected: true,
|
|
2228
|
+
agentType: agentInfo.agentType,
|
|
2229
|
+
agentName: agentInfo.agentName,
|
|
2230
|
+
agentConfidence: agentInfo.confidence,
|
|
2231
|
+
mcpSessionId: agentInfo.sessionId,
|
|
2232
|
+
isMcp: agentInfo.isMcp,
|
|
2233
|
+
agentSignals: agentInfo.signals.join(",")
|
|
2234
|
+
} : {}
|
|
2235
|
+
}
|
|
1030
2236
|
});
|
|
2237
|
+
if (agentInfo.isAgent) {
|
|
2238
|
+
client.track("http.agent_detected", {
|
|
2239
|
+
ip,
|
|
2240
|
+
userId,
|
|
2241
|
+
meta: {
|
|
2242
|
+
endpoint: url,
|
|
2243
|
+
method,
|
|
2244
|
+
agentType: agentInfo.agentType,
|
|
2245
|
+
agentName: agentInfo.agentName,
|
|
2246
|
+
agentConfidence: agentInfo.confidence,
|
|
2247
|
+
mcpSessionId: agentInfo.sessionId,
|
|
2248
|
+
isMcp: agentInfo.isMcp,
|
|
2249
|
+
agentSignals: agentInfo.signals.join(","),
|
|
2250
|
+
status,
|
|
2251
|
+
userAgent: ua
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
1031
2255
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
1032
2256
|
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
1033
2257
|
}
|
|
@@ -1054,6 +2278,11 @@ function createExpressMiddleware2(client) {
|
|
|
1054
2278
|
const ip = client.config.getIp(req);
|
|
1055
2279
|
const userId = client.config.getUserId(req);
|
|
1056
2280
|
const { method, originalUrl: url } = req;
|
|
2281
|
+
if (client.isBlocked(ip)) {
|
|
2282
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2283
|
+
res.status(403).json({ error: "Forbidden" });
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
1057
2286
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
1058
2287
|
client.track(EventName.PATH_TRAVERSAL, {
|
|
1059
2288
|
ip,
|
|
@@ -1118,11 +2347,15 @@ function createExpressMiddleware2(client) {
|
|
|
1118
2347
|
// src/middleware/fastify.ts
|
|
1119
2348
|
function createFastifyPlugin2(client) {
|
|
1120
2349
|
return async function sentinelPlugin(fastify) {
|
|
1121
|
-
fastify.addHook("onRequest", async (req,
|
|
2350
|
+
fastify.addHook("onRequest", async (req, reply) => {
|
|
1122
2351
|
const ip = client.config.getIp(req);
|
|
1123
2352
|
const userId = client.config.getUserId(req);
|
|
1124
2353
|
const url = req.url;
|
|
1125
2354
|
const method = req.method.toUpperCase();
|
|
2355
|
+
if (client.isBlocked(ip)) {
|
|
2356
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2357
|
+
return reply.code(403).send({ error: "Forbidden" });
|
|
2358
|
+
}
|
|
1126
2359
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
1127
2360
|
client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
|
|
1128
2361
|
}
|