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