@anomira/node-sdk 0.2.0 → 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/README.md +25 -1
- package/dist/index.cjs +1172 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -6
- package/dist/index.d.ts +92 -6
- package/dist/index.js +1172 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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/
|
|
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
|
|
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.
|
|
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,40 @@ var AnomiraClient = class {
|
|
|
666
1487
|
* });
|
|
667
1488
|
* ```
|
|
668
1489
|
*/
|
|
669
|
-
/**
|
|
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
|
-
|
|
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;
|
|
673
1524
|
}
|
|
674
1525
|
/**
|
|
675
1526
|
* Fire-and-forget: report a blocked-IP attempt to the ingest server so the
|
|
@@ -701,12 +1552,15 @@ var AnomiraClient = class {
|
|
|
701
1552
|
}
|
|
702
1553
|
track(eventName, data) {
|
|
703
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;
|
|
704
1558
|
const event = {
|
|
705
1559
|
name: eventName,
|
|
706
1560
|
ts: Date.now(),
|
|
707
|
-
ip:
|
|
1561
|
+
ip: resolvedIp,
|
|
708
1562
|
userId: data.userId,
|
|
709
|
-
meta:
|
|
1563
|
+
meta: resolvedMeta
|
|
710
1564
|
};
|
|
711
1565
|
this.buffer.push(event);
|
|
712
1566
|
}
|
|
@@ -725,13 +1579,15 @@ var AnomiraClient = class {
|
|
|
725
1579
|
async trackLogin(data) {
|
|
726
1580
|
if (this.disabled) return;
|
|
727
1581
|
const tsMs = Date.now();
|
|
728
|
-
|
|
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 } });
|
|
729
1585
|
if (!this.config.detect.geoVelocity) return;
|
|
730
1586
|
try {
|
|
731
|
-
const result = await checkGeoVelocity(data.userId,
|
|
1587
|
+
const result = await checkGeoVelocity(data.userId, resolvedIp, tsMs, this.config.geoLookupUrl || void 0);
|
|
732
1588
|
if (!result) return;
|
|
733
1589
|
this.track(EventName.GEO_VELOCITY, {
|
|
734
|
-
ip:
|
|
1590
|
+
ip: resolvedIp,
|
|
735
1591
|
userId: data.userId,
|
|
736
1592
|
meta: {
|
|
737
1593
|
distanceKm: result.distanceKm,
|
|
@@ -766,6 +1622,7 @@ var AnomiraClient = class {
|
|
|
766
1622
|
if (this.disabled) return;
|
|
767
1623
|
this.track(EventName.PHONE_AUTH, {
|
|
768
1624
|
ip: data.ip,
|
|
1625
|
+
// track() auto-resolves from context if empty
|
|
769
1626
|
userId: data.userId,
|
|
770
1627
|
meta: { phone: data.phone, ...data.meta }
|
|
771
1628
|
});
|
|
@@ -834,6 +1691,22 @@ var AnomiraClient = class {
|
|
|
834
1691
|
*/
|
|
835
1692
|
async flush() {
|
|
836
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
|
+
}
|
|
837
1710
|
await Promise.all([this.buffer.flush(), this.#flushLogs()]);
|
|
838
1711
|
}
|
|
839
1712
|
/**
|
|
@@ -860,8 +1733,54 @@ var AnomiraClient = class {
|
|
|
860
1733
|
function defaultGetUserId(req) {
|
|
861
1734
|
const r = req;
|
|
862
1735
|
const user = r["user"];
|
|
863
|
-
if (
|
|
864
|
-
|
|
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;
|
|
865
1784
|
}
|
|
866
1785
|
function normalizeIp(raw) {
|
|
867
1786
|
if (raw === "::1") return "127.0.0.1";
|
|
@@ -882,6 +1801,27 @@ function defaultGetIp(req) {
|
|
|
882
1801
|
const socket = r["socket"];
|
|
883
1802
|
return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
|
|
884
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
|
+
}
|
|
885
1825
|
function createExpressMiddleware(client) {
|
|
886
1826
|
return async function sentinelMiddleware(req, res, next) {
|
|
887
1827
|
const startMs = Date.now();
|
|
@@ -890,7 +1830,20 @@ function createExpressMiddleware(client) {
|
|
|
890
1830
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
891
1831
|
const url_ = req["originalUrl"] ?? req["url"] ?? "/";
|
|
892
1832
|
const ua_ = req["headers"]?.["user-agent"] ?? "";
|
|
1833
|
+
const reason_ = client.blockReason(ip);
|
|
893
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
|
+
}
|
|
894
1847
|
const res_ = res;
|
|
895
1848
|
if (typeof res_["status"] === "function") {
|
|
896
1849
|
res_["status"](403);
|
|
@@ -904,8 +1857,100 @@ function createExpressMiddleware(client) {
|
|
|
904
1857
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
905
1858
|
const url = req["originalUrl"] ?? req["url"] ?? "/";
|
|
906
1859
|
const headers = req["headers"];
|
|
907
|
-
const
|
|
908
|
-
const
|
|
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 });
|
|
909
1954
|
if (fwMatch) {
|
|
910
1955
|
client.track("http.firewall." + fwMatch.rule.action, {
|
|
911
1956
|
ip,
|
|
@@ -937,8 +1982,55 @@ function createExpressMiddleware(client) {
|
|
|
937
1982
|
client.track(EventName.REQUEST, {
|
|
938
1983
|
ip,
|
|
939
1984
|
userId,
|
|
940
|
-
meta: {
|
|
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
|
+
}
|
|
941
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
|
+
}
|
|
942
2034
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
943
2035
|
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
944
2036
|
}
|
|
@@ -970,7 +2062,20 @@ function createFastifyPlugin(client) {
|
|
|
970
2062
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
971
2063
|
const hdrs_ = req["headers"];
|
|
972
2064
|
if (client.isBlocked(ip)) {
|
|
2065
|
+
const reason_ = client.blockReason(ip);
|
|
973
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
|
+
}
|
|
974
2079
|
const rep = reply;
|
|
975
2080
|
if (typeof rep["code"] === "function") {
|
|
976
2081
|
const chained = rep["code"](403);
|
|
@@ -1025,13 +2130,62 @@ function createFastifyPlugin(client) {
|
|
|
1025
2130
|
const userId = client.config.getUserId(req);
|
|
1026
2131
|
const status = reply["statusCode"] ?? 0;
|
|
1027
2132
|
const headers = req["headers"];
|
|
1028
|
-
const ua = headers?.["user-agent"] ?? "";
|
|
2133
|
+
const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
|
|
1029
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 ?? {});
|
|
1030
2139
|
client.track(EventName.REQUEST, {
|
|
1031
2140
|
ip,
|
|
1032
2141
|
userId,
|
|
1033
|
-
meta: {
|
|
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
|
+
}
|
|
1034
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
|
+
}
|
|
1035
2189
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
1036
2190
|
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
1037
2191
|
}
|