Reports which DuckDB extensions a project loads, whether unsigned extension loading is enabled, and — as far as the bytes on disk allow — where each extension actually came from.
$ duckdb-extension-audit --db warehouse.db
INFO warehouse.db: allow_unsigned_extensions is connection-time config, not part of the database file — it cannot be audited by opening warehouse.db from here. Run --scan against the source of whatever connects to it.
INFO warehouse.db: loaded, origin=unknown, version=v1.5.5, install_mode=STATICALLY_LINKED [core_functions]
INFO warehouse.db: installed (not loaded in this session), origin=community, version=v1.5.5, install_mode=REPOSITORY [h3]
INFO warehouse.db: installed (not loaded in this session), origin=core, version=827222f, install_mode=REPOSITORY [httpfs]
INFO warehouse.db: loaded, origin=unknown, version=v1.5.5, install_mode=STATICALLY_LINKED [icu]
INFO warehouse.db: loaded, origin=unknown, version=v1.5.5, install_mode=STATICALLY_LINKED [json]
INFO warehouse.db: loaded, origin=unknown, version=v1.5.5, install_mode=STATICALLY_LINKED [parquet]
0 critical, 0 warning, 7 info
That's real, unedited output (see "Ground truth" below) against a scratch
database that had httpfs LOADed and h3 (a community extension)
installed, in the exact script that built the file. Notice httpfs still
reports not loaded — that script's own connection loaded it, then
closed; this audit opened a new one, and "loaded" is per-connection session
state, not something the file remembers. That's not a bug in this tool, it's
DuckDB's real behavior — see "the one real surprise" below. --scan is
where allow_unsigned_extensions actually gets checked — unconditionally,
regardless of any allowlist, because it's the one setting that turns every
other finding irrelevant. --db mode can't check it at all, and says so
instead of pretending to.
DuckDB loads extensions — httpfs, spatial, iceberg, plus community
ones — and LOAD runs native code in your process, same trust level as a
compiled dependency. DuckDB signs its official and community extensions and
checks that signature by default; allow_unsigned_extensions turns the check
off entirely. Nothing scans a project for which extensions it depends on,
whether they're signed, or whether that flag is set, the way pip-audit or
npm audit do for packages.
DuckDB's own metadata for this was investigated directly — install DuckDB in a scratch venv, install real extensions, and read what it actually reports — rather than assumed. What follows is exactly what was found, on DuckDB 1.5.5, Python 3.14.7, macOS arm64.
The columns are extension_name, loaded, installed, install_path,
description, aliases, extension_version, install_mode,
installed_from. There is no boolean for "is this signed." install_mode
takes values like STATICALLY_LINKED (compiled into the DuckDB binary
itself — json, parquet, icu, core_functions on this build),
REPOSITORY (downloaded and installed separately), NOT_INSTALLED, and
CUSTOM_PATH (seen when INSTALLing directly from a local file path
instead of a repository name — installed_from for one of these is that
local path itself, not core/community).
installed_from is either core, community, that local path, or empty —
this is the field that answers "core or community," directly, no inference
needed, for the ordinary case:
('httpfs', True, True, '/Users/…/.duckdb/extensions/v1.5.5/osx_arm64/httpfs.duckdb_extension',
'Adds support for reading and writing files over a HTTP(S) connection',
['http', 'https', 's3'], '827222f', 'REPOSITORY', 'core')
('h3', True, True, '/Users/…/.duckdb/extensions/v1.5.5/osx_arm64/h3.duckdb_extension',
'', [], 'v1.5.5', 'REPOSITORY', 'community')
So "is it signed" has to come from somewhere else.
A byte flipped inside a validly-installed httpfs.duckdb_extension, then
LOADed with default settings:
IOException: IO Error: Extension "…/httpfs.duckdb_extension" could not be
loaded because its signature is either missing or invalid and unsigned
extensions are disabled by configuration (allow_unsigned_extensions)
The same tampered file, same connection settings except
allow_unsigned_extensions=true at connect time: it loads without complaint.
Two things about that setting turned out to matter for how this tool works:
-
It cannot be toggled by a running SQL script.
SET allow_unsigned_extensions=trueon an open connection fails withInvalid Input Error: Cannot change allow_unsigned_extensions setting while database is running— tested as literally the first statement on a fresh connection, not just mid-session. It is only settable at connect time, throughduckdb.connect(config={...})in Python, or (per DuckDB's own docs — not independently tested here, since theduckdbPyPI wheel ships no standalone CLI binary to test against) the-unsignedCLI flag. That's good news for static scanning: the setting has to appear at a connection's construction, not somewhere it could be assembled dynamically at runtime from a script--scancan't see. -
A tampered file loaded from an arbitrary path doesn't show up as tampered in
duckdb_extensions(). Loading a corrupted copy ofhttpfsfrom a throwaway directory (withallow_unsigned_extensions=true, so it succeeds) and then queryingduckdb_extensions()still reports theinstall_pathof the original, untouched, properly-installed copy — it does not point at the file that was actually mapped into the process.duckdb_extensions()answers "what's installed on this machine for this extension name," not "what file did this session actually load." This is the sharpest limitation--dbmode has, and it's stated plainly in "What it does not do." -
It is not persisted in the database file at all. A database created by a connection with
allow_unsigned_extensions=truereportsfalsethe moment any other, default connection reopens that same file:con = duckdb.connect("warehouse.db", config={"allow_unsigned_extensions": True}) con.close() duckdb.connect("warehouse.db").execute( "select value from duckdb_settings() where name='allow_unsigned_extensions'" ).fetchall() # -> [('false',)]
This sank the original plan to have
--dbmode report this setting: an audit connection can only ever see its own default, never what the actual application managing that database connects with.--dbmode reports this and moves on instead of printing a number that's always the same regardless of the database it's pointed at.--scanis where this setting is genuinely checked, because it lives in the connecting code, not the data file.
Every .duckdb_extension file DuckDB installs ends in a footer: the ASCII
magic duckdb_signature, a handful of fixed-size null-padded metadata
fields, then the file's last 256 bytes, which is an RSA signature. Both
httpfs (core) and h3 (community) had that trailing 256 bytes fully
non-zero — signed, on this install. They differ in one metadata field:
httpfs reports ABI type CPP, h3 reports C_STRUCT (DuckDB's C
extension API, which is what the community-extensions build pipeline
targets) — a corroborating signal, though installed_from / the .info
sidecar's URL is the real origin source of truth, not this field.
… duckdb_signature · · · CPP 827222f v1.5.5 osx_arm64 4 <256-byte RSA sig>
… duckdb_signature · · · C_STRUCT v1.5.5 v1.2.0 osx_arm64 4 <256-byte RSA sig>
(httpfs, core) (h3, community)
This footer format is not a public spec — it was reverse-engineered by
inspecting real files, the same spirit as wheelfloor parsing ELF without
pyelftools. Unlike ELF, there's no independent second parser (objdump,
readelf) to cross-check a private, undocumented binary format against, so
this tool does the minimum defensible thing with it: treat the last 256
bytes as "the signature" and report whether it's present (non-zero) or not,
never claim to have cryptographically verified it (that needs DuckDB's
embedded RSA public keys, which are not something to reimplement or
hardcode from outside DuckDB), and extract metadata by scanning for
printable ASCII runs rather than hard-coded byte offsets, since the two
samples checked already disagree on what a given offset means depending on
ABI type.
Alongside httpfs.duckdb_extension, DuckDB writes
httpfs.duckdb_extension.info — a small separate binary record. It's not
JSON and not documented, but it contains the literal download URL in plain
ASCII:
http://extensions.duckdb.org/v1.5.5/osx_arm64/httpfs.duckdb_extension.gz
versus, for h3:
http://community-extensions.duckdb.org/v1.5.5/osx_arm64/h3.duckdb_extension.gz
extensions.duckdb.org vs. community-extensions.duckdb.org in that URL is
exactly the fact installed_from reports when you can open the database —
except this is readable from a plain extension-cache directory on disk, with
no DuckDB process at all. That's what makes auditing a Docker image's baked
extension cache possible without ever running the image.
Install and LOAD spatial once, close the connection. Reopen a fresh
connection to the same database file and, without ever calling LOAD
again, run a query that calls a spatial function:
con.execute("select ST_AsText(geom) from pts").fetchall()
# -> [('POINT (1 2)',)] -- it ran. the extension executed.
con.execute("select loaded from duckdb_extensions() where extension_name='spatial'").fetchall()
# -> [(False,)] -- duckdb_extensions() disagreesReproduced across both the .sql() and .execute() client APIs, and across
multiple fresh connections. DuckDB's autoload machinery clearly loaded
enough of spatial to run the query — the result is correct — but the
metadata table --db mode relies on did not reflect it. A --db audit run
immediately after a query like that can under-report what actually executed
in that session. This is stated as a limitation, not silently worked around:
there is no reliable way from outside DuckDB's C++ internals to force
loaded to be accurate here.
Enough is exposed to build something genuinely useful: installed_from and
install_mode from a live connection, the .info sidecar's URL and the
footer's signature-presence from a cold filesystem, and a hard, tested fact
about where allow_unsigned_extensions can and can't be set that makes
static scanning meaningful rather than hopeful. The edges above are real,
not hidden, and shape what "What it does not do" says below.
pip install duckdb-extension-auditStandard library only for --scan and --extension-dir. --db needs a
running DuckDB to ask, so it needs the duckdb package — installed with:
pip install 'duckdb-extension-audit[live]'--db without it fails fast with that install instruction, not an
ImportError traceback.
# Connect (read-only) to a real database and report on its extensions
duckdb-extension-audit --db warehouse.db
# Scan a codebase for LOAD/INSTALL statements and unsigned-extension config,
# in .sql files and in Python source (duckdb.connect(config=...), and string
# literals passed to .execute()/.sql()/.query()/.run()) — no DuckDB involved
duckdb-extension-audit --scan ./src
# Inspect an extension cache directory on disk (e.g. one baked into a Docker
# image) — also no DuckDB involved
duckdb-extension-audit --extension-dir ~/.duckdb/extensions
# Fail CI on anything not sanctioned
duckdb-extension-audit --scan ./src --allowlist extensions.toml --jsonReal output (examples/demo.py) against a script that both flips
allow_unsigned_extensions on and LOADs a file by path:
$ duckdb-extension-audit --scan ./src
CRITICAL src/app.py:3: duckdb.connect(config={'allow_unsigned_extensions': True}) found — this disables DuckDB's signature check entirely
WARNING src/app.py:4: LOAD of an explicit file path ('/opt/custom/httpfs.duckdb_extension') found — this bypasses the named extension directory, so origin and installed_from cannot be confirmed statically; whatever file is at that path at run time is what actually loads [httpfs]
1 critical, 1 warning, 0 info
$ echo $?
1
Exit code is 0 if nothing CRITICAL was found, 1 if something was (an
unsanctioned extension, or allow_unsigned_extensions enabled anywhere), 2
on a usage error (bad path, missing duckdb package for --db, malformed
allowlist).
# extensions.toml
[duckdb-extension-audit]
allowed_extensions = ["httpfs", "parquet", "json"]Anything loaded, installed, or referenced that isn't on this list is a
CRITICAL finding. Extensions DuckDB reports as STATICALLY_LINKED (compiled
into the duckdb binary itself — no separate signed artifact, no repository
origin) are excluded from allowlist enforcement; flagging json on every
single run because it wasn't listed is noise about trusting DuckDB itself,
not about a loaded third-party extension.
allow_unsigned_extensions being enabled — found via --scan, the only mode
that can see it, since it's connection-time config rather than anything a
database file or extension cache carries — is always CRITICAL, allowlist
or not. There's no way to sanction disabling the entire signature check for
one team and not another.
| Mode | Needs | Sees |
|---|---|---|
--db PATH |
duckdb (the live extra) |
What DuckDB itself currently reports: installed/loaded extensions and their origin (not allow_unsigned_extensions — see "Ground truth") |
--scan PATH |
nothing | LOAD/INSTALL statements and allow_unsigned_extensions settings in .sql and .py source, before anything runs |
--extension-dir PATH |
nothing | Origin (from .info sidecars) and signature-blob presence (from .duckdb_extension footers) of whatever's on disk |
- It does not cryptographically verify a signature. The 256-byte blob at
the end of a
.duckdb_extensionfile is reported as present or absent, never as valid or invalid — that needs DuckDB's embedded RSA public keys, and reimplementing that verification from outside DuckDB, against an undocumented format, is not a thing to get subtly wrong in a tool whose whole point is trust. --dbmode reports what the machine running it has installed, and what this session has loaded — not a portable property of the.dbfile itself. Two machines can report differentinstalledlists for the exact same database file.--dbmode can under-report what actually executed. See "the one real surprise" above: a query that triggers DuckDB's autoload can run an extension's native code withoutduckdb_extensions().loadedreflecting it, in every case reproduced here.- An explicit-path
LOAD '/some/file.duckdb_extension'is a blind spot for both modes:--scanflags it (it can't confirm what's actually at that path at run time), and--dbmode'sinstall_pathreporting was observed to keep pointing at the canonical installed copy even when a different file was actually loaded from an arbitrary path. - The static scanner is pattern matching, not a SQL or Python
interpreter. It cannot see through variables, string concatenation from
non-literal parts, or a
configdict built anywhere but as a literal at theduckdb.connect()call site. - It does not touch, execute,
LOAD, orINSTALLanything itself.--dbopens the target database read-only and only ever runsSELECTagainstduckdb_extensions(). - The
-unsignedCLI flag was not independently tested — theduckdbPyPI wheel ships no standalone CLI binary, only the Python extension module, so this was verified throughduckdb.connect(config=...)only.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -q
.venv/bin/python -m mypy src --strictTests build a real, syntactically-correct .duckdb_extension-shaped footer
and .info sidecar by hand (matching what was found above) rather than
mocking the parser's return value, and spin up real DuckDB connections
(duckdb is a dev dependency, not a runtime one) to install a real
extension and read its real metadata back.
.venv/bin/python examples/demo.py # reproduces every real output block in this READMEMIT