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