Please report suspected vulnerabilities privately via GitHub Security Advisories: https://github.com/Nizoka/zipnative/security/advisories/new (Security → Report a vulnerability on the repository). Do not open a public issue for security reports. You should receive an acknowledgement within 48 hours and a fix or a documented mitigation within 7 days for a critical issue, 14 days otherwise; the advisory is published with the fix release and credits the reporter unless they prefer otherwise.
| Version | Supported |
|---|---|
| 1.1.x | ✅ |
| 1.0.x | ✅ (security fixes) |
| < 1.0 (git tags, never published to npm) | ❌ |
The latest published minor receives every fix; the previous minor (1.0.x) receives security fixes for six months after 1.1.0's publication, until 2027-04-08.
Packages are published from CI via npm Trusted Publishing (OIDC) with
provenance attestations — verify with npm audit signatures. A CycloneDX
SBOM is attached to every GitHub Release. Nobody publishes from a laptop
(publishConfig.provenance makes a local npm publish fail).
Every workflow runs under step-security/harden-runner (egress audited),
every action is pinned to a commit SHA (Dependabot owns the bumps),
dependencies install with npm ci --ignore-scripts (.npmrc makes it the
default locally too), pull requests pass dependency review with a
licence allow-list, and a weekly npm audit runs on the lockfile.
Publishing is three jobs with one artifact between them. verify holds no
publishing token: it fetches the previous release tag, runs the full
publish gate, packs the tarball once with its CycloneDX SBOM and a
SHA256SUMS file, and hands them on. publish runs in the npm-publish
environment behind a required reviewer, checks out .nvmrc and nothing
else, verifies the tarball's digest against the job outputs, and publishes
that file with a pinned npm through OIDC with provenance. attest downloads
the same artifact, compares it byte for byte with what the registry serves,
then generates SLSA Build L2 provenance for the published tarball and the
SBOM (actions/attest-build-provenance) and attaches both to the GitHub
Release. No release job restores a dependency cache; a workflow_dispatch
dry run exercises everything up to npm publish --dry-run. Branch and tag
rulesets are committed under .github/rulesets/. The engine itself has no
runtime dependencies, so the published artefact's transitive closure is empty.
- The public API surface (docs/assets/api.json) is frozen: removals and behavioral breaks are semver-major.
- The 39-code error vocabulary (docs/data/errors.json)
is frozen: removing or renaming a
err.codeis semver-major; additions are semver-minor. - The
deterministic: trueoutput bytes are part of the contract: changing them is semver-major (ecosystem.json → deterministic_bytes_are_semver_major); since 1.1 the canonical bytes ofcanonicalizeZip()/saveCompact({ canonical: true })are under the same contract. - Minor releases are additive: 1.1.0 grew the public surface from 77 to
106 exports and removed none, every new behaviour is opt-in, and the byte
baseline of the 33 pre-existing samples (
npm run verify:samples) proves existing output unchanged; thecompat-previousCI job runs the previous release's own test suite against every change. Each release note carries a compatibility ledger listing every observable change.
zipnative treats every archive as untrusted input. The engine is designed so that the default code path is the safe one.
ZIP is an attacker-friendly format: it is parsed from the end, tolerates leading and trailing garbage, duplicates its metadata (central directory vs local headers), and mixes 16/32/64-bit size fields. zipnative defends against:
| Threat | Defence | CWE |
|---|---|---|
Zip-slip path traversal (../, absolute paths, drive letters, backslashes, NUL, NTFS ADS, Windows reserved device names — CON/NUL/COM1…) |
rejectTraversal: true by default; sanitizeEntryPath() exported for external sinks |
CWE-22 / CWE-67 |
| Decompression bombs (high ratio, nesting, entry floods) | per-entry and total output caps, compression-ratio bound, entry-count cap — all enforced during inflation, not after | CWE-400 / CWE-409 |
| Symlink entries redirecting extraction | rejectSymlinks: true by default |
CWE-59 |
| Overlapping entries (one payload claimed by many entries) | always-on overlap detection over central-directory ranges | CWE-405 |
| Parser-differential smuggling (central directory disagreeing with local headers) | central directory is authoritative; method/size/CRC divergence is fatal, name divergence is diagnosed | CWE-436 |
| Ambiguous EOCD (trailing garbage, multiple candidate records) | only a self-consistent EOCD closest to EOF is accepted; ambiguity is refused, never guessed | — |
| Zip64 field spoofing (sentinel values masking lying 64-bit fields) | Zip64 records cross-checked against every non-sentinel classic field; divergence is fatal | CWE-1288 |
| Duplicate entry names (shadowing during extraction) | onDuplicate: 'error' by default |
CWE-694 |
| Integer overflow (> 2^53 sizes/offsets) | 64-bit fields read via BigInt and rejected above Number.MAX_SAFE_INTEGER |
CWE-190 |
| Unbounded allocation for a compressed payload fetched from a byte-range source (1.1) | maxEntryCompressedSize (default 1 GiB + 1 MiB) bounds openZipRange()'s whole-payload reads; the in-memory reader uses zero-copy views and never consults it |
CWE-770 |
Lying byte-range source (understated size, short reads) |
every record cross-checked as under openZip(); short reads and size lies are ZIP_RECORD_TRUNCATED; the central directory is fetched only after maxCentralDirectoryBytes is checked |
CWE-20 |
| Traversal smuggled through an injected name decoder (1.1) | path sanitisation runs on the decoded string; a decoder cannot bypass sanitizeEntryPath() |
CWE-22 |
The bounds themselves (ZipLimits, defaults in DEFAULT_ZIP_LIMITS,
overridden per call with limits: { … }):
| Limit | Default | Bounds | CWE |
|---|---|---|---|
maxEntries |
100 000 | entries in the central directory and in a forward walk | CWE-400 |
maxEntryUncompressedSize |
1 GiB | decompressed bytes of one entry, counted during inflation on every tier | CWE-400 |
maxEntryCompressedSize |
1 GiB + 1 MiB | one compressed payload fetched whole by openZipRange() (the in-memory reader uses zero-copy views and never consults it) — 1.1 |
CWE-770 |
maxTotalUncompressedSize |
8 GiB | decompressed bytes of one extraction | CWE-400 |
maxCompressionRatio |
1024 | declared uncompressed ÷ compressed size, for entries of 1 KiB and more compressed | CWE-409 |
maxNameBytes |
4 096 | one entry name | CWE-400 |
maxExtraFieldBytes |
65 535 | one extra-field block | CWE-400 |
maxCommentBytes |
65 535 | the archive comment or one entry comment | CWE-400 |
maxCentralDirectoryBytes |
256 MiB | the central directory, checked before it is read or fetched | CWE-400 |
Every bound is named, documented on ZipLimits, and caller-configurable —
raising a limit is always an explicit decision, never a silent default.
Every refusal above is thrown with a stable machine-readable error code
(v0.8+, e.g. ZIP_PATH_TRAVERSAL, ZIP_ENTRY_OVERLAP,
ZIP_LIMIT_EXCEEDED) — the frozen vocabulary lives in
docs/data/errors.json and is documented in
docs/guides/errors.md.
- Zero runtime dependencies — the supply chain is this repository.
- No
eval,Function(), or dynamic code execution (enforced by ESLint). - The engine never opens a socket and never touches the filesystem.
- No module-level side effects; all state lives in closure factories.
- CI runs CodeQL, OpenSSF Scorecard,
npm audit, and an adversarial fuzzing suite (truncation, corruption, bombs, encoding tricks) on every push, on Linux, Windows and macOS. - Every archive zipnative writes is validated clause by clause against
ISO/IEC 21320-1:2015 by an engine-independent validator
(
npm run validate:zip), blocking in CI and before every publish — see the conformance guide. Conformance is not safety: hostile-but-spec-valid archives (zip-slip et al.) are exactly why the guards above exist.
- Encrypted archives are not supported (read or write) in 1.x.
ZipCrypto is cryptographically broken; supporting it would create false
confidence. Encrypted entries are detected and fail with a typed error
whose
featurenames the scheme —'zipcrypto','aes'(WinZip AES, labelled since 1.1, a 2.0 candidate) or'strong-encryption'. removeEntry+ incrementalsave()does not erase content (v0.4+): the append-only save model keeps original bytes verbatim, so removed entries remain recoverable from the file.saveCompact()is the true deletion path. This is documented loudly on the API.- The forward streaming reader (
iterateZipEntries) trusts local headers alone — there is no central directory to cross-check names, sizes or methods, so a hostile archive can present different content there thanopenZip()authoritatively reports (the upload-scanner differential, CWE-436 adjacent). All size limits are enforced by output counting and CRCs are verified, but treat forward-read metadata as unverified: use it only for streams you cannot seek, and never feed its names to a filesystem withoutsanitizeEntryPath().
Confirmed vulnerabilities are fixed in a patch release with a GitHub Security Advisory and a CHANGELOG entry crediting the reporter (unless anonymity is requested).