@arcis/node 1.4.3 → 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 (45) 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 +1 -1
  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 +11 -0
  15. package/dist/core/types.d.ts.map +1 -1
  16. package/dist/index.js +253 -141
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +253 -141
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/logging/index.js.map +1 -1
  21. package/dist/logging/index.mjs.map +1 -1
  22. package/dist/middleware/bot-detection.d.ts.map +1 -1
  23. package/dist/middleware/csrf.d.ts.map +1 -1
  24. package/dist/middleware/index.js +224 -3
  25. package/dist/middleware/index.js.map +1 -1
  26. package/dist/middleware/index.mjs +224 -3
  27. package/dist/middleware/index.mjs.map +1 -1
  28. package/dist/middleware/main.d.ts.map +1 -1
  29. package/dist/sanitizers/index.d.ts +2 -1
  30. package/dist/sanitizers/index.d.ts.map +1 -1
  31. package/dist/sanitizers/index.js +213 -145
  32. package/dist/sanitizers/index.js.map +1 -1
  33. package/dist/sanitizers/index.mjs +213 -146
  34. package/dist/sanitizers/index.mjs.map +1 -1
  35. package/dist/sanitizers/sanitize.d.ts +13 -0
  36. package/dist/sanitizers/sanitize.d.ts.map +1 -1
  37. package/dist/stores/index.js.map +1 -1
  38. package/dist/stores/index.mjs.map +1 -1
  39. package/dist/telemetry/client.d.ts +3 -0
  40. package/dist/telemetry/client.d.ts.map +1 -1
  41. package/dist/telemetry/types.d.ts +12 -0
  42. package/dist/telemetry/types.d.ts.map +1 -1
  43. package/dist/validation/index.js.map +1 -1
  44. package/dist/validation/index.mjs.map +1 -1
  45. package/package.json +4 -1
@@ -40,6 +40,39 @@ 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,
@@ -677,6 +710,20 @@ function sanitizeXss(input, collectThreats = false, htmlEncode = false) {
677
710
  }
678
711
  return value;
679
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
+ }
680
727
 
681
728
  // src/sanitizers/sql.ts
682
729
  function sanitizeSql(input, collectThreats = false) {
@@ -760,6 +807,17 @@ function sanitizePath(input, collectThreats = false) {
760
807
  }
761
808
  return value;
762
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
+ }
763
821
 
764
822
  // src/sanitizers/command.ts
765
823
  function sanitizeCommand(input, collectThreats = false) {
@@ -805,6 +863,60 @@ function detectCommandInjection(input) {
805
863
  return false;
806
864
  }
807
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
+
808
920
  // src/sanitizers/sanitize.ts
809
921
  function sanitizeString(value, options = {}) {
810
922
  if (typeof value !== "string") return value;
@@ -874,9 +986,73 @@ function sanitizeObjectDepth(obj, options, depth) {
874
986
  }
875
987
  return result;
876
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
+ }
877
1034
  function createSanitizer(options = {}) {
878
- return (req, _res, next) => {
1035
+ return (req, res, next) => {
879
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
+ }
880
1056
  if (req.body && typeof req.body === "object") {
881
1057
  req.body = sanitizeObject(req.body, options);
882
1058
  }
@@ -1376,11 +1552,16 @@ var MAX_BATCH_SIZE = 500;
1376
1552
  var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
1377
1553
  var MIN_FLUSH_INTERVAL_MS = 500;
1378
1554
  var FLUSH_TIMEOUT_MS = 1e4;
1555
+ var DEFAULT_MAX_QUEUE_SIZE = 1e4;
1379
1556
  var TelemetryClient = class {
1380
1557
  constructor(options) {
1381
1558
  this.queue = [];
1382
1559
  this.flushing = false;
1383
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;
1384
1565
  if (!options.endpoint || typeof options.endpoint !== "string") {
1385
1566
  throw new TypeError("TelemetryClient: `endpoint` is required");
1386
1567
  }
@@ -1396,8 +1577,14 @@ var TelemetryClient = class {
1396
1577
  options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
1397
1578
  MIN_FLUSH_INTERVAL_MS
1398
1579
  );
1580
+ this.maxQueueSize = Math.max(
1581
+ options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
1582
+ this.batchSize
1583
+ );
1399
1584
  this.onError = options.onError ?? (() => {
1400
1585
  });
1586
+ this.onQueueOverflow = options.onQueueOverflow ?? (() => {
1587
+ });
1401
1588
  this.startTimer();
1402
1589
  }
1403
1590
  /**
@@ -1407,6 +1594,15 @@ var TelemetryClient = class {
1407
1594
  record(event) {
1408
1595
  if (this.closed) return;
1409
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
+ }
1410
1606
  if (this.queue.length >= this.batchSize) {
1411
1607
  void this.flush();
1412
1608
  }
@@ -1423,6 +1619,7 @@ var TelemetryClient = class {
1423
1619
  try {
1424
1620
  const batch = this.queue.splice(0, this.batchSize);
1425
1621
  await this.send(batch);
1622
+ this.droppedSinceLastFlush = 0;
1426
1623
  } catch (err) {
1427
1624
  this.safeNotify(err);
1428
1625
  } finally {
@@ -1567,7 +1764,10 @@ function arcis(options = {}) {
1567
1764
  cleanupFns.push(() => rateLimiter.close());
1568
1765
  }
1569
1766
  if (options.sanitize !== false) {
1570
- const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
1767
+ const sanitizeOpts = typeof options.sanitize === "object" ? { ...options.sanitize } : {};
1768
+ if (options.block && sanitizeOpts.block === void 0) {
1769
+ sanitizeOpts.block = true;
1770
+ }
1571
1771
  const sanitizer = createSanitizer(sanitizeOpts);
1572
1772
  middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
1573
1773
  }
@@ -2094,6 +2294,13 @@ function botProtection(options = {}) {
2094
2294
  return next();
2095
2295
  }
2096
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
+ };
2097
2304
  if (onDetected) {
2098
2305
  return onDetected(req, res, result);
2099
2306
  }
@@ -2101,6 +2308,13 @@ function botProtection(options = {}) {
2101
2308
  return;
2102
2309
  }
2103
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
+ };
2104
2318
  if (onDetected) {
2105
2319
  return onDetected(req, res, result);
2106
2320
  }
@@ -2154,7 +2368,14 @@ function csrfProtection(options = {}) {
2154
2368
  sameSite: options.cookie?.sameSite ?? "Lax",
2155
2369
  domain: options.cookie?.domain
2156
2370
  };
2157
- 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
+ };
2158
2379
  res.status(403).json({
2159
2380
  error: "CSRF token validation failed",
2160
2381
  message: "Invalid or missing CSRF token. Include the token from the cookie in the X-CSRF-Token header."