Guard Archive

Archive content-safety guard — refuses hostile archive metadata BEFORE files touch the filesystem. Validates an operator-supplied entry list (the framework ships no pure-JS unzip / untar parser per the no-deps rule) plus an optional magic-byte inspection on raw bytes. Operators enumerate entries via their archive library (built-in zlib, OS tar / unzip CLI, vendored mupdf-of-archives) and pass [{ name, size, compressedSize, isSymlink, isHardlink, linkTarget, isDirectory, isEncrypted, attrs }, ...] to validateEntries.

Zip-slip / path-traversal: entry names containing .. segments, leading / or \\, or Windows drive-letter prefixes (C:\\) are refused under every profile. Composes b.guardFilename for the full leaf-safety catalog (null-byte, Windows reserved names, NTFS ADS, RTLO bidi, overlong UTF-8, shell-exec extensions, double- extension). Tracks the 2025-2026 CVE class: CVE-2025-3445 (mholt/archiver), CVE-2025-32779 (EDDI), CVE-2025-62156 (Argo Workflows), CVE-2025-66945 (Zdir Pro), CVE-2025-45582 (GNU Tar two-step symlink bypass), CVE-2025-11001 / 11002 (7-Zip RCE), CVE-2025-4138 / 4517 (Python tarfile), CVE-2025-10854 (txtai), CVE-2025-12060 (Keras), CVE-2026-26960 (node-tar hardlink-via- symlink chain).

Symlink / hardlink escape: entries whose linkTarget contains .. or is absolute are refused. strict rejects symlinks AND hardlinks outright; balanced permits in-root symlinks and rejects hardlinks (CVE-2026-26960 class); permissive audits both.

Decompression amplification: per-entry compressedSize/size ratio cap defaults 100:1 (strict) / 100:1 (balanced) / 1000:1 (permissive). Aggregate ratio across all entries also capped (maxAggregateRatio). Entry-count cap (maxEntries), per-entry size cap (maxEntryBytes), total uncompressed cap (maxTotalBytes).

NTFS ADS, overlong UTF-8, leaf-bidi: routed through b.guardFilename on every entry name with pathSeparatorsPolicy: "allow" (archive entries legitimately use / as separator).

Nested archives: entries with archive extensions (.zip, .tar.gz, .7z, .rar, .zst, ...) refused under strict (maxNestedDepth: 0); audited under balanced (depth 2) / permissive (depth 4) so the operator can recurse.

Duplicate-name + case-insensitive collision detection — the second entry with the same name silently overwrites on extraction (refused); case-insensitive collisions on Windows / HFS+ / APFS-non-case- sensitive volumes (audited / refused per profile).

inspectMagic(buffer) returns { format, magic } for ZIP / GZIP / BZIP2 / XZ / 7Z / RAR4 / RAR5 / LZMA / ZSTD / TAR (the latter via the "ustar" magic at offset 257). checkExtractionPath(name, root) provides a single-entry boolean for callers that already enumerate.

Profiles strict / balanced / permissive and compliance postures hipaa / pci-dss / gdpr / soc2 overlay on the profile baseline.

b.guardArchive.inspectMagic(buffer) #

stable0.7.8

Read the first bytes of buffer and return { format, magic } when the buffer matches a known archive-format signature (zip / gzip / bzip2 / xz / 7z / rar4 / rar5 / lzma / zstd / tar). TAR is detected via the "ustar" magic at offset 257 within the first 512-byte header block. Returns null on unrecognized input or non-Buffer / empty input. Pure inspection — never mutates the buffer or throws.

Operators compare the detected format against the declared Content-Type / extension to surface format-claim mismatches before routing the bytes to a parser.

var zipBytes = Buffer.from([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00]);
var hit = b.guardArchive.inspectMagic(zipBytes);
hit.format;                                         // → "zip"

var noise = Buffer.from([0x00, 0x01, 0x02, 0x03]);
b.guardArchive.inspectMagic(noise);                 // → null

b.guardArchive.checkExtractionPath(entryName, extractionRoot) #

stable0.7.8

Single-entry boolean check: returns { ok, reason } for a candidate (entryName, extractionRoot) pair. Refuses entries whose name contains a .. component (zip slip — CVE-2025-3445 class), is an absolute path (leading /, \\, or C:\\ drive-letter prefix), carries a null byte, or is empty. The framework cannot resolve path.resolve(extractionRoot, entryName) without a node:path coupling that the gate keeps portable; the operator's extraction code is expected to additionally call path.resolve and confirm the result starts with path.resolve(extractionRoot).

Use when the operator already enumerates archive entries and wants a per-call boolean rather than running the full validateEntries issue list.

b.guardArchive.checkExtractionPath("docs/readme.txt", "/var/extract").ok;
//                                                   → true

var bad = b.guardArchive.checkExtractionPath("../etc/passwd", "/var/extract");
bad.ok;                                              // → false
bad.reason;                                          // → "entry name contains .. component (zip slip)"

b.guardArchive.validateEntries(entries, opts) #

stable0.7.8hipaapci-dssgdprsoc2
{
  profile:    "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  traversalPolicy:                "reject"|"audit"|"allow",
  absolutePathPolicy:             "reject"|"audit"|"allow",
  symlinkPolicy:                  "reject"|"audit"|"allow",
  hardlinkPolicy:                 "reject"|"audit"|"allow",
  nestedArchivePolicy:            "reject"|"audit"|"allow",
  duplicateNamePolicy:            "reject"|"audit"|"allow",
  caseInsensitiveCollisionPolicy: "reject"|"audit"|"allow",
  encryptionPolicy:               "reject"|"audit"|"allow",
  sparseEntryPolicy:              "reject"|"audit"|"allow",
  filenameProfile:                "balanced"|"strict"|"permissive",
  maxEntries:           number,   // strict 100, balanced 10000, permissive 100000
  maxTotalBytes:        number,   // strict 100 MiB, balanced 1 GiB, permissive 10 GiB
  maxEntryBytes:        number,   // strict 50 MiB, balanced 500 MiB, permissive 2 GiB
  maxCompressionRatio:  number,   // strict / balanced 100, permissive 1000
  maxAggregateRatio:    number,   // strict 200, balanced 1000, permissive 10000
  maxNestedDepth:       number,   // strict 0, balanced 2, permissive 4
}

Inspect an operator-supplied entries array (one entry per archive member: { name, size, compressedSize, isSymlink, isHardlink, linkTarget, isDirectory, isEncrypted, attrs }) and return { ok, issues }. Issues carry { kind, severity, ruleId, location, snippet } with severity "warn" / "high" / "critical". Detected: zip-slip, absolute path, symlink / hardlink escape, compression-ratio bombs (per-entry + aggregate), per-entry size cap, total-size cap, entry-count cap, nested-archive entries, duplicate names, case-insensitive collisions, encryption-claim mismatch, sparse-tar entries, plus the full b.guardFilename leaf-safety catalog re-attached with archive-context locations. Pure inspection — never mutates input or throws on hostile entries.

var rv = b.guardArchive.validateEntries([
  { name: "docs/readme.txt", size: 1000, compressedSize: 500 },
  { name: "../etc/passwd",   size: 100,  compressedSize: 50 },
], { profile: "strict" });
rv.ok;                                               // → false
rv.issues[0].kind;                                   // → "zip-slip"
rv.issues[0].severity;                               // → "critical"

// Compression-ratio bomb — 50 MiB uncompressed from 50 KiB compressed
// is 1000:1, far above the 100:1 strict cap.
var bomb = b.guardArchive.validateEntries([
  { name: "bomb.bin", size: 52428800, compressedSize: 51200 },
], { profile: "strict" });
bomb.issues.some(function (i) { return i.kind === "compression-ratio-bomb"; });
//                                                   → true

b.guardArchive.gate(opts) #

stable0.7.8hipaapci-dssgdprsoc2
{
  profile:    "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  name:       string,
  ...:        any validateEntries opt
}

Build a b.gateContract gate suitable for b.fileUpload({ contentSafety: { "application/zip": gate } }) or b.staticServe. Operators pass ctx.entries (the enumerated entry list from their archive library) — when only ctx.bytes is supplied, the gate runs inspectMagic to confirm the format and refuses with a "no-entry-list" issue directing the operator to enumerate entries explicitly (the framework ships no parser for any archive format).

Action chain: serve (no issues) → audit-only (warn-only) → refuse (any critical/high). Archive content has no safe sanitization — there is no sanitize action in the chain.

var archiveGate = b.guardArchive.gate({ profile: "strict" });

var verdict = await archiveGate.check({
  entries: [
    { name: "docs/readme.txt", size: 1000, compressedSize: 500 },
    { name: "../etc/passwd",   size: 100,  compressedSize: 50 },
  ],
});
verdict.action;                                      // → "refuse"

// Bytes-only call without an entry list — operator must enumerate.
var zipBytes = Buffer.from([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00]);
var v2 = await archiveGate.check({ bytes: zipBytes });
v2.action;                                           // → "refuse"
v2.issues[0].kind;                                   // → "no-entry-list"

b.guardArchive.buildProfile(opts) #

stable0.7.8
{
  extends: "strict"|"balanced"|"permissive",   // base profile name(s)
}

Resolve a named profile against the guard's PROFILES catalog and return the merged options bag. Operators introspecting the active caps (without calling validateEntries / gate) use this. Throws GuardArchiveError("archive.bad-profile") on unknown name.

var resolved = b.guardArchive.buildProfile({ extends: "strict" });
resolved.maxEntries;                                 // → 100
resolved.symlinkPolicy;                              // → "reject"
resolved.maxCompressionRatio;                        // → 100

b.guardArchive.compliancePosture(name) #

stable0.7.8hipaapci-dssgdprsoc2

Return the option overlay for a named compliance posture ("hipaa" / "pci-dss" / "gdpr" / "soc2"). Composes over a base profile to harden defaults per regulatory regime. Throws GuardArchiveError("archive.bad-posture") on unknown name.

var posture = b.guardArchive.compliancePosture("hipaa");
posture.symlinkPolicy;                               // → "reject"
posture.hardlinkPolicy;                              // → "reject"
posture.forensicSnippetBytes;                        // → 256

b.guardArchive.loadRulePack(pack) #

stable0.7.8

Register an operator-supplied rule pack with the guard-archive registry. The pack is identified by pack.id (non-empty string) and stored for later inspection / dispatch by gates that opt in via opts.rulePackId. Returns the pack object unchanged on success; throws GuardArchiveError("archive.bad-opt") when pack is missing or pack.id is not a non-empty string.

var pack = b.guardArchive.loadRulePack({
  id: "kb-2026-archive",
  extraReservedNames: ["system32"],
  rules: [
    { id: "no-windows-system", severity: "critical",
      reason: "entry name targets Windows system directory" },
  ],
});
pack.id;                                             // → "kb-2026-archive"

b.guardArchive.inspect(adapter, opts?) #

stable0.12.7
{
  profile:        "strict" | "balanced" | "permissive" | "hipaa" | ...,
  format:         "zip" (v0.12.7 — tar v0.12.8, gz v0.12.9),
  audit:          b.audit,
}

Bridge primitive: runs b.archive.read.zip(adapter).inspect() to enumerate the entry list (no decompression), then hands the list to validateEntries for the full posture-aware gate. Returns { entries, issues, decisions } so the caller decides whether to proceed.

Operators using the lower-level read primitive directly call this to combine the metadata pass with the guard pass; b.safeArchive. extract does the same composition inline under the hood.

var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
var summary = await b.guardArchive.inspect(adapter, { profile: "strict" });
if (summary.issues.length > 0) refuse(summary.issues);

b.guardArchive.zipBombPolicy(opts) #

stable0.12.7
{
  maxEntries:                65535,
  maxEntryDecompressedBytes: 128 * MiB,
  maxTotalDecompressedBytes: 4 * GiB,
  maxExpansionRatio:         100,
}

Policy-object builder for decompression-bomb caps. Operators declare the cap set once + reuse it across b.archive.read.zip / b.safeArchive.extract call sites. Defaults match the cap shape in lib/archive-read.js DEFAULT_BOMB_POLICY.

var policy = b.guardArchive.zipBombPolicy({
  maxTotalDecompressedBytes: 256 * 1024 * 1024,
  maxExpansionRatio: 50,
});
await b.safeArchive.extract({ source, destination, bombPolicy: policy });

b.guardArchive.entryTypePolicy(opts) #

stable0.12.7
{
  symlinks:   false,
  hardlinks:  false,
  devices:    false,
  fifos:      false,
  sockets:    false,
}

Policy-object builder for entry-type allowlist. Defaults refuse every "interesting" entry type (symlink / hardlink / device / fifo / socket); operators opt in per-type and route through the additional realpath-on-target check in b.guardFilename. verifyExtractionPath.

Symlinks + hardlinks under default settings are refused unconditionally — CVE-2025-11001 / 11002 / 26960 class.

var policy = b.guardArchive.entryTypePolicy({ symlinks: true });
await b.safeArchive.extract({ source, destination, entryTypePolicy: policy });

b.guardArchive.tarEntryPolicy(opts) #

stable0.12.8
{
  symlinks:   false,
  hardlinks:  false,
  devices:    false,
  fifos:      false,
  sockets:    false,
}

Tar-specific entry-type policy. Same shape as entryTypePolicy but explicitly named for tar's typeflag vocabulary (1=hardlink, 2=symlink, 3=char-device, 4=block-device, 6=FIFO, 7=contiguous- file) so call sites read clearly when the operator's intent is tar-specific. Defaults refuse every dangerous typeflag. Operators opting symlinks / hardlinks in get the link target routed through b.guardFilename.verifyExtractionPath's realpath-on-target check (defends CVE-2026-23745 / 24842 node-tar path-resolution divergence class).

var policy = b.guardArchive.tarEntryPolicy({ symlinks: true });
await b.safeArchive.extract({
  source, destination, entryTypePolicy: policy,
  allowDangerous: { symlinks: true },
});

Last updated 2026-08-08T16:39:49.652Z by seeder.