@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/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/client.ts
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 from the ingest server. */
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.1.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
- /** Returns true if the IP is in the current blocked list. Synchronous — no network call. */
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
- return this.blockedIpCache.has(ip);
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: data.ip,
1561
+ ip: resolvedIp,
704
1562
  userId: data.userId,
705
- meta: data.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
- this.track(EventName.LOGIN_SUCCESS, { ...data, meta: { ...data.meta } });
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, data.ip, tsMs, this.config.geoLookupUrl || void 0);
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: data.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 (!user) return void 0;
860
- return user["id"] ?? user["userId"] ?? user["sub"] ?? user["_id"];
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
- const xff = fwd?.["x-forwarded-for"];
872
- if (xff) {
873
- const first = Array.isArray(xff) ? xff[0] : xff.split(",")[0];
874
- if (first) return normalizeIp(first.trim());
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
- const xri = fwd?.["x-real-ip"];
877
- if (typeof xri === "string") return normalizeIp(xri.trim());
878
- const socket = r["socket"];
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 ua = headers?.["user-agent"] ?? "";
904
- const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headers ?? {}, ip });
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: { method, endpoint: url, status, latencyMs, userAgent: ua, bytes }
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: { method, endpoint: url, status, latencyMs: Math.round(latencyMs), userAgent: ua, bytes: 0 }
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, _reply) => {
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
  }