| project | urna |
|---|---|
| audience | users and security researchers |
| status | active |
| last-updated | 2026-10-14 |
| domain | security |
urna is maintained by Hoff Research. Author: Brenner Cruvinel.
Only the latest minor on main is supported.
| Version | Status |
|---|---|
| 0.5.x | Supported (current) |
| 0.4.x and earlier | Not supported, please upgrade (the reader still opens 0.4.0 .nest files: LEGACY_MAGIC in rust/format/src/layout/mod.rs) |
Do not open a public GitHub issue for security vulnerabilities.
Use one of:
- Private vulnerability report: https://github.com/hoffresearch/urna/security/advisories/new
- Email: [email protected]
We aim to acknowledge within 72 hours and to publish a fix or mitigation within 14 days for confirmed reports. Coordinated disclosure preferred; we credit reporters who request it.
Things we treat as security bugs:
- Malformed
.urnafiles that trigger UB / OOB / panic in the Rust runtime - A citation collision (two distinct chunks producing the same
chunk_id) - A
content_hashcollision under the v1 hash domain separation - A path that bypasses
model_hashvalidation in a text query path (search-text,ask,retrieve) without the user passing an explicit skip flag;search-spacetakes a raw vector and validates only when--expect-model-hashis given - A path that executes model-repo code (
trust_remote_codepresets) without the explicit opt-in - the spec'sallow_remote_codeat build time,URNA_ALLOW_REMOTE_CODEon the query/bench/bridge side (presetqry.py,modelrank.py,uibackend.py) - or with a code file whose SHA256 is outside the pinned allowlist inrust/bridge/python/urna/model/presetmap.py - Secrets or credentials accidentally committed to the repository
Things we do not treat as security bugs:
- Low recall on a particular corpus
- HNSW recall under user expectation (configuration tuning, see
--ef) - BM25 tokenizer degrading on CJK / Thai / Lao (documented limitation)
- Compressed vs raw size differences
- Vulnerabilities in upstream sentence-transformers / HuggingFace stack; report those upstream first
- Weaknesses in the embedding model itself (false positives, biased recall)
- Configuration choices made by the operator (e.g. building a corpus with the placeholder
model_hashand using--skip-model-hash-check)
- The
.urnafile_hashandcontent_hash(urna stats <file>prints both) - The runtime
simd_backendand platform (urna stats) - The exact CLI or Python invocation
- A minimal reproducer if possible (a synthetic
.urnais fine, seerust/format/tests/fixtures/) - Whether you have a proposed mitigation
- The runtime (Rust) never opens a network socket. Queries are answered from
mmap. The default query embedders are offline too:ask/retrieveuse the vendored potion table (no network by construction), and thesearch-textsentence-transformers path forcesHF_HUB_OFFLINE/TRANSFORMERS_OFFLINEunless you opt in withURNA_ALLOW_DOWNLOAD=1(or pass--model-path). A registry query embedder (urna/embed/presetqry.py, behindaskandretrieveon a registry model) fetches only with the sameURNA_ALLOW_DOWNLOAD=1opt-in; an open_clip preset pinned to a hub revision (SigLIP2) then fetches exactly its listed files at that revision, and without the opt-in a missing file is an error naming it, never a lookup. The other place a model is fetched is the model install (urna setup --model, the explorer's install panel): only after an explicit confirmation, only the files the payload's catalog pins at a fixed hub revision, withURNA_ALLOW_DOWNLOAD=1set for that fetch child alone, and kept only when they fingerprint to the catalog'smodel_hash; a model that runs its repo's code needs a second, separate consent and its reviewed file hashes. Packages go only into the venvurna setupmanages. model_hashis a granular fingerprint over the local model snapshot (config + tokenizer + weights + pooling + dim + normalize); for an open_clip model it covers the loaded weights and the image preprocess instead, and the tokenizer is pinned by loading it from the same snapshot revision. A mismatch fails with a typed error, never silently. The CLI (search-text) enforces this; the PythonUrnaFile.retrievebinding acceptsexpected_model_hashand the flagshipurna/reads/retrieval.pypasses it by default, so the honesty gate holds on the Python surface too.unsafelives in the SIMD kernels (rust/engine/src/simd/) and the twommapcalls (rust/engine/src/mmap_file.rs,mmap_cold.rs); the former zero-copy casts inrust/format(header / footer / section-entry byte views, the int8 row view) are now safebytemuckcasts whose layout invariants the compiler checks, and the crate keeps zerounsafeby#![forbid(unsafe_code)], and the whole format crate runs under Miri nightly (gatecheck.ymljobmiri), so "no undefined behaviour" in the parsers is a run, not a claim. Every remainingunsafeblock carries a// SAFETY:comment naming the invariant, andclippy::undocumented_unsafe_blocksis denied workspace-wide so a new undocumented block fails the build. The safe SIMD dispatchers check every slice length withassert!(kept in release), so the raw-pointer kernels never run on a mismatched row even if a validation layer upstream regresses.- No
unwrap()orexpect()on a parse path:clippy::unwrap_usedandclippy::expect_usedare denied workspace-wide (tests exempt), little-endian field reads go throughurna_format::bytesand returnUnexpectedEof, every header-derived size (n * dim * width) is overflow-checked, every payload cursor bounds-checks asneed > remaining(neverpos + need > len, which wraps), every count read from the file is bounded against the remaining bytes before it sizes an allocation, and every f32 ranking sort is a NaN-last total order. - Fuzzing:
cargo testruns a deterministic mutation-fuzz harness on every push (rust/format/tests/mutation_fuzz.rs,rust/engine/tests/mutation_fuzz.rs: bit flips, byte sets, zero runs, integer specials, truncation, splices, half of them with checksums resealed so the corruption reaches the decoders), andfuzz/carries fourcargo-fuzztargets (reader, section codecs, index codecs, mmap open + every search verb) thatgatecheck.ymlsmoke-runs on nightly. The harnesses' first runs found and fixed five classes of bug: an uncheckedn * dimoverflow in the expected-section-size check, a NaN score reaching apartial_cmp-based sort (a panic since Rust 1.81) through an unvalidated multimodal band, a wrappingpos + ncursor bounds check, BM25 postings whose doc IDs were never checked againstn_docs, and (from the coverage-guided run) a URI-pool count in the intpack spans repack that reachedVec::with_capacityunbounded, so a 90-byte payload asked the allocator for 31 GB. Every count-driven allocation on a decode path is now bounded by what the bytes can hold before it happens. Those are exactly the bug classes this document declares in scope; a.urnathat panics or aborts the runtime is still a security bug, please report it. - Untrusted
.urnafiles: the header/section/footer checksums are unkeyed SHA-256 (corruption detection, NOT authenticity); an attacker can recompute them, sovalidate()does not prove a file is trustworthy. Safety against a hostile file rests on the parser's memory-safety (bounds-checked indices, capped decompression/allocation); opening an untrusted corpus still executes that parser, so treat unknown.urnafiles with the same care as any untrusted input. - Release provenance: commits are signed (SSH signing). Checksums: the five binary archives and the embedder payload carry a per-file
.sha256(the installers andurna setupcheck the payload's before unpacking), andsha256.sumlists the archives and the npm package; the SBOM and the Homebrew formula carry none. Attestations: the five archives carry a Sigstore keyless build attestation (gh attestation verify <archive> --repo hoffresearch/urna); the payload, the SBOM, the npm package and the formula carry none (gh release verify-assetonly proves they are the bytes GitHub stored). Release tags are annotated and SSH-signed and verified against.github/trustkeys(.github/workflows/tagverify.yml) before the host job releases anything or a publish job runs; the archive builds run beside that check. Every release ships one CycloneDX SBOM for the CLI package (cargo cyclonedx, not attested), the binaries embed their dependency tree (cargo auditable), every action in the workflows is pinned to a commit SHA (release.ymlthrough cargo-dist'sgithub-action-commitsinCargo.toml, whichrehearsal.ymlreads too), andcargo denygates advisories, licenses and sources in CI (deny.toml).Cargo.lockis committed so the Rust dependency set is pinned and auditable. Since 0.5.4 the release attests the four wheels and attaches them to the GitHub release with a.sha256each, and publishes those same files to PyPI with OIDC trusted publishing and PEP 740 attestations (maintainer checklist step 4 indocs/USAGE.md). 0.5.0 to 0.5.3 reached PyPI on a token and carry no PEP 740 provenance. Releases are immutable, so a release's third-party record is made around it: before the release is created,.github/workflows/linuxpack.ymlpacks the two musl archives as.deband.rpmpackages, listed inpackages.sha256.sum, and.github/workflows/stampwork.ymlmakes an OpenTimestamps proof ofsha256.sumand ofpackages.sha256.sum, published with the release assha256.sum.otsandpackages.sha256.sum.ots; a release without one, 0.5.4 and earlier, cannot gain it, and if fewer than two calendars answer, nothing is published until the failed jobs are re-run. To check it (pip install opentimestamps-client): the proof is pending for the few hours until the calendars' commitment reaches a Bitcoin block, andots upgrade sha256.sum.otscompletes it from the calendars at any later time; thenots verify sha256.sum.ots, beside the downloadedsha256.sum, checks it against a local Bitcoin Core node, and without oneots info sha256.sum.otsnames the block and its Merkle root, which any block explorer confirms. Once the release is announced,.github/workflows/archiving.ymlasks Software Heritage to save the repository, so the tagged tree is archived outside GitHub under its SWHID (swh:1:rel:followed by the tag's git object id). - Package signing keys: the apt repository at
urna.dev/aptis signed by a key kept for that index alone,CC29 E7F2 8CBC EF3E A20F D63C D148 4AF5 8C54 FBD9; the PPA's uploads are signed by another,A826 0485 3FB1 EF68 214E 44AC 5C07 0070 1E27 FCE0, and Launchpad signs the PPA itself with its own key. Neither signs a release or a commit, so a leaked CI secret of either channel signs no release. To check the apt keyring before trusting it:curl -fsSL https://urna.dev/apt/urna.gpg | gpg --show-keys --with-fingerprint, and compare the fingerprint with the one here. apt then refuses any index not signed by that key, and checks each.debagainst the SHA-256 the index lists. - Model registry remote code: presets that require
trust_remote_code(wemm, jina) load only with an explicitallow_remote_codeopt-in in the build spec AND matching pinned SHA256 allowlists for the model-repo code files. A hash identifies a version, it does not make it safe; review the pinned files before trusting a new pin, and prefer building in an isolated environment when the model directory is not fully trusted. Sentence-transformers models run in a per-model worker process.
A shipped .urna is an immutable, self-contained, content-addressed file meant to be copied around (phones, edge nodes, air-gapped boxes). When the embedded content is personal data that design has consequences; this section records the posture so it is auditable.
- A
.urnais a datastore, not a cache. It stores the canonical chunk text andsource_uriin cleartext plus the embeddings (a derived representation of that text). There is no at-rest encryption in the format;zstdis compression, not confidentiality. A file built over personal or sensitive data (a clinical corpus, say) is a copy of that data and needs the same controls as the source, encryption at rest included (FileVault / APFS / LUKS on any volume that holds it). For special-category data that is a required control, not a residual risk. - Erasure and rectification (LGPD art. 18, GDPR art. 16 and 17) cannot be honored by editing a distributed file in place. A citation is
urna://<content_hash>/<chunk_id>, changing any chunk changescontent_hashand invalidates every issued citation, and the runtime never opens a socket, so there is no callback channel to recall copies already shipped. Before shipping a.urnathat contains personal data: establish a lawful basis and run a DPIA (GDPR art. 35, LGPD art. 38), prefer anonymized or CC0 / permissively licensed corpora (the MIT-licenseddemo/corpora/introcorpus exists for this), define the revocation process up front (version the corpus, each build has its owncontent_hashandfile_hash; publish a revocation list; put an operational obligation on operators to re-pull the current build and destroy superseded copies; treat embeddings as in-scope derived personal data), and record consent and provenance for embedded third-party content. Do not distribute a.urnacontaining special-category data (health, etc.) without counsel confirming the immutable-distribution model is compatible with the applicable data-subject rights. - Provenance and build integrity are compliance assets: reproducible builds (
reproducible=True+ same chunks + same model fingerprint + same urna version = byte-identicalfile_hashon any machine; the manifest'sproducernames that version), the four SHA-256 checks (an 8-byte header checksum, an 8-byte checksum per section over its physical bytes,file_hashover the whole file, andcontent_hashover the decoded canonical sections, stable across encodings), and themodel_hashfingerprint that fails the honesty gate when a query is embedded by a different model. Declarative builds add the versioned build manifest (manifest_schema_version, canonical serialization, provenance redaction modes minimal / standard / full) andbuild.lock.json(package versions, tool binary hashes, model hashes, the materialized spec); a byte-identical rebuild claim is only valid under a matching lock. The on-disk checksums are unkeyed SHA-256: integrity, not authenticity (see the hardening notes above).