Conformiti
ProductEditionsPricing ConsultingSelf-hostDocs Security GitHub Book a demo

Security & trust

A compliance tool has to
be worth auditing itself.

You are being asked to put your policies, your access reviews, your risk register and your auditor's evidence into this. The only honest basis for that is a posture you can inspect, so the source is public, every security review is published in full, and the residual risks are named rather than omitted.

Posture at a glance

LicenceMIT: every line readable, forkable, auditable
TelemetryNone. No analytics, no licence server, no seat meter, and no outbound call from the application the operator did not configure. The bundled interface does load its two webfonts from Google Fonts: self-host them and drop the two fonts.* entries from the CSP if that matters to you
Second factorTOTP and WebAuthn passkeys, with backup codes owned by the account
Single sign-onOIDC and SAML 2.0, environment-configured only, with step-up
TransportHttpOnly cookies by default, __Host- prefixes over https
Evidence integritySHA-256 per file, canonical manifest, detached Ed25519 signature
Audit trailWritten server-side only; the API exposes no create, update or delete method on it
UploadsCapped at 32 MB by nginx and again in the application, refused by extension for active-content types, served as sandboxed attachments, optional ClamAV with quarantine

How it is built

The decisions that matter.

Authentication fails closed

A passkey whose signature counter regresses looks cloned. Conformiti disables that credential and refuses the sign-in: it does not quietly drop the account back to password-only, which is the failure mode most implementations choose because it generates fewer support tickets. Backup codes belong to the account, not to the TOTP device, so a passkey-only person still has a recovery path.

Tokens rotate and can be revoked

Refreshing rotates the refresh token and blacklists the old one; signing out blacklists the current one. Login, failed login with the reason, and logout are explicit audit events. In the Docker stack the per-client login throttle counters live in Redis, so the limit is shared across workers rather than reset by whichever process answers; a bare install without CACHE_URL falls back to a per-process cache. Set NUM_PROXIES to your proxy hop count if you front it differently. It is rate limiting, not account lockout. There is no per-account failure counter.

SSO cannot be configured from a form

OIDC and SAML settings come from the environment only. Verified-email linking will never attach an external identity to a superuser, a staff account or anyone who can manage users; auto-provisioning refuses user-managing roles; the issuer is compared with trailing slashes stripped; JWKS verification is asymmetric only. If your provider asserts no second factor, SSO_STEP_UP asks for a local one. It defaults to if_enrolled. A code is demanded only from people who have a local authenticator; set it to required to refuse the sign-in from anyone who has neither.

Authorisation is resolved server-side

Folder access resolves through superuser, view-all, ownership and the strongest grant on the folder or any ancestor, then caps Auditor roles at view. Those rules gate every folder, document, tree, feed and evidence count. They do not narrow the registers: anyone signed in to a workspace can read its control programme, risks, vendors, RACI and analytics, so the shipped Auditor role reaches further than "granted folders" implies. Narrowing it is on the list. Write controls are gated on the same capability flags the API re-checks on every request, so the interface does not offer an action the API will refuse. The Django admin is an operator tool that sits outside those rules entirely: it bypasses MFA and the login throttle, so restrict who can reach it, as SECURITY.md says.

Tenancy is enforced at the ORM

Workspace scoping lives on the queryset and is re-applied on every chain, so a view that forgets to filter still reads only the active workspace. It is not an absolute: a queryset that has already been sliced or combined skips the filter, and installation-level tables (the signing key, the webhook delivery log, per-person authentication state) sit outside the mechanism by design. Pinning never widens. No active workspace means no filter: correct for migrations and for jobs that walk every tenant, but an API request with nowhere to go is refused with 403 rather than served unscoped.

Evidence is rendered, never executed

Inline delivery happens only after a magic-byte check on the actual bytes. PDFs are drawn by pdf.js onto canvases rather than handed to a plugin. Word and Excel are parsed server-side into structured JSON and rendered as structure: the file's own markup never reaches the page. Media is served as sandboxed attachments; the viewer fetches through the API and renders from a blob.

The audit trail records names, not values

Middleware reads the top-level field names of a mutating request before the view runs: password, token and code keys are dropped outright, and writes the entry only after a successful response. Auth events are written explicitly. The API exposes no create, update or delete method on the trail. The Django admin is the exception, as it is for every other rule here: restrict who can reach it.

Outbound calls are hardened

The Jira client accepts https only, refuses redirects, and pins to public hosts. A stored integration URL is a classic SSRF vector. Slack and Teams webhooks are https-only and settable by an operator and nowhere else. Secrets like the TOTP seed and the Jira token are encrypted at the field level.

Evidence integrity

What a sealed package proves,
and what it does not.

Sealing snapshots every row into a canonical manifest (version 4 now names the organisation the package belongs to inside the signed bytes) with its own digest, then signs that manifest with a detached Ed25519 signature from a key kept in a file outside the database. The bundle carries the signature and the public key; the fingerprint is published at /api/signing-keys/ and under Settings › About.

The verifier shipped in every bundle is standard library only: a from-scratch RFC 8032 implementation tested against the specification's own vectors, so an auditor runs it on an air-gapped laptop with no pip install and no call to us. It checks the manifest signature and every digest listed in SHA256SUMS. It does not prove the bundle is complete.

The honest limit. The signature covers manifest.json and nothing else. The auditor's conclusions in controls.csv and samples.csv are recorded after sealing and sit outside the signed bytes, bound only by SHA256SUMS, which is signed by nothing, so the signature proves the snapshot and the evidence digests are ours and unchanged, not that the conclusions are. Closing that is the next piece of work on this file. A signature also proves only that the holder of a key signed a manifest. It cannot prove the key was never stolen, and it cannot prove when it was signed. The seal entry in the audit trail and a digest you publish out of band (an email to the assessor, a ticket, a signed message) are the other half of that binding. We would rather write that here than let a padlock icon imply more than it delivers.
what the bundle contains
conformiti-package-fy26-soc2/
├── manifest.json      the signed bytes: snapshots + digests
├── MANIFEST.sha256    the manifest's digest, on its own
├── manifest.sig       detached Ed25519 signature
├── signing-key.pub    the public key, for comparison
├── SHA256SUMS         every file, hashed
├── verify.py          stdlib only, no network
├── controls.csv       scope, status, owner, conclusions
├── evidence.csv       name, version, size, digest
├── samples.csv        population, selections, verdicts
├── trail.csv          what happened, and when
├── INTEGRITY.txt      the re-hash result at export time
├── README.txt         how to verify it, and what that proves
└── evidence/          the files themselves

Verified with sha256sum -c SHA256SUMS and python3 verify.py. Roll-forward names the predecessor package in the manifest, so a chain of engagements verifies end to end.

Quality gates

What we run,
and what we publish.

Not a badge. A pipeline that fails the build. ./install.sh --test runs the backend suite, the static validator and a production frontend build on your own machine; the browser suite, the PostgreSQL run and the Docker boot are CI's job.

Continuous integration gates
GateWhat it proves
tools/validate.py
19 checks, no dependencies
App and route wiring, the API contract between the SPA and the backend, that every model change has a shipped migration, theme packs, and that tests and CI exist. Runs on a bare Python interpreter so it cannot be defeated by a missing package.
manage.py test
580 tests, 36 modules
Auth and MFA, token rotation, RBAC and folder-tree integrity, evidence access control, the auditor’s reachable surface enumerated by walking the routers, access reviews, risk import/export safety, the audit trail, reminder claims, outbound request checks, field encryption and key rotation, health, demo retirement, the boot guard, WebAuthn against virtual authenticators, SAML against locally signed assertions, and Ed25519 against RFC 8032 vectors.
Backend matrixPython 3.11, 3.12, 3.13 and 3.14 on SQLite, plus PostgreSQL 16: the combination we actually support in production, and the backend image ships on 3.14.
FrontendA production build that must succeed, and npm audit --audit-level=high.
DockerBoth images build; the API image boots and answers /api/health/.
End-to-end
99 Playwright tests
A real browser drives the built SPA through the shipped screens, twice per build, against both auth transports, and the suite fails on any console error, which is how several bugs that never surfaced in unit tests were found. A separate screenshot generator writes image files into the repository and so runs only when asked.

Every review is published

REVIEW_090.md is our own adversarial pass at the 0.9.0 tree: sixteen reviewers, 69 candidates, 50 confirmed, fixed across 0.9.1 to 0.9.4. Four outside reviews have followed it, each with its own document and its own release: nine findings closed in 0.9.5b, fourteen in 0.9.5f, fourteen more in 0.9.5h and three in 0.9.5i. Every one names the finding, its severity, and what it let an attacker do.

Dependencies are current

Django 5.2 LTS, React 19, Vite 8, Tailwind 3, pdf.js 6. Dependabot raises the bumps; they are applied and pushed through the normal review path rather than merged blind.

Findings you still have to close

One is open, and it is written down: of the forty raised by outside reviewers, thirty-nine are fixed and the last is deferred with the reasoning printed beside it rather than filed in a backlog. What remains is in SECURITY.md: the risks that are properties of your deployment rather than of the code, named rather than omitted, and therefore yours to close.

Coordinated disclosure

Found something?
Tell us properly.

We would much rather hear it from you than from a customer. Reports are acknowledged within two business days: sooner than the week SECURITY.md commits to, and triaged with a severity and a target date you will be told.

  • Email sales@conformiti.app with SECURITY in the subject, or open a private security advisory on the GitHub repository
  • Include a proof of concept and the version or commit: /api/health/ reports it
  • Please do not open a public issue, and please do not test against somebody else's deployment
  • Fixes are published with a release note; the advisory names the reporter unless you ask us not to
  • Subscribers on Priority or Dedicated support receive advisories ahead of public disclosure. That lead time is one of the things a subscription buys

There is no bug bounty. It is a small operation, and we would rather promise a fast, honest response than a payout we cannot fund.

In scope

  • The Conformiti application and container images on a supported release
  • Anything that crosses a workspace, folder-grant or audit-package boundary
  • Authentication, MFA, SSO, token handling and session fixation
  • Evidence delivery, the viewer, the preview parser and the upload path
  • Manifest, signature or bundle integrity
  • This website

Out of scope

  • Findings that require an already-compromised administrator account, but tell us anyway if the path runs through /admin/, we know that surface is thin
  • Missing hardening headers on a deployment you control and configured
  • Rate-limit or brute-force reports against the demo dataset
  • Automated scanner output with no demonstrated impact
  • Social engineering of us, our clients, or our clients' auditors

Managed hosting

If we hold your data.

Self-hosting means none of this is ours to get wrong, which is why we recommend it. If you ask us to operate the service instead, here is what you are relying on.

Managed-service commitments
AreaCommitment
Hosting regionCanada or the United States, chosen at provisioning and never moved without written instruction.
EncryptionTLS 1.2+ in transit; disk encryption at rest; field-level encryption for the TOTP seed and integration tokens, as in the open-source build.
IsolationA single-tenant instance and database: never a shared schema. Workspace scoping is enforced at the ORM rather than per view. A short list of tables is installation-wide by design: the signing key, the webhook delivery log, per-person authentication state, and we will tell you which.
BackupsNightly, encrypted, retained 90 days, with point-in-time recovery. Restores are tested, not assumed.
Signing keysYour instance holds its own key, in an HSM-backed store with published rotation. You may instead supply a key from your own secret store and rotate it on your schedule, in which case we never see it at all.
Access by usNamed staff only, least privilege, second factor mandatory, every action written to your audit log with our name on it. We do not read evidence content except when you ask us to on a support ticket.
Sub-processorsListed in the DPA and notified before any change, with a right to object.
ExitFull export on demand (database dump, evidence files, manifests and signatures), always. Data retained 60 days after termination, then destroyed.
Incident notificationWithin 72 hours of confirming a personal-data breach, or sooner where the DPA or law requires it.

A DPA on every managed engagement, and a BAA where you need one. Managed customers can request the platform's own SOC 2 report under NDA. Ask on the contact form.

Do not take our word for it.

Read the source, run the gates, seal a package and verify it yourself. That is the whole argument.