Before you trust it
Three reviews of this codebase — what was found, what was fixed, and what is knowingly accepted and why. Published in full and unedited, so you can read it before you talk to anyone.
Two entries below record a review that was wrong: an XSS finding that missed the path where the real bug turned out to be, and an Atlassian endpoint that only a live call proved incorrect. They are left in, because a security document that lists only wins is not evidence of anything.
Your access. Your rules.
Three security reviews have been done against this codebase. The first (auth, secret handling, persistence, and the multi-tenant API surface) found four issues; a second pass over the web UI's own surface — session cookies, CSRF, and the sinks that render stored data — is recorded below it; the third, on 2026-09-02, found three more and has its own section further down. All seven findings are fixed. 1,022 tests pass, ruff and mypy clean, and pip-audit reports no known vulnerabilities.
From the first review:
Platform-admin token comparison was not constant-time. require_platform_admin compared the Authorization header to the expected value with !=, a timing side-channel (CWE-208) against the single highest-privilege credential in the system (it can create organizations). Fixed with secrets.compare_digest (api.py).
Onboarding temporary passwords were leaking into the SIEM webhook payload. send_siem_event serialized the entire run result — including OnboardRequest.temporary_password — and pushed it to an external SIEM webhook in plaintext. A SIEM is a durable, typically broad-access audit trail; a live account credential has no business persisting there forever. Fixed by redacting temporary_password before the payload is built (siem.py::_redacted_payload) — it's still returned once in the API response, where the caller actually needs it.
Provider credentials weren't validated at write time. PUT /providers/{name} accepted an arbitrary dict, encrypted it, and stored it without checking it against the provider's real settings model — malformed config (e.g. a GitHub credential missing token) would only fail later as an unhandled 500 the next time the org ran /connectivity, /offboard, or /onboard. Fixed by validating against the same *Settings pydantic model tenancy.py uses, at write time, returning a clean 400 instead (api.py).
.gitignore didn't match the actual audit log filename. The structured audit log is written to logs/offboarding.jsonl, but the ignore patterns were logs/*.log and logs/*.json — neither matches a .jsonl extension, so the log (which contains actor/target emails and run details) wasn't actually protected from being committed. Fixed by adding logs/*.jsonl.
Web UI-specific review (the session-cookie/CSRF/HTML-rendering surface the API doesn't have):
XSS: confirmed Jinja2Templates autoescapes .html templates by default, then pinned it with a regression test that submits <script>alert(1)</script> as an org name and an offboard target and asserts it comes back HTML-entity-escaped, not literal, on every page that renders it. This matters more than usual here because provider detail strings can contain up to 300 characters of a third-party API's own error response text, rendered directly into the run detail page.
That review was incomplete, and a later one found what it missed (2026-09-02). Autoescaping is only the default; it does not apply to a value a template marks |safe. i18n.translate() interpolates with a plain str.format() and escapes nothing, and three templates passed model data through it and then marked the result safe — employee_detail.html (a manager's name and identifier), whatif.html (the same pair on a report, and the report target). A manager named <script>…</script> therefore executed in the browser of any admin who opened the page of someone reporting to them, and the Content-Security-Policy did not stop it because script-src carries 'unsafe-inline'.
This was not self-inflicted. Employee names are not typed by the administrator: they arrive from a CSV upload or from the provider directory import — Google's name.fullName, Entra's displayName — and in most tenants an ordinary user can edit their own display name. So the reachable path was an ordinary employee obtaining script execution in an authenticated admin's session, which can run an offboard.
Fixed by escaping at the sink rather than at each call site: web/context.translate_html() escapes every interpolated value and returns Markup, and build_ctx binds the template t() to it, so a page that interpolates a value is safe by default and cannot forget. Call sites that genuinely need markup pass Markup() explicitly. Every |safe has been removed from the templates; none is needed. Pinned by tests/test_i18n_html_escaping.py (including a test that the binding itself has not been reverted) and by payload tests in tests/test_employees_web_ui.py. i18n.translate() still returns plain unescaped text for the CLI and JSON API, which is also tested.
The general lesson, stated because it is the more useful finding: "the framework autoescapes" is a claim about a default, not about a codebase. It is only true where nothing opts out.
CSRF: every state-changing form carries a session-bound token checked with secrets.compare_digest; combined with samesite=lax cookies, a cross-site page can't trigger a real offboard/onboard against a logged-in admin's session. Re-verified route by route on 2026-09-02, because "every" is exactly the kind of claim the XSS entry above got wrong: every POST/PUT/DELETE under /ui reaches verify_csrf, including the two bulk-CSV submits, which do it inside the shared _bulk_csv_submit_ui helper rather than in the route body.
Session design: the cookie stores only an opaque org id (or an admin flag) after login validates the real secret once — never the raw API key or admin token on every request — so a stolen UI session cookie has a strictly smaller blast radius than a stolen API key.
A real bug was found and fixed during live testing (not by static review): the dashboard built its provider-connectivity dict via **result.model_dump() (pydantic's default "python" mode), which left status as the ActionStatus enum member rather than its string value — Jinja rendered it as the literal text "ActionStatus.FAILED" instead of "failed", and the same dict-spread also silently overwrote the provider's real critical flag with the connectivity result's own (always-False) critical field. Fixed by building the dict explicitly instead of spreading a model dump, with a regression test (test_dashboard_renders_connectivity_status_as_plain_string_not_enum_repr) that mocks a real provider's HTTP failure and asserts the rendered badge text. This is exactly the kind of bug unit tests with mocked internals don't catch — it only showed up once an actual browser-shaped request hit an actual rendered template.
A fresh pass over the multi-tenant surface, run as an attacker would rather than as a test suite: injection surface, cross-tenant isolation, the sinks that render stored data, admin-supplied values that become file paths or URLs, credential storage, and dependencies.
What held up. Cross-tenant isolation is correct — every ID-parameterised route (/runs/{id}, /employees/{id}, bulk runs, departments, roles, admins) loads through a helper that takes the authenticated organization and rejects a mismatch, so no IDOR was found. There is no raw SQL anywhere. API keys are hashed at rest. The safelist refused a live offboard of a protected account when tried against a real running container.
Three real findings, all fixed:
Stored XSS via the translation layer — see the XSS entry above.
An organization could choose which file the server opened. service_account_json_path was a free-text value an org admin set through the provider form or PUT /providers/google, and it was passed straight to from_service_account_file(). In a single-company install that admin already owns the machine, so it changes nothing; in an MSP install, where one deployment serves several client companies, it let one client's admin aim the server at another tenant's mounted key or at the generated-secrets file. Fixed by storing the service-account JSON itself, encrypted like every other credential, and using from_service_account_info(). tenancy.build_settings_for_org() now filters path-shaped keys out of per-organization config on read, not just on write, so a value stored before the guard existed stops being honoured on upgrade instead of surviving it; where that leaves nothing usable the organization gets an actionable 503 telling them to re-enter the credential. The API refuses such a key outright with a 400. The path form remains available to the *operator* via GOOGLE__SERVICE_ACCOUNT_JSON_PATH in .env, which is a file on their own machine and which the single-tenant CLI depends on.
Four advisories in cryptography 46.0.7 (PYSEC-2026-3552/3553/3554, GHSA-537c-gmf6-5ccf), cleared by moving to 50.0.1. msal was the only ceiling and moved to 1.38.0 with it. Data encrypted under the old version was verified to still decrypt — provider credentials, a MultiFernet blob, an encrypted salary, and a key rotation — before the bump was accepted. pip-audit is clean.
Two administrator-supplied URLs are fetched by the server. The SIEM webhook (siem.webhook_url) and the Anthropic SCIM base_url, the latter sent with its bearer token. Both are deliberately free-form: a SIEM lives wherever the customer put it, and Anthropic's SCIM base URL differs between the commercial product and Claude for Government, so neither can be hardcoded.
This is recorded rather than blocked, and the reasoning is worth stating because it depends on how you deploy:
Single-company install — not a vulnerability. The administrator who can set the webhook already has access to that machine and that network. Nothing is reachable through the application that is not reachable directly, so restricting the field would remove capability and add no security.
MSP install — this is the shape to think about. Several client companies share one deployment, and each client administrator is a third party to the MSP. A client administrator can point the webhook, or the SCIM base URL and its token, at an address on the MSP's own network. The question an MSP should answer before handing a client the provider settings page is whether that client's administrators are trusted on the network the deployment sits in.
Not blocked by default because JaraLock is self-hosted and bring-your-own-credentials: the network belongs to the operator, and plenty of organizations legitimately run their SIEM on an internal address. Refusing private address ranges would break a correct setup to defend against an administrator the operator themselves onboarded. An opt-in restriction is a small additive change if a deployment wants one — ask.
Also still true:
The FastAPI webhook's bearer-token auth is defense-in-depth, not the primary access control — put this service behind a network boundary (internal-only VPC/VPN, API gateway with real authn) rather than exposing it directly to the internet.
APP__ENV=production refuses to start without APP__WEBHOOK_AUTH_TOKEN set — there's no way to accidentally run the webhook unauthenticated in prod.
Log redaction: none of the current log statements include secret values, but if you extend logging, never log tokens/credentials — only actor/target/provider/status.
Service account / app credentials for each provider should use the minimum required scope (see the table above) — don't grant broader admin scopes than each specific action needs.
The Atlassian provider's account-lookup call (_find_account_id) was rewritten against the v2 directory-scoped endpoint (new required setting: ATLASSIAN__DIRECTORY_ID) after the v1 endpoint was sunset around 2026-06-30. That rewrite was wrong, and only a live call found it: the v2 org endpoints need an /admin prefix. GET /v2/orgs/{orgId}/directories returns 404; GET /admin/v2/orgs/{orgId}/directories returns 200. Both the lookup and the invite call were corrected on 2026-08-17. This is the clearest argument in this codebase for live verification over documentation reading — the mocked tests passed against the wrong URL, because they asserted the same wrong URL the provider used.
Rate limiting (ratelimit.py) is applied per client IP to the sensitive endpoints only — both login forms, both MFA verifies, key rotation, and the platform-admin API — at APP__RATE_LIMIT_PER_MINUTE (default 20/min). Ordinary authenticated org traffic (offboard, onboard, whatif, employees) is deliberately not throttled, so a script offboarding a departing team isn't blocked. Note this is an in-process fixed-window limiter: in a multi-worker/multi-instance deployment each process enforces its own window, so it is defense-in-depth on top of the database-backed MFA lockout (which *is* cross-instance), not a replacement for a gateway-level limiter if you expose this publicly.
Employee salary is encrypted at rest (Fernet, same as provider credentials) in employees.salary_encrypted; the Employee.salary property encrypts/decrypts transparently, so a database dump or backup never contains plaintext compensation. Nothing queries or sorts on salary in SQL, so ciphertext storage costs nothing operationally.
API keys can be rotated/revoked — POST /keys/rotate (a customer rotates using the key they still hold), POST /admin/organizations/{id}/rotate-key, or the "Rotate key" button on the admin organizations page. Rotation revokes every prior active key for that org and issues one new key, shown once. A leaked key is a one-click fix, not a database edit.
Undecryptable stored credentials (usually a changed APP__ENCRYPTION_KEY) return a clean, actionable 503 naming the affected provider rather than an opaque 500; endpoints that don't need to decrypt (e.g. the employee directory) keep working.
CSV employee imports are capped at 5 MB (MAX_CSV_UPLOAD_BYTES), rejected before decode on both the JSON API (413) and the web form, so a single upload can't exhaust process memory.
Per-administrator keys and attribution (admins.py). Each administrator holds their own key, so removing one person's access revokes only their credential. Two properties are worth stating as security claims rather than features: (1) the actor recorded on a run comes from the authenticated key, never from the request body, so a departing admin cannot record an action under a colleague's name; (2) disabling someone ends their existing browser session too — the session's admin_id is re-checked against the database on every request, so revocation is effective on their next click across every worker and instance, not whenever a cookie expires. Keys belonging to a disabled admin are refused at resolution as a second line of defence even if the key row somehow survived revocation. Legacy organization-level keys (admin_id IS NULL) are unchanged and still authenticate, so upgrading cannot lock a deployment out.
If your security team wants something here checked, ask directly — ms.blxckroze@blxckroze.com. The 30-day trial ships the full source, so every claim on this page can be verified against the code rather than taken on trust.