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.
- package/README.md +125 -85
- package/dist/pompelmi.browser.cjs +99 -43
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +99 -43
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +99 -43
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +99 -43
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/pompelmi.react.cjs +99 -43
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +99 -43
- package/dist/pompelmi.react.esm.js.map +1 -1
- package/dist/types/src/scanners/zip-bomb-guard.d.ts +2 -0
- package/package.json +1 -1
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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
|
|
1842
|
-
const nameEnd =
|
|
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",
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
const
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
},
|