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.esm.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
|
1820
|
-
const nameEnd =
|
|
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",
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
},
|