@anomira/node-sdk 0.1.9 → 0.2.2

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,737 @@ 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, callbackBase, orgId);
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 webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
654
+ const dnsDb = `db.${canaryToken}.srv.anomira.io`;
655
+ const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
656
+ const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
657
+ const body = `# Production Environment Configuration
658
+ # Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
659
+
660
+ NODE_ENV=production
661
+ PORT=3000
662
+
663
+ # \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
664
+ DATABASE_URL=postgresql://db_prod:${dbPassword}@${dnsDb}:5432/app_production
665
+ DATABASE_REPLICA_URL=postgresql://db_prod:${dbPassword}@${dnsDb}:5433/app_production
666
+ DATABASE_POOL_MIN=2
667
+ DATABASE_POOL_MAX=10
668
+
669
+ # \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
670
+ REDIS_URL=redis://:${redisPassword}@${dnsCache}:6379/0
671
+
672
+ # \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
673
+ JWT_SECRET=${jwtSecret}
674
+ JWT_EXPIRES_IN=15m
675
+ REFRESH_TOKEN_SECRET=${genHex(32)}
676
+ SESSION_SECRET=${genHex(32)}
677
+
678
+ # \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
679
+ SERVICE_ACCOUNT_TOKEN=${fakeJwt}
680
+
681
+ # \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
682
+ AWS_REGION=eu-west-1
683
+ AWS_S3_BUCKET=app-production-${genHex(4)}
684
+
685
+ # \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
686
+ MONITORING_WEBHOOK=${webhookUrl}
687
+ ALERT_WEBHOOK=${webhookUrl}
688
+ DEPLOY_HOOK=${callbackBase}/v1/canary/${orgId}/${canaryToken}
689
+
690
+ # \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
691
+ INTERNAL_API_KEY=${apiKey}
692
+ `;
693
+ return {
694
+ statusCode: 200,
695
+ contentType: "text/plain; charset=utf-8",
696
+ headers: {
697
+ "Cache-Control": "no-store",
698
+ "Last-Modified": new Date(Date.now() - 7 * 864e5).toUTCString(),
699
+ "ETag": `"${genHex(8)}-${genHex(4)}"`,
700
+ // Apache-style headers — most exposed .env files are on PHP/Apache stacks
701
+ "Server": "Apache/2.4.41 (Ubuntu)",
702
+ "X-Content-Type-Options": "nosniff"
703
+ },
704
+ body,
705
+ canaryToken
706
+ };
707
+ }
708
+ function makeAwsCredentials(canaryToken) {
709
+ const fakeKeyId = makeAwsKeyId();
710
+ const fakeSecret = makeAwsSecret();
711
+ const stsPrefixKey = "ASIA" + makeAwsKeyId().slice(4);
712
+ const stsSecret = makeAwsSecret();
713
+ const stsToken = genBase64(300);
714
+ const body = `[default]
715
+ aws_access_key_id=${fakeKeyId}
716
+ aws_secret_access_key=${fakeSecret}
717
+ region=eu-west-1
718
+
719
+ [production]
720
+ aws_access_key_id=${fakeKeyId}
721
+ aws_secret_access_key=${fakeSecret}
722
+ region=eu-west-1
723
+
724
+ [staging]
725
+ aws_access_key_id=${stsPrefixKey}
726
+ aws_secret_access_key=${stsSecret}
727
+ aws_session_token=${stsToken}
728
+ region=eu-west-1
729
+ `;
730
+ return {
731
+ statusCode: 200,
732
+ contentType: "text/plain; charset=utf-8",
733
+ headers: {
734
+ "Cache-Control": "no-store",
735
+ "Last-Modified": new Date(Date.now() - 14 * 864e5).toUTCString(),
736
+ // Match the Server header an AWS EC2 instance metadata endpoint might have
737
+ "Server": "EC2ws",
738
+ "X-Content-Type-Options": "nosniff"
739
+ },
740
+ body,
741
+ canaryToken
742
+ };
743
+ }
744
+ function makeGitConfig(canaryToken, callbackBase, orgId) {
745
+ const ghpToken = `ghp_${genAlphanumeric(36)}`;
746
+ const glpatToken = `glpat-${genAlphanumeric(20)}`;
747
+ const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
748
+ const dnsGit = `git.${canaryToken}.srv.anomira.io`;
749
+ const body = `[core]
750
+ repositoryformatversion = 0
751
+ filemode = true
752
+ bare = false
753
+ logallrefupdates = true
754
+
755
+ [remote "origin"]
756
+ url = https://oauth2:${ghpToken}@github.com/company/app.git
757
+ fetch = +refs/heads/*:refs/remotes/origin/*
758
+
759
+ [remote "deploy"]
760
+ url = https://deploy-token:${glpatToken}@${dnsGit}/backend/app.git
761
+ fetch = +refs/heads/*:refs/remotes/deploy/*
762
+
763
+ [remote "notify"]
764
+ url = ${webhookUrl}
765
+
766
+ [branch "main"]
767
+ remote = origin
768
+ merge = refs/heads/main
769
+
770
+ [branch "production"]
771
+ remote = deploy
772
+ merge = refs/heads/production
773
+
774
+ [credential "https://github.com"]
775
+ username = deploy-bot
776
+ `;
777
+ return {
778
+ statusCode: 200,
779
+ contentType: "text/plain; charset=utf-8",
780
+ headers: {
781
+ "Cache-Control": "no-store",
782
+ "Last-Modified": new Date(Date.now() - 30 * 864e5).toUTCString(),
783
+ "Server": "Apache/2.4.41 (Ubuntu)"
784
+ },
785
+ body,
786
+ canaryToken
787
+ };
788
+ }
789
+ function makeGraphQL() {
790
+ const body = JSON.stringify({
791
+ data: {
792
+ __schema: {
793
+ queryType: { name: "Query" },
794
+ mutationType: { name: "Mutation" },
795
+ types: [
796
+ { kind: "OBJECT", name: "Query", fields: [{ name: "user" }, { name: "users" }, { name: "posts" }] },
797
+ { kind: "OBJECT", name: "Mutation", fields: [{ name: "login" }, { name: "createUser" }, { name: "adminReset" }, { name: "exportData" }] },
798
+ { kind: "OBJECT", name: "User", fields: [{ name: "id" }, { name: "email" }, { name: "role" }, { name: "createdAt" }] },
799
+ { kind: "OBJECT", name: "AuthPayload", fields: [{ name: "token" }, { name: "user" }] },
800
+ { kind: "SCALAR", name: "String", fields: null },
801
+ { kind: "SCALAR", name: "Boolean", fields: null },
802
+ { kind: "SCALAR", name: "Int", fields: null },
803
+ { kind: "SCALAR", name: "ID", fields: null }
804
+ ]
805
+ }
806
+ }
807
+ });
808
+ return {
809
+ statusCode: 200,
810
+ contentType: "application/json; charset=utf-8",
811
+ headers: {
812
+ "X-Powered-By": "Express",
813
+ "X-Request-Id": `req_${genHex(16)}`
814
+ },
815
+ body,
816
+ canaryToken: ""
817
+ };
818
+ }
819
+ function makeSpringActuator(canaryToken) {
820
+ const dbPassword = genAlphanumeric(20);
821
+ const secretKey = genHex(32);
822
+ const body = JSON.stringify({
823
+ activeProfiles: ["production"],
824
+ defaultProfiles: ["default"],
825
+ propertySources: [
826
+ {
827
+ name: "systemProperties",
828
+ properties: {
829
+ "java.runtime.version": { value: "17.0.9+9" },
830
+ "server.port": { value: "8080" },
831
+ "user.home": { value: "/home/appuser" }
832
+ }
833
+ },
834
+ {
835
+ name: "applicationConfig: [classpath:/application-production.properties]",
836
+ properties: {
837
+ "spring.datasource.url": { origin: "class path resource [application-production.properties] - 3:1", value: "jdbc:postgresql://db.internal:5432/myapp" },
838
+ "spring.datasource.username": { origin: "class path resource [application-production.properties] - 4:1", value: "dbadmin" },
839
+ "spring.datasource.password": { origin: "class path resource [application-production.properties] - 5:1", value: dbPassword },
840
+ "app.jwt.secret": { origin: "class path resource [application-production.properties] - 8:1", value: secretKey },
841
+ "app.canary.token": { value: canaryToken },
842
+ "management.endpoints.web.exposure.include": { value: "health,info,env,metrics,loggers" }
843
+ }
844
+ }
845
+ ]
846
+ }, null, 2);
847
+ return {
848
+ statusCode: 200,
849
+ contentType: "application/vnd.spring-boot.actuator.v3+json",
850
+ headers: {
851
+ "X-Application-Context": "myapp:production:8080",
852
+ "X-Content-Type-Options": "nosniff",
853
+ "X-XSS-Protection": "1; mode=block"
854
+ },
855
+ body,
856
+ canaryToken
857
+ };
858
+ }
859
+ function makeJsonConfig(canaryToken, callbackBase, orgId) {
860
+ const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
861
+ const body = JSON.stringify({
862
+ environment: "production",
863
+ version: "2.4.1",
864
+ database: {
865
+ host: "db.internal.company.com",
866
+ port: 5432,
867
+ name: "app_production",
868
+ user: "db_prod",
869
+ password: genAlphanumeric(20)
870
+ },
871
+ jwt: {
872
+ secret: genHex(32),
873
+ expiresIn: "15m"
874
+ },
875
+ webhooks: {
876
+ events: webhookUrl,
877
+ alerts: `${callbackBase}/v1/canary/${orgId}/${canaryToken}`
878
+ },
879
+ internalApiKey: genHex(32)
880
+ }, null, 2);
881
+ return {
882
+ statusCode: 200,
883
+ contentType: "application/json; charset=utf-8",
884
+ headers: {
885
+ "Cache-Control": "no-store",
886
+ "ETag": `"${genHex(8)}"`,
887
+ "X-Powered-By": "Express"
888
+ },
889
+ body,
890
+ canaryToken
891
+ };
892
+ }
893
+ function makeHtpasswd(canaryToken) {
894
+ const apr1Salt = genAlphanumeric(8).toLowerCase();
895
+ const apr1Hash = genBase64(22).replace(
896
+ /[+=/]/g,
897
+ (c) => ({ "+": ".", "=": "/", "/": "X" })[c] ?? c
898
+ );
899
+ const bcryptHash = genBase64(53).replace(
900
+ /[+=]/g,
901
+ (c) => ({ "+": ".", "=": "/" })[c] ?? c
902
+ );
903
+ const canaryApr1 = `$apr1$${canaryToken.slice(0, 8)}$${genBase64(22).slice(0, 22)}`;
904
+ const body = `# Apache HTTP Server password file
905
+ admin:$apr1$${apr1Salt}$${apr1Hash}
906
+ deploy:$2y$10$${bcryptHash}
907
+ root:${canaryApr1}
908
+ backup-user:$apr1$${genAlphanumeric(8).toLowerCase()}$${genBase64(22).slice(0, 22)}
909
+ `;
910
+ return {
911
+ statusCode: 200,
912
+ contentType: "text/plain; charset=utf-8",
913
+ headers: {
914
+ "Cache-Control": "no-store",
915
+ "Last-Modified": new Date(Date.now() - 45 * 864e5).toUTCString(),
916
+ "Server": "Apache/2.4.41 (Ubuntu)",
917
+ "Content-Disposition": "inline"
918
+ },
919
+ body,
920
+ canaryToken
921
+ };
922
+ }
923
+ function makeS3Bucket(canaryToken) {
924
+ const ownerId = genHex(32) + genHex(32);
925
+ const now = /* @__PURE__ */ new Date();
926
+ const dateStr = (offset) => new Date(now.getTime() - offset).toISOString().replace(/\.\d{3}Z/, ".000Z");
927
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
928
+ <ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
929
+ <Owner>
930
+ <ID>${ownerId}</ID>
931
+ <DisplayName>company-admin</DisplayName>
932
+ </Owner>
933
+ <Buckets>
934
+ <Bucket>
935
+ <Name>company-production-data</Name>
936
+ <CreationDate>${dateStr(120 * 864e5)}</CreationDate>
937
+ </Bucket>
938
+ <Bucket>
939
+ <Name>company-assets-${genHex(4)}</Name>
940
+ <CreationDate>${dateStr(90 * 864e5)}</CreationDate>
941
+ </Bucket>
942
+ <Bucket>
943
+ <Name>company-backups-${canaryToken.slice(0, 12)}</Name>
944
+ <CreationDate>${dateStr(30 * 864e5)}</CreationDate>
945
+ </Bucket>
946
+ <Bucket>
947
+ <Name>company-logs-archive</Name>
948
+ <CreationDate>${dateStr(180 * 864e5)}</CreationDate>
949
+ </Bucket>
950
+ </Buckets>
951
+ </ListAllMyBucketsResult>`;
952
+ return {
953
+ statusCode: 200,
954
+ contentType: "application/xml",
955
+ headers: {
956
+ "x-amz-request-id": genHex(8).toUpperCase() + genHex(8).toUpperCase(),
957
+ "x-amz-id-2": genBase64(60),
958
+ "Server": "AmazonS3"
959
+ },
960
+ body,
961
+ canaryToken
962
+ };
963
+ }
964
+ function makeAdminPortal(canaryToken) {
965
+ const body = `<!DOCTYPE html>
966
+ <html lang="en">
967
+ <head>
968
+ <meta charset="UTF-8" />
969
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
970
+ <title>Administration \u2014 Login</title>
971
+ <style>
972
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
973
+ body {
974
+ background: #0f1117;
975
+ color: #c9d1d9;
976
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
977
+ display: flex; align-items: center; justify-content: center;
978
+ min-height: 100vh;
979
+ }
980
+ .card {
981
+ background: #161b22;
982
+ border: 1px solid #30363d;
983
+ border-radius: 8px;
984
+ padding: 32px;
985
+ width: 100%;
986
+ max-width: 360px;
987
+ }
988
+ h1 { font-size: 1.1rem; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
989
+ .subtitle { font-size: 0.8rem; color: #7d8590; margin-bottom: 24px; }
990
+ label { display: block; font-size: 0.8rem; font-weight: 600; color: #c9d1d9; margin-bottom: 6px; }
991
+ input[type=text], input[type=password] {
992
+ width: 100%; padding: 8px 12px; border: 1px solid #30363d;
993
+ border-radius: 6px; background: #0d1117; color: #c9d1d9;
994
+ font-size: 0.875rem; outline: none; margin-bottom: 16px;
995
+ }
996
+ input:focus { border-color: #388bfd; box-shadow: 0 0 0 3px rgba(56,139,253,.1); }
997
+ .row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
998
+ .row label { margin-bottom: 0; font-weight: normal; display: flex; align-items: center; gap: 6px; cursor: pointer; }
999
+ button {
1000
+ width: 100%; padding: 8px 16px; background: #238636; color: #fff;
1001
+ border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600;
1002
+ cursor: pointer; transition: background .15s;
1003
+ }
1004
+ button:hover { background: #2ea043; }
1005
+ .footer { text-align: center; font-size: 0.72rem; color: #7d8590; margin-top: 20px; }
1006
+ </style>
1007
+ </head>
1008
+ <body>
1009
+ <div class="card">
1010
+ <h1>Administration Panel</h1>
1011
+ <p class="subtitle">Sign in to continue</p>
1012
+ <form method="POST" action="./login" autocomplete="off">
1013
+ <input type="hidden" name="_token" value="${canaryToken}" />
1014
+ <div>
1015
+ <label for="username">Username</label>
1016
+ <input id="username" name="username" type="text" placeholder="admin" autocomplete="off" />
1017
+ </div>
1018
+ <div>
1019
+ <label for="password">Password</label>
1020
+ <input id="password" name="password" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" autocomplete="off" />
1021
+ </div>
1022
+ <div class="row">
1023
+ <label><input type="checkbox" name="remember" /> Remember me</label>
1024
+ <a href="#" style="font-size:.8rem;color:#388bfd;text-decoration:none;">Forgot password?</a>
1025
+ </div>
1026
+ <button type="submit">Sign in</button>
1027
+ </form>
1028
+ <p class="footer">v3.2.1 \xB7 Secure Administration Portal</p>
1029
+ </div>
1030
+ </body>
1031
+ </html>`;
1032
+ return {
1033
+ statusCode: 200,
1034
+ contentType: "text/html; charset=utf-8",
1035
+ headers: {
1036
+ "Cache-Control": "no-store, no-cache",
1037
+ "X-Frame-Options": "SAMEORIGIN",
1038
+ "Server": "nginx/1.18.0 (Ubuntu)"
1039
+ },
1040
+ body,
1041
+ canaryToken
1042
+ };
1043
+ }
1044
+ function makeAdminLoginFailed() {
1045
+ const body = `<!DOCTYPE html>
1046
+ <html lang="en">
1047
+ <head>
1048
+ <meta charset="UTF-8" />
1049
+ <title>Administration \u2014 Login</title>
1050
+ <style>
1051
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1052
+ body { background: #0f1117; color: #c9d1d9; font-family: -apple-system, sans-serif;
1053
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; }
1054
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 100%; max-width: 360px; }
1055
+ h1 { font-size: 1.1rem; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
1056
+ .error { background: #3d1010; border: 1px solid #f85149; color: #f85149; border-radius: 6px; padding: 10px 14px; font-size: .8rem; margin-bottom: 16px; }
1057
+ label { display: block; font-size: .8rem; font-weight: 600; color: #c9d1d9; margin-bottom: 6px; }
1058
+ 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; }
1059
+ button { width: 100%; padding: 8px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: .875rem; font-weight: 600; cursor: pointer; }
1060
+ .footer { text-align: center; font-size: .72rem; color: #7d8590; margin-top: 20px; }
1061
+ </style>
1062
+ </head>
1063
+ <body>
1064
+ <div class="card">
1065
+ <h1>Administration Panel</h1>
1066
+ <div class="error">\u26A0 Invalid username or password. Please try again.</div>
1067
+ <form method="POST" action="" autocomplete="off">
1068
+ <div><label for="u">Username</label><input id="u" name="username" type="text" autocomplete="off" /></div>
1069
+ <div><label for="p">Password</label><input id="p" name="password" type="password" autocomplete="off" /></div>
1070
+ <button type="submit">Sign in</button>
1071
+ </form>
1072
+ <p class="footer">v3.2.1 \xB7 Secure Administration Portal</p>
1073
+ </div>
1074
+ </body>
1075
+ </html>`;
1076
+ return {
1077
+ statusCode: 401,
1078
+ contentType: "text/html; charset=utf-8",
1079
+ headers: {
1080
+ "Cache-Control": "no-store",
1081
+ "Server": "nginx/1.18.0 (Ubuntu)",
1082
+ "WWW-Authenticate": 'Form realm="Administration Panel"'
1083
+ },
1084
+ body,
1085
+ canaryToken: ""
1086
+ };
1087
+ }
1088
+ function makeGeneric(canaryToken) {
1089
+ return {
1090
+ statusCode: 200,
1091
+ contentType: "text/plain; charset=utf-8",
1092
+ headers: { "Cache-Control": "no-store" },
1093
+ body: `# ${canaryToken}
1094
+ `,
1095
+ canaryToken
1096
+ };
1097
+ }
1098
+ function generateCanaryJwt(canaryToken, secret) {
1099
+ const now = Math.floor(Date.now() / 1e3);
1100
+ const header = { alg: "HS256", typ: "JWT", kid: `canary-${canaryToken.slice(0, 12)}` };
1101
+ const payload = {
1102
+ sub: "svc_internal_7482",
1103
+ name: "service-account",
1104
+ role: "admin",
1105
+ email: "admin@internal.company.com",
1106
+ iat: now - 172800,
1107
+ // issued 48h ago (realistic stale credential)
1108
+ exp: now - 86400,
1109
+ // expired 24h ago
1110
+ jti: `canary-${canaryToken}`
1111
+ };
1112
+ const h = Buffer.from(JSON.stringify(header)).toString("base64url");
1113
+ const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
1114
+ const sig = crypto.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[crypto.randomBytes(1)[0] % base32Chars.length]
1122
+ ).join("");
1123
+ return `AKIAI${middle}A`;
1124
+ }
1125
+ function makeAwsSecret() {
1126
+ return crypto.randomBytes(30).toString("base64").slice(0, 40);
1127
+ }
1128
+ function genHex(bytes) {
1129
+ return crypto.randomBytes(bytes).toString("hex");
1130
+ }
1131
+ function genAlphanumeric(len) {
1132
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1133
+ return Array.from(
1134
+ { length: len },
1135
+ () => chars[crypto.randomBytes(1)[0] % chars.length]
1136
+ ).join("");
1137
+ }
1138
+ function genBase64(approxLen) {
1139
+ return crypto.randomBytes(Math.ceil(approxLen * 3 / 4)).toString("base64").slice(0, approxLen);
1140
+ }
410
1141
  var requestContext = new async_hooks.AsyncLocalStorage();
411
1142
  var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
412
1143
  var DEFAULT_BATCH_SIZE = 100;
@@ -418,10 +1149,28 @@ var AnomiraClient = class {
418
1149
  this.logFlushTimer = null;
419
1150
  this.blocklistTimer = null;
420
1151
  this.firewallTimer = null;
1152
+ this.communityThreatTimer = null;
421
1153
  /** True when credentials are missing — all operations become no-ops. */
422
1154
  this.disabled = false;
423
- /** 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. */
424
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();
425
1174
  /** In-process cache of firewall rules with pre-compiled regex — refreshed every 60 s. */
426
1175
  this.compiledRules = [];
427
1176
  // Saved originals — used by SDK internals so patched console doesn't recurse
@@ -445,7 +1194,8 @@ var AnomiraClient = class {
445
1194
  service: "app",
446
1195
  getUserId: defaultGetUserId,
447
1196
  getIp: defaultGetIp,
448
- 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 }
449
1199
  };
450
1200
  this.buffer = new EventBuffer({ appId: "", apiKey: "", ingestUrl: DEFAULT_INGEST_URL, maxBatchSize: 0, flushIntervalMs: 999999999, maxRetries: 0, debug: false });
451
1201
  return;
@@ -470,6 +1220,10 @@ var AnomiraClient = class {
470
1220
  xss: config.detect?.xss ?? true,
471
1221
  scanDetection: config.detect?.scanDetection ?? true,
472
1222
  geoVelocity: config.detect?.geoVelocity ?? true
1223
+ },
1224
+ autoBlock: {
1225
+ enabled: config.autoBlock?.enabled ?? true,
1226
+ communityThreshold: config.autoBlock?.communityThreshold ?? 85
473
1227
  }
474
1228
  };
475
1229
  this.buffer = new EventBuffer({
@@ -492,6 +1246,21 @@ var AnomiraClient = class {
492
1246
  void this.#refreshFirewallRules();
493
1247
  }, 6e4);
494
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();
495
1264
  this.logFlushTimer = setInterval(() => {
496
1265
  void this.#flushLogs();
497
1266
  }, 1e4);
@@ -527,7 +1296,7 @@ var AnomiraClient = class {
527
1296
  const res = await fetch(syncUrl, {
528
1297
  headers: {
529
1298
  Authorization: `Bearer ${this.config.apiKey}`,
530
- "User-Agent": `@anomira/node-sdk/0.1.0`
1299
+ "User-Agent": `@anomira/node-sdk/0.2.2`
531
1300
  },
532
1301
  signal: AbortSignal.timeout(5e3)
533
1302
  });
@@ -540,6 +1309,58 @@ var AnomiraClient = class {
540
1309
  } catch {
541
1310
  }
542
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
+ }
543
1364
  async #refreshFirewallRules() {
544
1365
  const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/firewall-rules/sync");
545
1366
  try {
@@ -666,10 +1487,64 @@ var AnomiraClient = class {
666
1487
  * });
667
1488
  * ```
668
1489
  */
669
- /** 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
+ */
670
1501
  isBlocked(ip) {
671
1502
  if (this.disabled) return false;
672
- 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;
1524
+ }
1525
+ /**
1526
+ * Fire-and-forget: report a blocked-IP attempt to the ingest server so the
1527
+ * dashboard can show that the block is actively working.
1528
+ * Called automatically by the Express/Fastify middleware — no manual call needed.
1529
+ */
1530
+ reportBlockedHit(ip, meta) {
1531
+ if (this.disabled) return;
1532
+ const blockedHitUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/blocked-hit");
1533
+ fetch(blockedHitUrl, {
1534
+ method: "POST",
1535
+ headers: {
1536
+ "Content-Type": "application/json",
1537
+ "Authorization": `Bearer ${this.config.apiKey}`
1538
+ },
1539
+ body: JSON.stringify({
1540
+ appId: this.config.appId,
1541
+ ip,
1542
+ method: meta.method,
1543
+ endpoint: meta.url,
1544
+ userAgent: meta.userAgent,
1545
+ ts: Date.now()
1546
+ })
1547
+ }).catch(() => null);
673
1548
  }
674
1549
  /** Evaluate firewall rules against a request. Returns the matched rule or null. Synchronous. */
675
1550
  matchFirewallRule(req) {
@@ -677,12 +1552,15 @@ var AnomiraClient = class {
677
1552
  }
678
1553
  track(eventName, data) {
679
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;
680
1558
  const event = {
681
1559
  name: eventName,
682
1560
  ts: Date.now(),
683
- ip: data.ip,
1561
+ ip: resolvedIp,
684
1562
  userId: data.userId,
685
- meta: data.meta
1563
+ meta: resolvedMeta
686
1564
  };
687
1565
  this.buffer.push(event);
688
1566
  }
@@ -701,13 +1579,15 @@ var AnomiraClient = class {
701
1579
  async trackLogin(data) {
702
1580
  if (this.disabled) return;
703
1581
  const tsMs = Date.now();
704
- 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 } });
705
1585
  if (!this.config.detect.geoVelocity) return;
706
1586
  try {
707
- 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);
708
1588
  if (!result) return;
709
1589
  this.track(EventName.GEO_VELOCITY, {
710
- ip: data.ip,
1590
+ ip: resolvedIp,
711
1591
  userId: data.userId,
712
1592
  meta: {
713
1593
  distanceKm: result.distanceKm,
@@ -742,6 +1622,7 @@ var AnomiraClient = class {
742
1622
  if (this.disabled) return;
743
1623
  this.track(EventName.PHONE_AUTH, {
744
1624
  ip: data.ip,
1625
+ // track() auto-resolves from context if empty
745
1626
  userId: data.userId,
746
1627
  meta: { phone: data.phone, ...data.meta }
747
1628
  });
@@ -810,6 +1691,22 @@ var AnomiraClient = class {
810
1691
  */
811
1692
  async flush() {
812
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
+ }
813
1710
  await Promise.all([this.buffer.flush(), this.#flushLogs()]);
814
1711
  }
815
1712
  /**
@@ -836,8 +1733,54 @@ var AnomiraClient = class {
836
1733
  function defaultGetUserId(req) {
837
1734
  const r = req;
838
1735
  const user = r["user"];
839
- if (!user) return void 0;
840
- 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
+ }
1773
+ } catch {
1774
+ }
1775
+ }
1776
+ return void 0;
1777
+ }
1778
+ function pickId(obj) {
1779
+ const id = obj["id"] ?? obj["sub"] ?? // JWT standard claim
1780
+ obj["userId"] ?? obj["user_id"] ?? obj["uid"] ?? // Firebase
1781
+ obj["_id"] ?? // MongoDB
1782
+ obj["accountId"] ?? obj["account_id"] ?? obj["customerId"] ?? obj["customer_id"];
1783
+ return typeof id === "string" && id.length > 0 ? id : void 0;
841
1784
  }
842
1785
  function normalizeIp(raw) {
843
1786
  if (raw === "::1") return "127.0.0.1";
@@ -858,11 +1801,49 @@ function defaultGetIp(req) {
858
1801
  const socket = r["socket"];
859
1802
  return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
860
1803
  }
1804
+ function sendFakeResponse(res, fakeResponse) {
1805
+ const allHeaders = {
1806
+ "Content-Type": fakeResponse.contentType,
1807
+ ...fakeResponse.headers
1808
+ };
1809
+ if (typeof res["set"] === "function") {
1810
+ res["set"](allHeaders);
1811
+ } else if (typeof res["setHeader"] === "function") {
1812
+ for (const [k, v] of Object.entries(allHeaders)) {
1813
+ res["setHeader"](k, v);
1814
+ }
1815
+ }
1816
+ if (typeof res["status"] === "function") {
1817
+ res["status"](fakeResponse.statusCode);
1818
+ } else {
1819
+ res["statusCode"] = fakeResponse.statusCode;
1820
+ }
1821
+ if (typeof res["end"] === "function") {
1822
+ res["end"](fakeResponse.body);
1823
+ }
1824
+ }
861
1825
  function createExpressMiddleware(client) {
862
1826
  return async function sentinelMiddleware(req, res, next) {
863
1827
  const startMs = Date.now();
864
1828
  const ip = client.config.getIp(req);
865
1829
  if (client.isBlocked(ip)) {
1830
+ const method_ = req["method"]?.toUpperCase() ?? "GET";
1831
+ const url_ = req["originalUrl"] ?? req["url"] ?? "/";
1832
+ const ua_ = req["headers"]?.["user-agent"] ?? "";
1833
+ const reason_ = client.blockReason(ip);
1834
+ client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: ua_ });
1835
+ if (reason_?.source === "community") {
1836
+ client.track("http.community_threat_blocked", {
1837
+ ip,
1838
+ meta: {
1839
+ endpoint: url_,
1840
+ method: method_,
1841
+ score: reason_.score,
1842
+ topAttack: reason_.topAttack,
1843
+ source: "anomira_network"
1844
+ }
1845
+ });
1846
+ }
866
1847
  const res_ = res;
867
1848
  if (typeof res_["status"] === "function") {
868
1849
  res_["status"](403);
@@ -876,8 +1857,100 @@ function createExpressMiddleware(client) {
876
1857
  const method = req["method"]?.toUpperCase() ?? "GET";
877
1858
  const url = req["originalUrl"] ?? req["url"] ?? "/";
878
1859
  const headers = req["headers"];
879
- const ua = headers?.["user-agent"] ?? "";
880
- const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headers ?? {}, ip });
1860
+ const headersStr = headers;
1861
+ const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
1862
+ const agentInfo = detectAgent(headers ?? {});
1863
+ const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
1864
+ const h2settings = extractHttp2Settings(req);
1865
+ const upstreamTls = extractUpstreamTls(headers ?? {});
1866
+ if (client["canaryTokenCache"] && client["canaryTokenCache"].size > 0) {
1867
+ const authHeader = (typeof headers?.["authorization"] === "string" ? headers["authorization"] : "") ?? "";
1868
+ if (authHeader.startsWith("Bearer ")) {
1869
+ const bearerToken = authHeader.slice(7);
1870
+ if (client["canaryTokenCache"].has(bearerToken)) {
1871
+ client.track("http.canary.triggered", {
1872
+ ip,
1873
+ userId,
1874
+ meta: { endpoint: url, method, token: bearerToken.slice(0, 16), source: "authorization_header" }
1875
+ });
1876
+ }
1877
+ try {
1878
+ const parts = bearerToken.split(".");
1879
+ if (parts.length === 3) {
1880
+ const b64h = (parts[0] ?? "").replace(/-/g, "+").replace(/_/g, "/");
1881
+ const hdr = JSON.parse(Buffer.from(b64h + "==", "base64").toString());
1882
+ const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
1883
+ const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
1884
+ const jti = pay["jti"] ?? "";
1885
+ const kid = hdr["kid"] ?? "";
1886
+ if (jti.startsWith("canary-") && client["canaryTokenCache"].has(jti.slice(7)) || kid.startsWith("canary-") && client["canaryTokenCache"].has(kid.slice(7))) {
1887
+ client.track("http.canary.triggered", {
1888
+ ip,
1889
+ userId,
1890
+ meta: { endpoint: url, method, token: jti.slice(7, 23), source: "canary_jwt" }
1891
+ });
1892
+ }
1893
+ }
1894
+ } catch {
1895
+ }
1896
+ }
1897
+ }
1898
+ const urlPath = url.split("?")[0]?.toLowerCase() ?? url.toLowerCase();
1899
+ const isHoneypot = client["honeypotPaths"].size > 0 && [...client["honeypotPaths"]].some(
1900
+ (hp) => urlPath === hp || urlPath.startsWith(hp + "/") || urlPath.startsWith(hp + ".")
1901
+ );
1902
+ if (isHoneypot) {
1903
+ const honeypotType = detectHoneypotType(url);
1904
+ const callbackBase = client.config.ingestUrl.replace(/\/v1\/events$/, "");
1905
+ const orgId = "";
1906
+ if (method === "POST" && honeypotType === "admin_portal") {
1907
+ const body_ = req["body"];
1908
+ const username = String(
1909
+ body_?.["username"] ?? body_?.["email"] ?? body_?.["user"] ?? body_?.["login"] ?? ""
1910
+ );
1911
+ const password = String(
1912
+ body_?.["password"] ?? body_?.["pass"] ?? body_?.["pwd"] ?? body_?.["passwd"] ?? ""
1913
+ );
1914
+ if (username || password) {
1915
+ const passwordHint = password.length > 0 ? `${password.slice(0, 2)}***[${password.length}]` : "(empty)";
1916
+ client.track("http.honeypot.credential_attempt", {
1917
+ ip,
1918
+ userId,
1919
+ meta: {
1920
+ endpoint: url,
1921
+ method,
1922
+ userAgent: ua,
1923
+ honeypotType,
1924
+ username,
1925
+ passwordHint,
1926
+ // Hidden _token field — if this is the canary token we issued
1927
+ // in the GET response, we know this is the same attacker session
1928
+ formToken: String(body_?.["_token"] ?? "")
1929
+ }
1930
+ });
1931
+ }
1932
+ const failed = makeAdminLoginFailed();
1933
+ sendFakeResponse(res, failed);
1934
+ return;
1935
+ }
1936
+ const canaryToken = crypto.randomBytes(16).toString("hex");
1937
+ const fakeResponse = generateHoneypotResponse(honeypotType, canaryToken, callbackBase, orgId);
1938
+ client.track("http.honeypot.hit", {
1939
+ ip,
1940
+ userId,
1941
+ meta: {
1942
+ endpoint: url,
1943
+ method,
1944
+ userAgent: ua,
1945
+ honeypotType,
1946
+ canaryToken,
1947
+ responseType: "enhanced"
1948
+ }
1949
+ });
1950
+ sendFakeResponse(res, fakeResponse);
1951
+ return;
1952
+ }
1953
+ const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headersStr ?? {}, ip });
881
1954
  if (fwMatch) {
882
1955
  client.track("http.firewall." + fwMatch.rule.action, {
883
1956
  ip,
@@ -909,8 +1982,55 @@ function createExpressMiddleware(client) {
909
1982
  client.track(EventName.REQUEST, {
910
1983
  ip,
911
1984
  userId,
912
- meta: { method, endpoint: url, status, latencyMs, userAgent: ua, bytes }
1985
+ meta: {
1986
+ method,
1987
+ endpoint: url,
1988
+ status,
1989
+ latencyMs,
1990
+ userAgent: ua,
1991
+ bytes,
1992
+ _fp: fingerprint.score,
1993
+ _fpSig: fingerprint.signals.join(","),
1994
+ ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
1995
+ // HTTP/2 SETTINGS from client connection preface (when Node.js is TLS endpoint)
1996
+ ...h2settings ? {
1997
+ _h2Window: h2settings.initialWindowSize,
1998
+ _h2HdrTable: h2settings.headerTableSize,
1999
+ _h2Score: h2settings.score,
2000
+ _h2Signals: h2settings.signals.join(",")
2001
+ } : {},
2002
+ // JA3/JA4 from upstream proxy (Nginx with ngx_ssl_fingerprint_module)
2003
+ ...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
2004
+ ...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
2005
+ ...agentInfo.isAgent ? {
2006
+ agentDetected: true,
2007
+ agentType: agentInfo.agentType,
2008
+ agentName: agentInfo.agentName,
2009
+ agentConfidence: agentInfo.confidence,
2010
+ mcpSessionId: agentInfo.sessionId,
2011
+ isMcp: agentInfo.isMcp,
2012
+ agentSignals: agentInfo.signals.join(",")
2013
+ } : {}
2014
+ }
913
2015
  });
2016
+ if (agentInfo.isAgent) {
2017
+ client.track("http.agent_detected", {
2018
+ ip,
2019
+ userId,
2020
+ meta: {
2021
+ endpoint: url,
2022
+ method,
2023
+ agentType: agentInfo.agentType,
2024
+ agentName: agentInfo.agentName,
2025
+ agentConfidence: agentInfo.confidence,
2026
+ mcpSessionId: agentInfo.sessionId,
2027
+ isMcp: agentInfo.isMcp,
2028
+ agentSignals: agentInfo.signals.join(","),
2029
+ status,
2030
+ userAgent: ua
2031
+ }
2032
+ });
2033
+ }
914
2034
  if (client.config.detect.rateAbuse && status === 429) {
915
2035
  client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
916
2036
  }
@@ -938,7 +2058,24 @@ function createFastifyPlugin(client) {
938
2058
  return async function sentinelFastifyPlugin(fastify) {
939
2059
  fastify.addHook("onRequest", (req, reply) => {
940
2060
  const ip = client.config.getIp(req);
2061
+ const url_ = req["url"] ?? "/";
2062
+ const method_ = req["method"]?.toUpperCase() ?? "GET";
2063
+ const hdrs_ = req["headers"];
941
2064
  if (client.isBlocked(ip)) {
2065
+ const reason_ = client.blockReason(ip);
2066
+ client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: hdrs_?.["user-agent"] ?? "" });
2067
+ if (reason_?.source === "community") {
2068
+ client.track("http.community_threat_blocked", {
2069
+ ip,
2070
+ meta: {
2071
+ endpoint: url_,
2072
+ method: method_,
2073
+ score: reason_.score,
2074
+ topAttack: reason_.topAttack,
2075
+ source: "anomira_network"
2076
+ }
2077
+ });
2078
+ }
942
2079
  const rep = reply;
943
2080
  if (typeof rep["code"] === "function") {
944
2081
  const chained = rep["code"](403);
@@ -993,13 +2130,62 @@ function createFastifyPlugin(client) {
993
2130
  const userId = client.config.getUserId(req);
994
2131
  const status = reply["statusCode"] ?? 0;
995
2132
  const headers = req["headers"];
996
- const ua = headers?.["user-agent"] ?? "";
2133
+ const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
997
2134
  const latencyMs = reply["elapsedTime"] ?? 0;
2135
+ const agentInfo = detectAgent(headers ?? {});
2136
+ const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
2137
+ const h2settings = extractHttp2Settings(req);
2138
+ const upstreamTls = extractUpstreamTls(headers ?? {});
998
2139
  client.track(EventName.REQUEST, {
999
2140
  ip,
1000
2141
  userId,
1001
- meta: { method, endpoint: url, status, latencyMs: Math.round(latencyMs), userAgent: ua, bytes: 0 }
2142
+ meta: {
2143
+ method,
2144
+ endpoint: url,
2145
+ status,
2146
+ latencyMs: Math.round(latencyMs),
2147
+ userAgent: ua,
2148
+ bytes: 0,
2149
+ _fp: fingerprint.score,
2150
+ _fpSig: fingerprint.signals.join(","),
2151
+ ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
2152
+ ...h2settings ? {
2153
+ _h2Window: h2settings.initialWindowSize,
2154
+ _h2HdrTable: h2settings.headerTableSize,
2155
+ _h2Score: h2settings.score,
2156
+ _h2Signals: h2settings.signals.join(",")
2157
+ } : {},
2158
+ ...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
2159
+ ...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
2160
+ ...agentInfo.isAgent ? {
2161
+ agentDetected: true,
2162
+ agentType: agentInfo.agentType,
2163
+ agentName: agentInfo.agentName,
2164
+ agentConfidence: agentInfo.confidence,
2165
+ mcpSessionId: agentInfo.sessionId,
2166
+ isMcp: agentInfo.isMcp,
2167
+ agentSignals: agentInfo.signals.join(",")
2168
+ } : {}
2169
+ }
1002
2170
  });
2171
+ if (agentInfo.isAgent) {
2172
+ client.track("http.agent_detected", {
2173
+ ip,
2174
+ userId,
2175
+ meta: {
2176
+ endpoint: url,
2177
+ method,
2178
+ agentType: agentInfo.agentType,
2179
+ agentName: agentInfo.agentName,
2180
+ agentConfidence: agentInfo.confidence,
2181
+ mcpSessionId: agentInfo.sessionId,
2182
+ isMcp: agentInfo.isMcp,
2183
+ agentSignals: agentInfo.signals.join(","),
2184
+ status,
2185
+ userAgent: ua
2186
+ }
2187
+ });
2188
+ }
1003
2189
  if (client.config.detect.rateAbuse && status === 429) {
1004
2190
  client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
1005
2191
  }