Reporting a security issue#
Send reports to security@bitexact.ai. Please include the affected
component (recorder, viewer, SDK, CLI, or the open verifier), the version
(bitexact version, or bitexact-verifier --version), and the steps that
reproduce it. Reports about the verifier are especially welcome: it is the
trust root that auditors and counterparties rely on, and a verifier that
accepts evidence it should refuse is the most serious class of issue this
product can have.
Please do not open a public issue for a vulnerability, and do not test against a deployment you do not operate. BitExact is self-hosted: there is no BitExact-run service holding customer evidence, so there is no hosted system in scope.
What a fix looks like, when one ships:
- The product: a new release with a
SHA256SUMSfile and a cosign-signed image, delivered to every licensed customer, with the issue described in its release notes. - The verifier: a new version with a row in
CHECKSUMS.txtand averifier-v<version>tag, so anyone holding a bundle can re-verify it with the fixed verifier.
The machine-readable contact for each of our sites is at
/.well-known/security.txt.
The product's own integrity#
The product ships compiled (Nuitka): a release wheel carries no Python
source of bitexact. The open verifier is the exception by design - its own
package, never compiled, so the file an auditor reads is the file that runs.
| Threat | What detects it |
|---|---|
| A shipped file is modified, say to skip the license check | Each build lists every file it installs with its sha256, signed with the release key (Ed25519 and ML-DSA). At startup the product checks the list against the release public key compiled into it, every listed file, and that nothing unlisted is in the package. On any difference it records nothing - a declared build_modified gap - passes the agents' calls, and says so in its health. |
| A license is forged or self-signed | Licenses are checked against the vendor public keys compiled into the build, both halves, and no other. |
| A license is copied to another deployment | It binds to the recorder key; every sealed run commits the deployment's fingerprint; the renewal request is signed by that recorder key. |
| A modified installation asks to be renewed | The renewal request carries a TPM 2.0 quote over the IMA measurement log (Linux): every measured file of the product must be one the release signed, and the TPM's attestation key is proved to live in a genuine TPM by its endorsement certificate and a credential only that TPM can open. |
| A lapse or tamper is hidden | One standing rule feeds health, the readiness probe and every run's run_meta; the open verifier prints each run's stated standing and fails a run that recorded while its standing said it must not. |
The honest limit: against root on the host, every one of these detects and none prevents. Root can patch the running process, and a build modified that way can write any standing it likes into the runs it signs - which is why the verifier prints a run's standing as the producer's statement, never as proof. What the TPM adds is that the next renewal shows the files the kernel measured, which a modified installation cannot hide without also subverting the kernel's measurement; and every such act breaches the license agreement, which prohibits reverse engineering and modification and gives BitExact an audit right.
Sensitive values in the evidence#
The capture policy (capture-controls.md) decides what the evidence keeps of each field before anything is written.
What it guarantees. It runs where every entry is built and where every remote event enters a host's collector, so a value it scrubs or keeps hash-only never reaches a spool, the wire, a store, the index, a log line or a backup. Card numbers (issuer prefix and Luhn), IBANs (mod-97) and credentials are scrubbed everywhere by default; US Social Security numbers, email addresses and your own patterns are detectors a policy turns on. Every outcome other than kept is declared inside the entry hash and every signature over it, and a verdict counts the declarations, never tokens in the text. A field it cannot scrub in time is withheld and declared a gap, never written raw; the agent's own call is never touched. The proxy's stream journal, which holds a response before the policy has run on it, is always sealed under the host key.
Turning it off is declared. With BITEXACT_CAPTURE_POLICY=off every
value is kept as captured: the run records off, every verdict over it
says no capture policy was in force, and health raises capture-policy-off
(critical when BITEXACT_ENV=prod). A host whose policy cannot be loaded
records no run rather than one without the controls it was asked for: the
agent runs on, the unrecorded run is noted under capture_policy_invalid,
and health raises capture-policy-invalid. A collector running with no
policy forwards raw values; the recorder applies its own before anything
is stored and raises collector-unscrubbed, but those values crossed the
wire and sat in the recorder's spool.
Key custody. A hash-only field is committed under the capture key,
which the evidence never holds: from a file, KMS-encrypted, an AWS KMS
HMAC key that KMS computes every HMAC with, or a secret key in a PKCS#11
token that computes every HMAC itself. Keep it apart from the
evidence, as the recorder key is kept. Rotate it by naming the new key and
retiring the old one, whose fields still open; a key that may have been
taken goes into revoked_keys with the date it was lost, and from then on
bitexact verify and health say its fields are guessable by its holder
from that date; the open verifier says the same when given
--revoked-capture-key ID=DATE. Testing a candidate value
against a hash-only field is recorded on the run's custody chain, because
it is the same act as guessing. The host key that seals stream journals is
kept beside the data directory, never in it.
What it is built against. The values it protects are the raw sensitive values an agent sends and receives; the capture key; and the declarations that say what was removed. The parties it is built against are an outsider with a copy of the store, a backup or an evidence pack; an insider with store access but no keys; a root user on one agent host; and a developer whose agent emits sensitive data by accident.
| Threat | What stops it, or declares it |
|---|---|
| Guessing a value from the evidence: a card number with a known issuer prefix and last four is at most 10^5 candidates | No unkeyed digest is stored. A hash-only field's salt is an HMAC-SHA256 under the capture key of a random per-field nonce and the path, and a gap's digest of content it did not store is keyed the same way or dropped, so a guess cannot be tested without the key (capture-controls.md, Why a plain hash of a card number protects nothing). |
| A value dressed to slip past a detector: spaced or hyphenated, in another script's digits, escaped inside a JSON string, URL-encoded, or split across stream chunks | Digits of any script are read as digits, a JSON document inside a string is scanned as the document, a URL-encoded space or hyphen reads as the separator it encodes, and a streamed response is scanned once it is reassembled. |
| An insider deletes a value and claims a scrub removed it | Every declaration is inside the entry hash and every signature over it, so changing one breaks the chain for anyone without the recorder key. |
Agent output carrying a token such as [PAN ••••1111] to make a record look clean | A verdict counts declarations only, never tokens in the text. |
| An operator turns the policy off, or a root user on an agent host turns its collector's policy off | Both are declared, as above: the run records off, and the recorder reapplies its own policy and raises collector-unscrubbed. |
| Crafted input built to stall the detectors | The detectors are linear, a custom pattern is held to a grammar with no groups, alternation or unbounded repetition, and each step has a time budget per MiB it holds; a field that runs out is withheld and declared, and the agent's call goes on. |
| A detector that raises | The field is withheld and declared a scrub_failed gap; outside strict mode, the error never reaches the agent. |
| Over-scrubbing destroys real evidence, such as a Luhn-valid order number | A card number must also start with a card scheme's issuer prefix; a keep rule exempts a known id field; and every scrub is counted on the chain, so an investigator knows a value changed. |
| A stolen capture key | Rotation, a key id on every hash-only field, and revoked_keys, as above. |
| A replay or regress host that holds the capture key tests candidates against hash-only fields | Each opening is recorded on the source run's custody chain first; a field whose opening cannot be recorded is not opened. |
What it does not do. It finds only what its detectors find: a card
number split across fields, written in words or inside base64 (declared
unscanned; the pci preset keeps it hash-only) is not found, and names,
addresses and other free text are not detected at all - clean them before
BitExact sees them and say so with scrubbed-upstream, which is recorded
as your claim, not checked. A hash-only field is pseudonymous, not
de-identified: it is not HIPAA de-identification, and under GDPR the
evidence is still personal data. A linkable hash-only field shows anyone
which fields hold the same value. The policy does not change what the
agent sends or receives, and it does not reach evidence recorded before it
was in force.