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