pompelmi 0.35.3 → 0.35.5

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.
@@ -2,6 +2,7 @@ import * as crypto from 'crypto';
2
2
  import { createHash } from 'crypto';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import { createInflateRaw } from 'zlib';
5
6
 
6
7
  /**
7
8
  * Advanced configuration system for pompelmi
@@ -1752,12 +1753,15 @@ async function scanFilesWithRemoteYara(files, rulesSource, remote) {
1752
1753
  return results;
1753
1754
  }
1754
1755
 
1756
+ const ARCHIVE_BOMB_DETECTED = "ARCHIVE_BOMB_DETECTED";
1757
+ const SIG_LFH = 0x04034b50;
1755
1758
  const SIG_CEN = 0x02014b50;
1756
1759
  const DEFAULTS = {
1757
1760
  maxEntries: 1000,
1758
1761
  maxTotalUncompressedBytes: 500 * 1024 * 1024,
1762
+ maxPerEntryUncompressedBytes: 100 * 1024 * 1024,
1759
1763
  maxEntryNameLength: 255,
1760
- maxCompressionRatio: 1000,
1764
+ maxCompressionRatio: 100,
1761
1765
  eocdSearchWindow: 70000,
1762
1766
  };
1763
1767
  function r16(buf, off) {
@@ -1767,7 +1771,6 @@ function r32(buf, off) {
1767
1771
  return buf.readUInt32LE(off);
1768
1772
  }
1769
1773
  function isZipLike(buf) {
1770
- // local file header at start is common
1771
1774
  return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
1772
1775
  }
1773
1776
  function lastIndexOfEOCD(buf, window) {
@@ -1779,6 +1782,47 @@ function lastIndexOfEOCD(buf, window) {
1779
1782
  function hasTraversal(name) {
1780
1783
  return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
1781
1784
  }
1785
+ function makeBombError() {
1786
+ return Object.assign(new Error("Archive bomb detected: decompression limits exceeded"), {
1787
+ code: ARCHIVE_BOMB_DETECTED,
1788
+ });
1789
+ }
1790
+ /**
1791
+ * Feeds `compressed` into a raw DEFLATE inflate stream and counts the actual
1792
+ * output bytes. Resolves with bombed=true and aborts early if any limit fires:
1793
+ * - decompressed bytes > maxPerEntry
1794
+ * - totalSoFar + decompressed > maxTotal
1795
+ * - decompressed / compressed > maxRatio (ratio measured on real bytes, not headers)
1796
+ *
1797
+ * Malformed DEFLATE is treated as safe (bombed=false, decompressed=0).
1798
+ */
1799
+ function streamInflate(compressed, maxPerEntry, maxTotal, alreadySeen, maxRatio) {
1800
+ return new Promise((resolve) => {
1801
+ const inf = createInflateRaw();
1802
+ let out = 0;
1803
+ const compBytes = compressed.length;
1804
+ let done = false;
1805
+ const finish = (bombed) => {
1806
+ if (done)
1807
+ return;
1808
+ done = true;
1809
+ inf.destroy();
1810
+ resolve({ decompressed: out, bombed });
1811
+ };
1812
+ inf.on("data", (chunk) => {
1813
+ out += chunk.length;
1814
+ if (out > maxPerEntry ||
1815
+ alreadySeen + out > maxTotal ||
1816
+ (compBytes > 0 && out / compBytes > maxRatio)) {
1817
+ finish(true);
1818
+ }
1819
+ });
1820
+ inf.on("end", () => finish(false));
1821
+ // Malformed DEFLATE stream → not a bomb, just corrupt
1822
+ inf.on("error", () => finish(false));
1823
+ inf.end(compressed);
1824
+ });
1825
+ }
1782
1826
  function createZipBombGuard(opts = {}) {
1783
1827
  const cfg = { ...DEFAULTS, ...opts };
1784
1828
  return {
@@ -1787,43 +1831,36 @@ function createZipBombGuard(opts = {}) {
1787
1831
  const matches = [];
1788
1832
  if (!isZipLike(buf))
1789
1833
  return matches;
1790
- // Find EOCD near the end
1834
+ // ── 1. Locate EOCD ──────────────────────────────────────────────────────
1791
1835
  const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
1792
1836
  if (eocdPos < 0 || eocdPos + 22 > buf.length) {
1793
- // ZIP but no EOCD — malformed or polyglot → suspicious
1794
1837
  matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
1795
1838
  return matches;
1796
1839
  }
1797
1840
  const totalEntries = r16(buf, eocdPos + 10);
1798
1841
  const cdSize = r32(buf, eocdPos + 12);
1799
1842
  const cdOffset = r32(buf, eocdPos + 16);
1800
- // Bounds check
1801
1843
  if (cdOffset + cdSize > buf.length) {
1802
1844
  matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
1803
1845
  return matches;
1804
1846
  }
1805
- // Iterate central directory entries
1847
+ const lfhIndex = [];
1806
1848
  let ptr = cdOffset;
1807
1849
  let seen = 0;
1808
- let sumComp = 0;
1809
- let sumUnc = 0;
1810
1850
  while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
1811
- const sig = r32(buf, ptr);
1812
- if (sig !== SIG_CEN)
1813
- break; // stop if structure breaks
1814
- const compSize = r32(buf, ptr + 20);
1815
- const uncSize = r32(buf, ptr + 24);
1851
+ if (r32(buf, ptr) !== SIG_CEN)
1852
+ break;
1853
+ const cdCompSize = r32(buf, ptr + 20);
1816
1854
  const fnLen = r16(buf, ptr + 28);
1817
1855
  const exLen = r16(buf, ptr + 30);
1818
1856
  const cmLen = r16(buf, ptr + 32);
1819
- const nameStart = ptr + 46;
1820
- const nameEnd = nameStart + fnLen;
1857
+ const lfhOffset = r32(buf, ptr + 42);
1858
+ const nameEnd = ptr + 46 + fnLen;
1821
1859
  if (nameEnd > buf.length)
1822
1860
  break;
1823
- const name = buf.toString("utf8", nameStart, nameEnd);
1824
- sumComp += compSize;
1825
- sumUnc += uncSize;
1861
+ const name = buf.toString("utf8", ptr + 46, nameEnd);
1826
1862
  seen++;
1863
+ lfhIndex.push({ lfhOffset, cdCompSize });
1827
1864
  if (name.length > cfg.maxEntryNameLength) {
1828
1865
  matches.push({
1829
1866
  rule: "zip_entry_name_too_long",
@@ -1834,48 +1871,67 @@ function createZipBombGuard(opts = {}) {
1834
1871
  if (hasTraversal(name)) {
1835
1872
  matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
1836
1873
  }
1837
- // move to next entry
1838
1874
  ptr = nameEnd + exLen + cmLen;
1839
1875
  }
1840
1876
  if (seen !== totalEntries) {
1841
- // central dir truncated/odd, still report what we found
1842
1877
  matches.push({
1843
1878
  rule: "zip_cd_truncated",
1844
1879
  severity: "medium",
1845
1880
  meta: { seen, totalEntries },
1846
1881
  });
1847
1882
  }
1848
- // Heuristics thresholds
1849
1883
  if (seen > cfg.maxEntries) {
1850
1884
  matches.push({
1851
1885
  rule: "zip_too_many_entries",
1852
1886
  severity: "medium",
1853
1887
  meta: { seen, limit: cfg.maxEntries },
1854
1888
  });
1889
+ // Return early — decompressing thousands of entries would be a DoS vector
1890
+ return matches;
1855
1891
  }
1856
- if (sumUnc > cfg.maxTotalUncompressedBytes) {
1857
- matches.push({
1858
- rule: "zip_total_uncompressed_too_large",
1859
- severity: "medium",
1860
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
1861
- });
1862
- }
1863
- if (sumComp === 0 && sumUnc > 0) {
1864
- matches.push({
1865
- rule: "zip_suspicious_ratio",
1866
- severity: "medium",
1867
- meta: { ratio: Infinity },
1868
- });
1869
- }
1870
- else if (sumComp > 0) {
1871
- const ratio = sumUnc / Math.max(1, sumComp);
1872
- if (ratio >= cfg.maxCompressionRatio) {
1873
- matches.push({
1874
- rule: "zip_suspicious_ratio",
1875
- severity: "medium",
1876
- meta: { ratio, limit: cfg.maxCompressionRatio },
1877
- });
1892
+ // ── 3. True streaming decompression — archive bomb detection ────────────
1893
+ // For every DEFLATE entry (method=8) we feed the raw compressed bytes into
1894
+ // zlib.createInflateRaw() and count the bytes that come OUT. We abort the
1895
+ // moment any limit fires; we NEVER trust the header-reported uncompressed
1896
+ // size for the ratio decision.
1897
+ //
1898
+ // For STORED entries (method=0) compressed == uncompressed by spec, so the
1899
+ // byte count is immediate.
1900
+ let totalDecompressed = 0;
1901
+ for (const { lfhOffset, cdCompSize } of lfhIndex) {
1902
+ if (lfhOffset + 30 > buf.length)
1903
+ continue;
1904
+ if (r32(buf, lfhOffset) !== SIG_LFH)
1905
+ continue;
1906
+ const gpbf = r16(buf, lfhOffset + 6);
1907
+ const method = r16(buf, lfhOffset + 8);
1908
+ let lfhCompSz = r32(buf, lfhOffset + 18);
1909
+ const fnLen = r16(buf, lfhOffset + 26);
1910
+ const exLen = r16(buf, lfhOffset + 28);
1911
+ const dataOff = lfhOffset + 30 + fnLen + exLen;
1912
+ // If the data-descriptor flag is set (GPBF bit 3), the LFH sizes are 0.
1913
+ // Fall back to the CD size purely for navigation — not for bomb detection.
1914
+ if ((gpbf & 0x08) !== 0 && lfhCompSz === 0) {
1915
+ lfhCompSz = cdCompSize;
1916
+ }
1917
+ if (dataOff + lfhCompSz > buf.length)
1918
+ continue; // truncated entry — skip
1919
+ if (method === 8 /* DEFLATE */) {
1920
+ const compressed = buf.slice(dataOff, dataOff + lfhCompSz);
1921
+ const { decompressed, bombed } = await streamInflate(compressed, cfg.maxPerEntryUncompressedBytes, cfg.maxTotalUncompressedBytes, totalDecompressed, cfg.maxCompressionRatio);
1922
+ if (bombed)
1923
+ throw makeBombError();
1924
+ totalDecompressed += decompressed;
1925
+ }
1926
+ else if (method === 0 /* STORED */) {
1927
+ // Compressed == uncompressed for stored entries
1928
+ if (lfhCompSz > cfg.maxPerEntryUncompressedBytes)
1929
+ throw makeBombError();
1930
+ totalDecompressed += lfhCompSz;
1931
+ if (totalDecompressed > cfg.maxTotalUncompressedBytes)
1932
+ throw makeBombError();
1878
1933
  }
1934
+ // Other methods (bzip2=12, lzma=14, zstd=93, …) — skip; no built-in support
1879
1935
  }
1880
1936
  return matches;
1881
1937
  },