Skip to content

bug: import_check / import_apply never verify member sha256 against manifest — bundle integrity gate is bypassable #74

Description

@galuis116

What happened

bundle.import_check and bundle.import_apply never verify each
tar member's content against the per-file sha256 recorded in
manifest.json. The integrity guarantee that the README markets
("tar.gz + manifest.json with per-file sha256") is therefore only
enforced by export_check; the import side trusts whatever is
in the tarball as long as the path appears in the manifest and the
schema parses.

An attacker (or an accidental disk-corruption event) who substitutes
a tar member's bytes without re-signing the manifest can land
arbitrary content into a .vouch/ KB:

  • The audit log records a successful bundle.import event with
    the legitimate bundle_id — there is no trace that the bytes
    ever differed from the manifest.
  • For YAML/markdown artifacts the attacker only has to ship a
    payload that passes pydantic validation. Claim text,
    confidence, status, citations, page bodies, entity attributes,
    relation targets — all are attacker-controlled in a single
    shipped bundle.
  • For sources/<sha>/content files _validate_content skips
    validation entirely, so source content can be replaced with
    arbitrary bytes. Source.sha256 in meta.yaml remains the
    legitimate hash, so the resulting KB is internally inconsistent
    (vouch source verify would now fail), but the import itself
    succeeds.

This defeats the documented integrity story of vouch's portable
bundle format on every platform.

What you expected

vouch import-check and vouch import-apply reject any bundle
whose member bytes don't hash to the per-file sha256 in
manifest.json — the same check that vouch export-check already
performs. import_apply raises before writing a single byte when
the bundle has been tampered with.

Reproduction

$ python tamper_repro.py
work dir: /tmp/vouch-tamper-XXXXXXXX
original claims/c1.yaml sha256: bccff52f5cf3cf9e...
tampered claims/c1.yaml sha256: bb996b9aa4bd7058...

export_check(tampered): ok=False
  issues: ['hash mismatch: claims/c1.yaml']

import_check(tampered): ok=True
  issues: []

import_apply(tampered): wrote 4 files

landed c1.text: 'TAMPERED the sky is red'
landed claims/c1.yaml sha256: bb996b9aa4bd7058...
manifest sha256 for that path:  bccff52f5cf3cf9e...

BUG CONFIRMED: import_apply wrote attacker content; on-disk hash
does not match the manifest sha256.

tamper_repro.py exports a one-claim bundle, rewrites the tarball
so that claims/c1.yaml contains a different (but schema-valid)
claim body, leaves manifest.json byte-for-byte unchanged, and
imports into a fresh KB. The substituted text lands in the
durable artifact and the audit log records a clean
bundle.import event with the original bundle_id.

The asymmetry is at:

  • src/vouch/bundle.py:139export_check reads each member,
    hashes it, and reports hash mismatch: <name> on disagreement.
  • src/vouch/bundle.py:231import_check reads each member
    only to feed _validate_content (schema check), never hashes,
    never compares to manifest.
  • src/vouch/bundle.py:279import_apply reads each member
    and writes it after schema validation; the manifest sha256
    is loaded into recorded[...]["sha256"] but only consulted to
    decide whether the destination file conflicts under
    on_conflict="skip". It is never compared to the bundle
    member
    .

Environment

.vouch/ state

$ vouch doctor

Not relevant — the gap is in the import code path itself and
reproduces against a fresh .vouch/ initialised by the test.

Anything else

Suggested fix

In src/vouch/bundle.py, mirror the existing export_check
hash-verification logic into both import_check and import_apply:

  • import_check should, for every tar member that appears in the
    manifest, compare sha256_hex(member_bytes) to
    manifest_entry["sha256"] and append a hash mismatch: <name>
    issue on disagreement. (This makes the ok field honest.)
  • import_apply should re-verify the same hash immediately before
    dest.write_bytes(body) and skip-and-issue on disagreement —
    defence in depth against a TOCTOU between import_check and
    the re-open inside import_apply. Because import_apply already
    calls import_check and raises on any issue, the practical effect
    is that tampered bundles raise RuntimeError("refusing to import: hash mismatch: ...") before writing.

Why it's worth fixing

  • This is the only check that separates "the bundle vouch
    produced" from "any tar.gz with the same path layout." Without
    it, manifest.sha256 is decorative.
  • Same code area as the recently-merged CVE-2007-4559 path-
    traversal fix (Path Traversal in Bundle Import — Arbitrary File Write (Critical) #9 → PR fix: validate bundle content against Pydantic models before import (#13) #34); the team already invested in
    hardening this surface and this is the missing companion check.
  • Affects every platform and every host (CLI, MCP, JSONL) that
    calls kb.import_apply — not a platform-specific edge case.
  • The fix is small (one hash call per member in two functions),
    has zero impact on Linux/macOS/Windows bundles produced and
    imported on the same machine (the manifest hashes already
    match), and is testable end-to-end with the repro above.
  • Defensible audit story: after the fix, the audit log of a
    successful import is a true statement; today it is not.

Checked for duplicates

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions