@arcis/node 1.4.2 → 1.4.4

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.
Files changed (64) hide show
  1. package/README.md +11 -3
  2. package/dist/cli/arcis.d.ts +23 -0
  3. package/dist/cli/arcis.d.ts.map +1 -0
  4. package/dist/cli/arcis.js +312 -0
  5. package/dist/cli/arcis.js.map +1 -0
  6. package/dist/cli/arcis.mjs +309 -0
  7. package/dist/cli/arcis.mjs.map +1 -0
  8. package/dist/core/constants.d.ts +2 -2
  9. package/dist/core/constants.d.ts.map +1 -1
  10. package/dist/core/index.js +4 -1
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/index.mjs +4 -1
  13. package/dist/core/index.mjs.map +1 -1
  14. package/dist/core/types.d.ts +17 -0
  15. package/dist/core/types.d.ts.map +1 -1
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +658 -161
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.mjs +655 -162
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/logging/index.js.map +1 -1
  23. package/dist/logging/index.mjs.map +1 -1
  24. package/dist/middleware/bot-detection.d.ts.map +1 -1
  25. package/dist/middleware/cookies.d.ts.map +1 -1
  26. package/dist/middleware/csrf.d.ts +10 -0
  27. package/dist/middleware/csrf.d.ts.map +1 -1
  28. package/dist/middleware/hpp.d.ts.map +1 -1
  29. package/dist/middleware/index.d.ts +2 -0
  30. package/dist/middleware/index.d.ts.map +1 -1
  31. package/dist/middleware/index.js +833 -12
  32. package/dist/middleware/index.js.map +1 -1
  33. package/dist/middleware/index.mjs +832 -13
  34. package/dist/middleware/index.mjs.map +1 -1
  35. package/dist/middleware/main.d.ts.map +1 -1
  36. package/dist/middleware/rate-limit.d.ts.map +1 -1
  37. package/dist/middleware/signup-protection.d.ts +65 -0
  38. package/dist/middleware/signup-protection.d.ts.map +1 -0
  39. package/dist/middleware/telemetry.d.ts +36 -0
  40. package/dist/middleware/telemetry.d.ts.map +1 -0
  41. package/dist/sanitizers/index.d.ts +2 -1
  42. package/dist/sanitizers/index.d.ts.map +1 -1
  43. package/dist/sanitizers/index.js +238 -152
  44. package/dist/sanitizers/index.js.map +1 -1
  45. package/dist/sanitizers/index.mjs +238 -153
  46. package/dist/sanitizers/index.mjs.map +1 -1
  47. package/dist/sanitizers/pii.d.ts.map +1 -1
  48. package/dist/sanitizers/sanitize.d.ts +13 -0
  49. package/dist/sanitizers/sanitize.d.ts.map +1 -1
  50. package/dist/sanitizers/ssti.d.ts.map +1 -1
  51. package/dist/sanitizers/xxe.d.ts.map +1 -1
  52. package/dist/stores/index.js.map +1 -1
  53. package/dist/stores/index.mjs.map +1 -1
  54. package/dist/telemetry/client.d.ts +63 -0
  55. package/dist/telemetry/client.d.ts.map +1 -0
  56. package/dist/telemetry/index.d.ts +3 -0
  57. package/dist/telemetry/index.d.ts.map +1 -0
  58. package/dist/telemetry/types.d.ts +71 -0
  59. package/dist/telemetry/types.d.ts.map +1 -0
  60. package/dist/validation/index.js +3 -0
  61. package/dist/validation/index.js.map +1 -1
  62. package/dist/validation/index.mjs +3 -0
  63. package/dist/validation/index.mjs.map +1 -1
  64. package/package.json +10 -1
@@ -40,11 +40,47 @@ var HEADERS = {
40
40
  /** Default Cache-Control value for security */
41
41
  CACHE_CONTROL: "no-store, no-cache, must-revalidate, proxy-revalidate"
42
42
  };
43
+ var XSS_PATTERNS = [
44
+ /** Script tags (ReDoS-safe version) */
45
+ /<script[^>]*>[\s\S]*?<\/script>/gi,
46
+ /** javascript: protocol (allow optional spaces before colon) */
47
+ /javascript\s*:/gi,
48
+ /** vbscript: protocol */
49
+ /vbscript\s*:/gi,
50
+ /** Event handlers (onclick, onerror, etc.) — any separator before attribute */
51
+ /(?:[\s/])on\w+\s*=/gi,
52
+ /** iframe tags */
53
+ /<iframe/gi,
54
+ /** object tags */
55
+ /<object/gi,
56
+ /** embed tags */
57
+ /<embed/gi,
58
+ /** data: URIs (only dangerous ones, avoid false positives) */
59
+ /(?:^|[\s"'=])data:/gi,
60
+ /** URL-encoded script tags */
61
+ /%3Cscript/gi,
62
+ /** SVG with onload */
63
+ /<svg[^>]*onload/gi,
64
+ /** form tags — phishing/credential harvesting via action= redirection */
65
+ /<form[\s>]/gi,
66
+ /** meta tags — http-equiv refresh redirects or CSP bypass */
67
+ /<meta[\s>]/gi,
68
+ /** base href hijacking — redirects all relative URLs to attacker domain */
69
+ /<base[\s>]/gi,
70
+ /** link tag injection — stylesheet or preload CSRF attacks */
71
+ /<link[\s>]/gi,
72
+ /** style tag — CSS expression() / behavior: / IE-era attacks. Mirrors
73
+ * Python's xss-style-tag from packages/core/patterns.json. */
74
+ /<style[\s>]/gi
75
+ ];
43
76
  var XSS_REMOVE_PATTERNS = [
44
77
  /** Full script blocks (content + tags) */
45
78
  /<script[^>]*>[\s\S]*?<\/script>/gi,
46
79
  /** Standalone/unclosed script tags */
47
80
  /<script[^>]*>/gi,
81
+ /** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
82
+ /<style[^>]*>[\s\S]*?<\/style>/gi,
83
+ /<style[^>]*/gi,
48
84
  /** iframe — full block and partial/unclosed */
49
85
  /<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
50
86
  /<iframe[^>]*/gi,
@@ -357,13 +393,13 @@ function createRateLimiter(options = {}) {
357
393
  statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
358
394
  keyGenerator = (req) => {
359
395
  const ip = req.ip ?? req.socket?.remoteAddress;
360
- if (!ip) {
361
- console.warn(
362
- "[arcis] Rate limiter: cannot resolve client IP. All unresolvable clients share one counter. Set Express trust proxy if behind a reverse proxy."
363
- );
364
- return "unknown";
365
- }
366
- return ip;
396
+ if (ip) return ip;
397
+ const ua = req.headers["user-agent"] ?? "";
398
+ const lang = req.headers["accept-language"] ?? "";
399
+ const fp = `${ua}|${lang}`;
400
+ let hash = 0;
401
+ for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
402
+ return `unknown:${hash.toString(36)}`;
367
403
  },
368
404
  skip,
369
405
  store: externalStore
@@ -555,6 +591,80 @@ var SecurityThreatError = class extends ArcisError {
555
591
  }
556
592
  };
557
593
 
594
+ // src/middleware/telemetry.ts
595
+ var THREAT_TO_VECTOR = {
596
+ xss: "xss",
597
+ sql_injection: "sql",
598
+ nosql_injection: "nosql",
599
+ path_traversal: "path",
600
+ command_injection: "command",
601
+ prototype_pollution: "prototype",
602
+ header_injection: "header",
603
+ ssti: "ssti",
604
+ xxe: "xxe"
605
+ };
606
+ function createTelemetryEmitter(client) {
607
+ return (req, res, next) => {
608
+ const start = performance.now();
609
+ res.on("finish", () => {
610
+ try {
611
+ const event = buildEvent(req, res.statusCode, performance.now() - start);
612
+ client.record(event);
613
+ } catch {
614
+ }
615
+ });
616
+ next();
617
+ };
618
+ }
619
+ function tapSanitizerThreats(handler) {
620
+ return (req, res, next) => {
621
+ handler(req, res, (err) => {
622
+ if (err instanceof SecurityThreatError) {
623
+ const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
624
+ req.__arcis = {
625
+ vector,
626
+ rule: `${vector}/match`,
627
+ severity: "high",
628
+ matchedPattern: err.pattern,
629
+ reason: err.message,
630
+ decision: "deny"
631
+ };
632
+ }
633
+ next(err);
634
+ });
635
+ };
636
+ }
637
+ function buildEvent(req, status, latencyMs) {
638
+ const marker = req.__arcis;
639
+ const decision = marker?.decision ?? inferDecision(status);
640
+ return {
641
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
642
+ ip: extractIp(req),
643
+ method: (req.method ?? "GET").toUpperCase(),
644
+ path: req.path ?? req.url ?? "/",
645
+ decision,
646
+ vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
647
+ rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
648
+ severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
649
+ userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
650
+ reason: marker?.reason,
651
+ status,
652
+ matchedPattern: marker?.matchedPattern,
653
+ latencyMs: Math.max(0, latencyMs)
654
+ };
655
+ }
656
+ function inferDecision(status) {
657
+ if (status === 429) return "deny";
658
+ if (status === 400) return "deny";
659
+ if (status === 403) return "deny";
660
+ return "allow";
661
+ }
662
+ function extractIp(req) {
663
+ if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
664
+ const remote = req.socket?.remoteAddress;
665
+ return typeof remote === "string" ? remote : "0.0.0.0";
666
+ }
667
+
558
668
  // src/sanitizers/utils.ts
559
669
  function encodeHtmlEntities(str) {
560
670
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
@@ -600,6 +710,20 @@ function sanitizeXss(input, collectThreats = false, htmlEncode = false) {
600
710
  }
601
711
  return value;
602
712
  }
713
+ function detectXss(input) {
714
+ if (typeof input !== "string") return false;
715
+ if (/\s+on\w+\s*=/i.test(input)) return true;
716
+ if (/javascript\s*:/i.test(input)) return true;
717
+ if (/vbscript\s*:/i.test(input)) return true;
718
+ if (/data\s*:\s*text\/html/i.test(input)) return true;
719
+ for (const pattern of XSS_PATTERNS) {
720
+ pattern.lastIndex = 0;
721
+ if (pattern.test(input)) {
722
+ return true;
723
+ }
724
+ }
725
+ return false;
726
+ }
603
727
 
604
728
  // src/sanitizers/sql.ts
605
729
  function sanitizeSql(input, collectThreats = false) {
@@ -683,6 +807,17 @@ function sanitizePath(input, collectThreats = false) {
683
807
  }
684
808
  return value;
685
809
  }
810
+ function detectPathTraversal(input) {
811
+ if (typeof input !== "string") return false;
812
+ const normalized = input.normalize("NFKC");
813
+ for (const pattern of PATH_PATTERNS) {
814
+ pattern.lastIndex = 0;
815
+ if (pattern.test(normalized)) {
816
+ return true;
817
+ }
818
+ }
819
+ return false;
820
+ }
686
821
 
687
822
  // src/sanitizers/command.ts
688
823
  function sanitizeCommand(input, collectThreats = false) {
@@ -728,6 +863,60 @@ function detectCommandInjection(input) {
728
863
  return false;
729
864
  }
730
865
 
866
+ // src/sanitizers/ssti.ts
867
+ var SSTI_DETECT_PATTERNS = [
868
+ /** Jinja2 / Twig / Nunjucks: {{ ... }} */
869
+ /\{\{.*?\}\}/g,
870
+ /** Freemarker / Thymeleaf / Spring EL: ${ ... } */
871
+ /\$\{.*?\}/g,
872
+ /** ERB / EJS: <%= ... %> or <% ... %> */
873
+ /<%[=\-]?.*?%>/gs,
874
+ /** Pug / Jade / Slim: #{ ... } */
875
+ /#\{.*?\}/g,
876
+ /** Python dunder sandbox escape */
877
+ /__(?:class|mro|subclasses|globals|builtins|import)__/gi,
878
+ /** Jinja2 config leak: {{config.X}} or {{config['X']}} */
879
+ /\{\{\s*config[.\[]/gi,
880
+ /** Jinja2 built-in objects */
881
+ /\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
882
+ ];
883
+ function detectSsti(input) {
884
+ if (typeof input !== "string") return false;
885
+ for (const pattern of SSTI_DETECT_PATTERNS) {
886
+ pattern.lastIndex = 0;
887
+ if (pattern.test(input)) {
888
+ return true;
889
+ }
890
+ }
891
+ return false;
892
+ }
893
+
894
+ // src/sanitizers/xxe.ts
895
+ var XXE_DETECT_PATTERNS = [
896
+ /** DOCTYPE declaration */
897
+ /<!DOCTYPE\b/gi,
898
+ /** ENTITY declaration */
899
+ /<!ENTITY\b/gi,
900
+ /** SYSTEM keyword with URI */
901
+ /\bSYSTEM\s+["']/gi,
902
+ /** PUBLIC keyword with URI */
903
+ /\bPUBLIC\s+["']/gi,
904
+ /** Parameter entity reference (%entity;) */
905
+ /%\s*\w+\s*;/g,
906
+ /** CDATA section (often used to smuggle payloads) */
907
+ /<!\[CDATA\[/gi
908
+ ];
909
+ function detectXxe(input) {
910
+ if (typeof input !== "string") return false;
911
+ for (const pattern of XXE_DETECT_PATTERNS) {
912
+ pattern.lastIndex = 0;
913
+ if (pattern.test(input)) {
914
+ return true;
915
+ }
916
+ }
917
+ return false;
918
+ }
919
+
731
920
  // src/sanitizers/sanitize.ts
732
921
  function sanitizeString(value, options = {}) {
733
922
  if (typeof value !== "string") return value;
@@ -797,9 +986,73 @@ function sanitizeObjectDepth(obj, options, depth) {
797
986
  }
798
987
  return result;
799
988
  }
989
+ function scanThreats(data, depth = 0) {
990
+ if (depth > INPUT.MAX_RECURSION_DEPTH) return null;
991
+ if (data && typeof data === "object" && !Array.isArray(data)) {
992
+ for (const key of Object.keys(data)) {
993
+ const lower = key.toLowerCase();
994
+ if (DANGEROUS_PROTO_KEYS.has(lower)) {
995
+ return { vector: "prototype", rule: "prototype/match", matchedPattern: key };
996
+ }
997
+ if (NOSQL_DANGEROUS_KEYS.has(key)) {
998
+ return { vector: "nosql", rule: "nosql/match", matchedPattern: key };
999
+ }
1000
+ const inner = scanThreats(data[key], depth + 1);
1001
+ if (inner) return inner;
1002
+ }
1003
+ return null;
1004
+ }
1005
+ if (Array.isArray(data)) {
1006
+ for (const item of data) {
1007
+ const inner = scanThreats(item, depth + 1);
1008
+ if (inner) return inner;
1009
+ }
1010
+ return null;
1011
+ }
1012
+ if (typeof data !== "string") return null;
1013
+ const sample = data.slice(0, 80);
1014
+ if (detectXss(data)) {
1015
+ return { vector: "xss", rule: "xss/match", matchedPattern: sample };
1016
+ }
1017
+ if (detectSsti(data)) {
1018
+ return { vector: "ssti", rule: "ssti/match", matchedPattern: sample };
1019
+ }
1020
+ if (detectXxe(data)) {
1021
+ return { vector: "xxe", rule: "xxe/match", matchedPattern: sample };
1022
+ }
1023
+ if (detectSql(data)) {
1024
+ return { vector: "sql", rule: "sql/match", matchedPattern: sample };
1025
+ }
1026
+ if (detectPathTraversal(data)) {
1027
+ return { vector: "path", rule: "path/match", matchedPattern: sample };
1028
+ }
1029
+ if (detectCommandInjection(data)) {
1030
+ return { vector: "command", rule: "command/match", matchedPattern: sample };
1031
+ }
1032
+ return null;
1033
+ }
800
1034
  function createSanitizer(options = {}) {
801
- return (req, _res, next) => {
1035
+ return (req, res, next) => {
802
1036
  try {
1037
+ if (options.block) {
1038
+ const hit = scanThreats(req.body) || scanThreats(req.query) || scanThreats(req.params) || scanThreats(req.path);
1039
+ if (hit) {
1040
+ req.__arcis = {
1041
+ vector: hit.vector,
1042
+ rule: hit.rule,
1043
+ severity: "high",
1044
+ matchedPattern: hit.matchedPattern,
1045
+ reason: `${hit.vector} pattern detected in request`,
1046
+ decision: "deny"
1047
+ };
1048
+ res.status(403).json({
1049
+ error: "Request blocked for security reasons",
1050
+ code: "SECURITY_THREAT",
1051
+ vector: hit.vector
1052
+ });
1053
+ return;
1054
+ }
1055
+ }
803
1056
  if (req.body && typeof req.body === "object") {
804
1057
  req.body = sanitizeObject(req.body, options);
805
1058
  }
@@ -989,6 +1242,238 @@ function validateField(field, value, rules) {
989
1242
  // Audio/Video
990
1243
  "audio/mpeg": [Buffer.from([255, 251]), Buffer.from([255, 243]), Buffer.from([73, 68, 51])]});
991
1244
 
1245
+ // src/validation/email.ts
1246
+ var MAX_EMAIL_LENGTH = 254;
1247
+ var MAX_LOCAL_LENGTH = 64;
1248
+ var MAX_DOMAIN_LENGTH = 255;
1249
+ var EMAIL_SYNTAX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
1250
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set([
1251
+ "gmail.com",
1252
+ "yahoo.com",
1253
+ "hotmail.com",
1254
+ "outlook.com",
1255
+ "aol.com",
1256
+ "protonmail.com",
1257
+ "proton.me",
1258
+ "icloud.com",
1259
+ "mail.com",
1260
+ "zoho.com",
1261
+ "yandex.com",
1262
+ "gmx.com",
1263
+ "gmx.net",
1264
+ "live.com",
1265
+ "msn.com",
1266
+ "me.com",
1267
+ "mac.com",
1268
+ "fastmail.com",
1269
+ "tutanota.com",
1270
+ "hey.com"
1271
+ ]);
1272
+ var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
1273
+ // Popular disposable services
1274
+ "guerrillamail.com",
1275
+ "guerrillamail.net",
1276
+ "guerrillamail.org",
1277
+ "tempmail.com",
1278
+ "temp-mail.org",
1279
+ "temp-mail.io",
1280
+ "throwaway.email",
1281
+ "throwaway.com",
1282
+ "mailinator.com",
1283
+ "mailinator.net",
1284
+ "yopmail.com",
1285
+ "yopmail.fr",
1286
+ "yopmail.net",
1287
+ "sharklasers.com",
1288
+ "grr.la",
1289
+ "guerrillamail.info",
1290
+ "guerrillamail.biz",
1291
+ "guerrillamail.de",
1292
+ "trashmail.com",
1293
+ "trashmail.me",
1294
+ "trashmail.net",
1295
+ "dispostable.com",
1296
+ "maildrop.cc",
1297
+ "mailnesia.com",
1298
+ "tempail.com",
1299
+ "mohmal.com",
1300
+ "getnada.com",
1301
+ "emailondeck.com",
1302
+ "discard.email",
1303
+ "fakeinbox.com",
1304
+ "mailcatch.com",
1305
+ "mintemail.com",
1306
+ "tempr.email",
1307
+ "tempinbox.com",
1308
+ "burnermail.io",
1309
+ "mailsac.com",
1310
+ "harakirimail.com",
1311
+ "tempmailo.com",
1312
+ "emailfake.com",
1313
+ "crazymailing.com",
1314
+ "armyspy.com",
1315
+ "dayrep.com",
1316
+ "einrot.com",
1317
+ "fleckens.hu",
1318
+ "gustr.com",
1319
+ "jourrapide.com",
1320
+ "rhyta.com",
1321
+ "superrito.com",
1322
+ "teleworm.us",
1323
+ "10minutemail.com",
1324
+ "10minutemail.net",
1325
+ "minutemail.com",
1326
+ "tempsky.com",
1327
+ "spamgourmet.com",
1328
+ "mytrashmail.com",
1329
+ "mailexpire.com",
1330
+ "safetymail.info",
1331
+ "filzmail.com",
1332
+ "trashymail.com",
1333
+ "sharkmail.com",
1334
+ "jetable.org",
1335
+ "nospam.ze.tc",
1336
+ "trash-me.com",
1337
+ "dodgit.com",
1338
+ "mailmoat.com",
1339
+ "spamfree24.org",
1340
+ "incognitomail.org",
1341
+ "tempomail.fr",
1342
+ "ephemail.net",
1343
+ "hidemail.de",
1344
+ "spaml.de",
1345
+ "uggsrock.com",
1346
+ "binkmail.com",
1347
+ "suremail.info",
1348
+ "bugmenot.com"
1349
+ ]);
1350
+ var DOMAIN_TYPOS = {
1351
+ "gmial.com": "gmail.com",
1352
+ "gmaill.com": "gmail.com",
1353
+ "gmai.com": "gmail.com",
1354
+ "gamil.com": "gmail.com",
1355
+ "gnail.com": "gmail.com",
1356
+ "gmal.com": "gmail.com",
1357
+ "gmil.com": "gmail.com",
1358
+ "gmail.co": "gmail.com",
1359
+ "gmail.cm": "gmail.com",
1360
+ "gmail.om": "gmail.com",
1361
+ "gmail.con": "gmail.com",
1362
+ "gmail.cim": "gmail.com",
1363
+ "gmail.comm": "gmail.com",
1364
+ "yahooo.com": "yahoo.com",
1365
+ "yaho.com": "yahoo.com",
1366
+ "yahoo.co": "yahoo.com",
1367
+ "yahoo.cm": "yahoo.com",
1368
+ "yahoo.con": "yahoo.com",
1369
+ "yahho.com": "yahoo.com",
1370
+ "hotmial.com": "hotmail.com",
1371
+ "hotmal.com": "hotmail.com",
1372
+ "hotmai.com": "hotmail.com",
1373
+ "hotmil.com": "hotmail.com",
1374
+ "hotmail.co": "hotmail.com",
1375
+ "hotmail.cm": "hotmail.com",
1376
+ "hotmail.con": "hotmail.com",
1377
+ "outlok.com": "outlook.com",
1378
+ "outloo.com": "outlook.com",
1379
+ "outlook.co": "outlook.com",
1380
+ "outlook.cm": "outlook.com",
1381
+ "protonmal.com": "protonmail.com",
1382
+ "protonmail.co": "protonmail.com",
1383
+ "icloud.co": "icloud.com",
1384
+ "icloud.cm": "icloud.com",
1385
+ "icoud.com": "icloud.com"
1386
+ };
1387
+ function invalidResult(reason, email) {
1388
+ return {
1389
+ valid: false,
1390
+ reason,
1391
+ suggestion: null,
1392
+ isFree: false,
1393
+ isDisposable: false,
1394
+ normalized: email
1395
+ };
1396
+ }
1397
+ function validateEmail(email, options = {}) {
1398
+ const {
1399
+ checkDisposable = true,
1400
+ suggestTypoFix = true,
1401
+ blockedDomains = [],
1402
+ allowedDomains = []
1403
+ } = options;
1404
+ const normalized = email.trim().toLowerCase();
1405
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) {
1406
+ return invalidResult("invalid_syntax", normalized);
1407
+ }
1408
+ const atIndex = normalized.lastIndexOf("@");
1409
+ if (atIndex === -1) {
1410
+ return invalidResult("invalid_syntax", normalized);
1411
+ }
1412
+ const localPart = normalized.slice(0, atIndex);
1413
+ const domain = normalized.slice(atIndex + 1);
1414
+ if (localPart.length === 0 || localPart.length > MAX_LOCAL_LENGTH) {
1415
+ return invalidResult("invalid_syntax", normalized);
1416
+ }
1417
+ if (domain.length === 0 || domain.length > MAX_DOMAIN_LENGTH) {
1418
+ return invalidResult("invalid_syntax", normalized);
1419
+ }
1420
+ if (localPart.includes("..")) {
1421
+ return invalidResult("invalid_syntax", normalized);
1422
+ }
1423
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
1424
+ return invalidResult("invalid_syntax", normalized);
1425
+ }
1426
+ if (!EMAIL_SYNTAX.test(normalized)) {
1427
+ return invalidResult("invalid_syntax", normalized);
1428
+ }
1429
+ const allowedSet = new Set(allowedDomains.map((d) => d.toLowerCase()));
1430
+ if (allowedSet.has(domain)) {
1431
+ return {
1432
+ valid: true,
1433
+ reason: "valid",
1434
+ suggestion: null,
1435
+ isFree: FREE_PROVIDERS.has(domain),
1436
+ isDisposable: false,
1437
+ normalized
1438
+ };
1439
+ }
1440
+ const blockedSet = new Set(blockedDomains.map((d) => d.toLowerCase()));
1441
+ if (blockedSet.has(domain)) {
1442
+ return invalidResult("blocked", normalized);
1443
+ }
1444
+ const isDisposable = DISPOSABLE_DOMAINS.has(domain);
1445
+ if (checkDisposable && isDisposable) {
1446
+ return {
1447
+ valid: false,
1448
+ reason: "disposable",
1449
+ suggestion: null,
1450
+ isFree: false,
1451
+ isDisposable: true,
1452
+ normalized
1453
+ };
1454
+ }
1455
+ const isFree = FREE_PROVIDERS.has(domain);
1456
+ if (suggestTypoFix && DOMAIN_TYPOS[domain]) {
1457
+ const corrected = `${localPart}@${DOMAIN_TYPOS[domain]}`;
1458
+ return {
1459
+ valid: true,
1460
+ reason: "typo",
1461
+ suggestion: corrected,
1462
+ isFree: FREE_PROVIDERS.has(DOMAIN_TYPOS[domain]),
1463
+ isDisposable: false,
1464
+ normalized
1465
+ };
1466
+ }
1467
+ return {
1468
+ valid: true,
1469
+ reason: "valid",
1470
+ suggestion: null,
1471
+ isFree,
1472
+ isDisposable,
1473
+ normalized
1474
+ };
1475
+ }
1476
+
992
1477
  // src/logging/redactor.ts
993
1478
  var LOG_LEVELS = {
994
1479
  debug: 0,
@@ -1061,10 +1546,213 @@ function redactString(str, maxLength, patterns) {
1061
1546
  return safe;
1062
1547
  }
1063
1548
 
1549
+ // src/telemetry/client.ts
1550
+ var DEFAULT_BATCH_SIZE = 50;
1551
+ var MAX_BATCH_SIZE = 500;
1552
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
1553
+ var MIN_FLUSH_INTERVAL_MS = 500;
1554
+ var FLUSH_TIMEOUT_MS = 1e4;
1555
+ var DEFAULT_MAX_QUEUE_SIZE = 1e4;
1556
+ var TelemetryClient = class {
1557
+ constructor(options) {
1558
+ this.queue = [];
1559
+ this.flushing = false;
1560
+ this.closed = false;
1561
+ // Counts events dropped since the last successful flush. Resets to 0
1562
+ // each flush so onQueueOverflow callbacks see "drops in this window"
1563
+ // rather than a monotonic lifetime counter.
1564
+ this.droppedSinceLastFlush = 0;
1565
+ if (!options.endpoint || typeof options.endpoint !== "string") {
1566
+ throw new TypeError("TelemetryClient: `endpoint` is required");
1567
+ }
1568
+ this.endpoint = options.endpoint;
1569
+ this.apiKey = options.apiKey;
1570
+ this.workspaceId = options.workspaceId;
1571
+ this.batchSize = clamp(
1572
+ options.batchSize ?? DEFAULT_BATCH_SIZE,
1573
+ 1,
1574
+ MAX_BATCH_SIZE
1575
+ );
1576
+ this.flushIntervalMs = Math.max(
1577
+ options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
1578
+ MIN_FLUSH_INTERVAL_MS
1579
+ );
1580
+ this.maxQueueSize = Math.max(
1581
+ options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
1582
+ this.batchSize
1583
+ );
1584
+ this.onError = options.onError ?? (() => {
1585
+ });
1586
+ this.onQueueOverflow = options.onQueueOverflow ?? (() => {
1587
+ });
1588
+ this.startTimer();
1589
+ }
1590
+ /**
1591
+ * Enqueue an event. Fast, synchronous, cannot throw.
1592
+ * Triggers a flush if the queue has reached `batchSize`.
1593
+ */
1594
+ record(event) {
1595
+ if (this.closed) return;
1596
+ this.queue.push(event);
1597
+ if (this.queue.length > this.maxQueueSize) {
1598
+ const drop = this.queue.length - this.maxQueueSize;
1599
+ this.queue.splice(0, drop);
1600
+ this.droppedSinceLastFlush += drop;
1601
+ try {
1602
+ this.onQueueOverflow(this.droppedSinceLastFlush);
1603
+ } catch {
1604
+ }
1605
+ }
1606
+ if (this.queue.length >= this.batchSize) {
1607
+ void this.flush();
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Manually flush the queue. Pulls up to `batchSize` events into a batch and
1612
+ * POSTs them. Returns a resolved promise on success OR on handled failure.
1613
+ * Never throws.
1614
+ */
1615
+ async flush() {
1616
+ if (this.flushing) return;
1617
+ if (this.queue.length === 0) return;
1618
+ this.flushing = true;
1619
+ try {
1620
+ const batch = this.queue.splice(0, this.batchSize);
1621
+ await this.send(batch);
1622
+ this.droppedSinceLastFlush = 0;
1623
+ } catch (err) {
1624
+ this.safeNotify(err);
1625
+ } finally {
1626
+ this.flushing = false;
1627
+ }
1628
+ if (!this.closed && this.queue.length > 0) {
1629
+ void this.flush();
1630
+ }
1631
+ }
1632
+ /**
1633
+ * Shut down: stop the interval timer and attempt one final flush.
1634
+ * Safe to call multiple times.
1635
+ */
1636
+ async close() {
1637
+ if (this.closed) return;
1638
+ this.closed = true;
1639
+ if (this.timer !== void 0) {
1640
+ clearInterval(this.timer);
1641
+ this.timer = void 0;
1642
+ }
1643
+ if (this.signalHandler !== void 0) {
1644
+ process.off("SIGTERM", this.signalHandler);
1645
+ process.off("SIGINT", this.signalHandler);
1646
+ this.signalHandler = void 0;
1647
+ }
1648
+ try {
1649
+ await this.flush();
1650
+ } catch {
1651
+ }
1652
+ }
1653
+ /**
1654
+ * Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
1655
+ * the queue on graceful shutdown. Opt-in — libraries should not silently
1656
+ * attach global signal handlers. Safe to call multiple times.
1657
+ */
1658
+ installShutdownHooks() {
1659
+ if (this.signalHandler !== void 0 || this.closed) return;
1660
+ const handler = () => {
1661
+ void this.close();
1662
+ };
1663
+ this.signalHandler = handler;
1664
+ process.once("SIGTERM", handler);
1665
+ process.once("SIGINT", handler);
1666
+ }
1667
+ /** Count of events currently waiting to be sent. Useful for tests. */
1668
+ get pendingCount() {
1669
+ return this.queue.length;
1670
+ }
1671
+ // ── internals ─────────────────────────────────────────────────────────
1672
+ async send(batch) {
1673
+ const headers = {
1674
+ "content-type": "application/json"
1675
+ };
1676
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
1677
+ if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
1678
+ const controller = new AbortController();
1679
+ const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
1680
+ try {
1681
+ const res = await fetch(this.endpoint, {
1682
+ method: "POST",
1683
+ headers,
1684
+ body: JSON.stringify({ events: batch }),
1685
+ signal: controller.signal
1686
+ });
1687
+ if (!res.ok) {
1688
+ const text = await safeReadBody(res);
1689
+ throw new TelemetryHttpError(res.status, text);
1690
+ }
1691
+ } finally {
1692
+ clearTimeout(abortTimer);
1693
+ }
1694
+ }
1695
+ startTimer() {
1696
+ this.timer = setInterval(() => {
1697
+ void this.flush();
1698
+ }, this.flushIntervalMs);
1699
+ this.timer.unref?.();
1700
+ }
1701
+ safeNotify(err) {
1702
+ try {
1703
+ this.onError(err instanceof Error ? err : new Error(String(err)));
1704
+ } catch {
1705
+ }
1706
+ }
1707
+ };
1708
+ var TelemetryHttpError = class extends Error {
1709
+ constructor(status, responseBody) {
1710
+ super(`Telemetry ingest returned HTTP ${status}`);
1711
+ this.status = status;
1712
+ this.responseBody = responseBody;
1713
+ this.name = "TelemetryHttpError";
1714
+ }
1715
+ };
1716
+ function clamp(value, min, max) {
1717
+ if (!Number.isFinite(value)) return min;
1718
+ return Math.max(min, Math.min(max, Math.trunc(value)));
1719
+ }
1720
+ async function safeReadBody(res) {
1721
+ try {
1722
+ const text = await res.text();
1723
+ return text.slice(0, 500);
1724
+ } catch {
1725
+ return "";
1726
+ }
1727
+ }
1728
+
1064
1729
  // src/middleware/main.ts
1730
+ function buildTelemetryFromEnv() {
1731
+ const env = typeof process !== "undefined" ? process.env : void 0;
1732
+ const endpoint = env?.ARCIS_ENDPOINT;
1733
+ if (!endpoint) return void 0;
1734
+ const opts = { endpoint };
1735
+ if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
1736
+ if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
1737
+ const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
1738
+ if (!Number.isNaN(batch)) opts.batchSize = batch;
1739
+ const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
1740
+ if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
1741
+ return opts;
1742
+ }
1065
1743
  function arcis(options = {}) {
1066
1744
  const middlewares = [];
1067
1745
  const cleanupFns = [];
1746
+ let telemetryClient;
1747
+ const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
1748
+ if (telemetryOpts) {
1749
+ const client = new TelemetryClient(telemetryOpts);
1750
+ telemetryClient = client;
1751
+ middlewares.push(createTelemetryEmitter(client));
1752
+ cleanupFns.push(() => {
1753
+ void client.close();
1754
+ });
1755
+ }
1068
1756
  if (options.headers !== false) {
1069
1757
  const headerOpts = typeof options.headers === "object" ? options.headers : {};
1070
1758
  middlewares.push(createHeaders(headerOpts));
@@ -1076,8 +1764,12 @@ function arcis(options = {}) {
1076
1764
  cleanupFns.push(() => rateLimiter.close());
1077
1765
  }
1078
1766
  if (options.sanitize !== false) {
1079
- const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
1080
- middlewares.push(createSanitizer(sanitizeOpts));
1767
+ const sanitizeOpts = typeof options.sanitize === "object" ? { ...options.sanitize } : {};
1768
+ if (options.block && sanitizeOpts.block === void 0) {
1769
+ sanitizeOpts.block = true;
1770
+ }
1771
+ const sanitizer = createSanitizer(sanitizeOpts);
1772
+ middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
1081
1773
  }
1082
1774
  const result = middlewares;
1083
1775
  result.close = () => {
@@ -1390,6 +2082,16 @@ function secureCookieDefaults(options = {}) {
1390
2082
  sameSite: options.sameSite ?? "Lax",
1391
2083
  path: options.path
1392
2084
  };
2085
+ if (resolved.sameSite === "None" && resolved.secure === false) {
2086
+ throw new Error(
2087
+ "[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
2088
+ );
2089
+ }
2090
+ if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
2091
+ console.warn(
2092
+ "[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
2093
+ );
2094
+ }
1393
2095
  return (_req, res, next) => {
1394
2096
  const originalSetHeader = res.setHeader.bind(res);
1395
2097
  res.setHeader = function patchedSetHeader(name, value) {
@@ -1522,7 +2224,16 @@ function detectBehavioralSignals(req) {
1522
2224
  }
1523
2225
  function detectBot(req) {
1524
2226
  const rawUa = req.headers["user-agent"] ?? "";
1525
- const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
2227
+ if (rawUa.length > 2048) {
2228
+ return {
2229
+ isBot: true,
2230
+ category: "UNKNOWN",
2231
+ name: null,
2232
+ confidence: 0.9,
2233
+ signals: detectBehavioralSignals(req)
2234
+ };
2235
+ }
2236
+ const ua = rawUa;
1526
2237
  const signals = detectBehavioralSignals(req);
1527
2238
  if (!ua) {
1528
2239
  return {
@@ -1583,6 +2294,13 @@ function botProtection(options = {}) {
1583
2294
  return next();
1584
2295
  }
1585
2296
  if (denySet.has(result.category)) {
2297
+ req.__arcis = {
2298
+ vector: "bot",
2299
+ rule: `bot/${result.category.toLowerCase()}`,
2300
+ severity: "medium",
2301
+ reason: result.name ? `Bot detected: ${result.name}` : "Bot detected",
2302
+ decision: "deny"
2303
+ };
1586
2304
  if (onDetected) {
1587
2305
  return onDetected(req, res, result);
1588
2306
  }
@@ -1590,6 +2308,13 @@ function botProtection(options = {}) {
1590
2308
  return;
1591
2309
  }
1592
2310
  if (defaultAction === "deny") {
2311
+ req.__arcis = {
2312
+ vector: "bot",
2313
+ rule: "bot/uncategorized",
2314
+ severity: "medium",
2315
+ reason: "Uncategorized bot under defaultAction=deny",
2316
+ decision: "deny"
2317
+ };
1593
2318
  if (onDetected) {
1594
2319
  return onDetected(req, res, result);
1595
2320
  }
@@ -1643,7 +2368,14 @@ function csrfProtection(options = {}) {
1643
2368
  sameSite: options.cookie?.sameSite ?? "Lax",
1644
2369
  domain: options.cookie?.domain
1645
2370
  };
1646
- const defaultOnError = (_req, res, _next) => {
2371
+ const defaultOnError = (req, res, _next) => {
2372
+ req.__arcis = {
2373
+ vector: "csrf",
2374
+ rule: "csrf/token-mismatch",
2375
+ severity: "high",
2376
+ reason: "CSRF token missing or invalid",
2377
+ decision: "deny"
2378
+ };
1647
2379
  res.status(403).json({
1648
2380
  error: "CSRF token validation failed",
1649
2381
  message: "Invalid or missing CSRF token. Include the token from the cookie in the X-CSRF-Token header."
@@ -1686,6 +2418,10 @@ function csrfProtection(options = {}) {
1686
2418
  if (!validateCsrfToken(cookieToken, requestToken)) {
1687
2419
  return onError(req, res, next);
1688
2420
  }
2421
+ if (options.rotateOnUse) {
2422
+ const freshToken = generateCsrfToken(tokenLength);
2423
+ setCsrfCookie(res, cookieName, freshToken, cookieOpts);
2424
+ }
1689
2425
  next();
1690
2426
  };
1691
2427
  }
@@ -1720,6 +2456,89 @@ function escapeRegex(str) {
1720
2456
  }
1721
2457
  var createCsrf = csrfProtection;
1722
2458
 
1723
- export { arcis, arcisWithMethods as arcisFunction, botProtection, createCors, createCsrf, createErrorHandler, createHeaders, createRateLimiter, createSecureCookies, createSlidingWindowLimiter, createTokenBucketLimiter, csrfProtection, main_default as default, detectBot, enforceSecureCookie, errorHandler, generateCsrfToken, rateLimit, safeCors, secureCookieDefaults, securityHeaders, validateCsrfToken };
2459
+ // src/middleware/signup-protection.ts
2460
+ function checkSignup(req, options = {}) {
2461
+ const {
2462
+ emailField = "email",
2463
+ checkEmail = true,
2464
+ blockDisposable = true,
2465
+ checkBot = true,
2466
+ allowedBotCategories = [],
2467
+ allowedEmailDomains = [],
2468
+ blockedEmailDomains = []
2469
+ } = options;
2470
+ if (checkBot) {
2471
+ const bot = detectBot(req);
2472
+ if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
2473
+ return {
2474
+ allowed: false,
2475
+ reason: "bot",
2476
+ details: { category: bot.category, name: bot.name, confidence: bot.confidence }
2477
+ };
2478
+ }
2479
+ }
2480
+ if (checkEmail) {
2481
+ const email = req.body?.[emailField];
2482
+ if (typeof email !== "string" || email.length === 0) {
2483
+ return { allowed: false, reason: "missing_email" };
2484
+ }
2485
+ const result = validateEmail(email, {
2486
+ checkDisposable: blockDisposable,
2487
+ allowedDomains: allowedEmailDomains,
2488
+ blockedDomains: blockedEmailDomains
2489
+ });
2490
+ if (!result.valid) {
2491
+ const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
2492
+ return { allowed: false, reason, details: { emailReason: result.reason } };
2493
+ }
2494
+ }
2495
+ return { allowed: true, reason: "ok" };
2496
+ }
2497
+ function signupProtection(options = {}) {
2498
+ const rateLimitCfg = options.rateLimit;
2499
+ const limiter = rateLimitCfg === false ? null : createRateLimiter({
2500
+ max: rateLimitCfg?.max ?? 5,
2501
+ windowMs: rateLimitCfg?.windowMs ?? 6e4,
2502
+ message: "Too many signup attempts"
2503
+ });
2504
+ const handler = (req, res, next) => {
2505
+ const result = checkSignup(req, options);
2506
+ if (!result.allowed) {
2507
+ options.onBlocked?.(req, result);
2508
+ const status = result.reason === "bot" ? 403 : 400;
2509
+ res.status(status).json({ error: "signup_blocked", reason: result.reason });
2510
+ return;
2511
+ }
2512
+ if (limiter) {
2513
+ let rateLimited = false;
2514
+ const rateLimitNext = (err) => {
2515
+ if (err) return next(err);
2516
+ if (!rateLimited) next();
2517
+ };
2518
+ const patchedRes = new Proxy(res, {
2519
+ get(target, prop) {
2520
+ if (prop === "status") {
2521
+ return (code) => {
2522
+ if (code === 429) {
2523
+ rateLimited = true;
2524
+ options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
2525
+ }
2526
+ return target.status.call(target, code);
2527
+ };
2528
+ }
2529
+ return Reflect.get(target, prop);
2530
+ }
2531
+ });
2532
+ limiter(req, patchedRes, rateLimitNext);
2533
+ return;
2534
+ }
2535
+ next();
2536
+ };
2537
+ const middleware = handler;
2538
+ middleware.close = () => limiter?.close();
2539
+ return middleware;
2540
+ }
2541
+
2542
+ export { arcis, arcisWithMethods as arcisFunction, botProtection, checkSignup, createCors, createCsrf, createErrorHandler, createHeaders, createRateLimiter, createSecureCookies, createSlidingWindowLimiter, createTokenBucketLimiter, csrfProtection, main_default as default, detectBot, enforceSecureCookie, errorHandler, generateCsrfToken, rateLimit, safeCors, secureCookieDefaults, securityHeaders, signupProtection, validateCsrfToken };
1724
2543
  //# sourceMappingURL=index.mjs.map
1725
2544
  //# sourceMappingURL=index.mjs.map