Security Policy
Last updated
This describes the application's actual current security posture, not an aspirational one. Signup is open — anyone who can reach this deployment can create an account — but every account operates within its own visibility scope for the assets and scans it creates. The known-limitations section is not an appendix; read it as part of the policy.
On this page
Authentication & accounts
- Password hashing with scrypt (salted, per-user), timing-safe comparison on verification.
- Server-side sessions: random 256-bit tokens, only a SHA-256 hash stored server-side, httpOnly + SameSite=Lax cookie, 7-day expiry.
- Every page and API route that touches scan/asset data requires an authenticated session.
- Two roles: owner and user. A
usercan run website scans and repository scans (both a local server path and a repository on a supported host), and can configure monitoring, webhook alerts, and domain ownership verification — but only for assets they can see (their own).ownercan additionally manage other users' accounts, and sees and manages every asset and scan in the deployment regardless of who created it. The UI disables actions a role can't perform rather than letting you hit a 403. - Optional TOTP-based two-factor authentication, enrolled per-account at
/account(RFC 6238, compatible with standard authenticator apps) with one-time backup codes for recovery. Off by default per account; disabling it requires re-entering the account password. - Account recovery: self-service password change (requires the current password) plus owner-assisted reset for forgotten passwords — see the limitations for why this isn't a self-service email-based flow.
- API keys for CLI/CI use, created at
/account, carry the same role as the user who created them and are only ever shown once at creation. Only a SHA-256 hash is stored; a key can be revoked instantly by its owner or by any owner-role account. They are not subject to session expiry — a forgotten, un-revoked key remains valid indefinitely, so revoke unused ones. - Invite links at
/users(owner-only, any platform role) and/organizations/:id(org-admin-only, org membership + role): a single-use, expiring, hash-stored token — the same pattern as sessions and API keys. Redeeming an org invite always grants the platformuserrole regardless of the org role granted, so an org admin can never use this path to bootstrap platform-wideowneraccess. There's no SMTP in this deployment, so “invite” means a link the inviter shares themselves, not an automatic email. - Rate limiting on every account and scan-creation surface: 5 failed logins per email, 5 signups per IP, and 5 MFA-code attempts per pending login — each per 15 minutes; 20 scan submissions per user per 15 minutes (website and repository scans tracked separately). Counters are stored in the database rather than in memory, so a lockout survives a restart and is shared across instances. A blocked attempt deliberately does not extend the window — otherwise the limiter becomes a denial of service anyone can point at a known email address.
Data isolation
Each asset records which user's scan first created it (Asset.createdByUserId), and each scan separately records who triggered it (Scan.triggeredByUserId). The second field matters because two different users can scan the same public URL or repository.
A user account's dashboard, scan list, and scan detail/report pages are filtered to assets it created or scans it personally triggered. Requesting another user's scan by ID returns 404, not 403, so its existence isn't confirmed to someone without access. The owner is exempt from this filtering and sees everything.
Organizations (/organizations) are an additive, optional shared scanning scope layered on top of this model, not a replacement for it. A scan can be run “as” an organization any member belongs to; every member can then view that scan directly, in addition to — never replacing — each user's own personal scan visibility.
Scanning safeguards
- Deep (safe active) scanning requires proven domain ownership via a DNS TXT record or a well-known file, checked with a real DNS/HTTPS lookup against the target before any exposure checks run. Without it, only passive checks (equivalent to a normal browser visit) are performed.
- GitHub repository scanning: a repository scan can target a public GitHub repo (
owner/repoor a github.com URL, with an optional branch) instead of a local path. The repo is shallow-cloned (git clone --depth 1) into a fresh temporary directory for the duration of that scan only, with a 30-second clone timeout, and deleted immediately afterward whether the scan succeeds or fails. The owner/repo/branch strings are validated against a strict character allowlist before ever reachinggit, and passed as separate process arguments (never through a shell), to prevent argument or command injection. Private repositories are supported on GitHub, GitLab, Bitbucket and Azure DevOps using a credential you add at/integrations: verified with the provider, stored encrypted, never displayed again, and never sent to a host it does not belong to. The token is passed togitthrough an ephemeral helper rather than embedded in the clone URL, which would otherwise put it in the process list and in the cloned.git/config. - Optional repository-scan allow-list: setting
CODE_SCAN_ALLOWED_ROOTS(comma-separated absolute paths) restricts local-path repository scans to those directories. Unset, any readable directory on the server can be scanned by any signed-inuser— see the limitations. This doesn't apply to GitHub scans, which never touch the local filesystem outside a scan's own temporary directory. - Secret-scan findings mask the detected value before storage or display (first and last few characters only).
- Bounded resource usage on scans: capped file counts and sizes for repository scanning, capped dependency lookups, capped HTTP response body reads, and network timeouts on every external call.
- Pattern-based code scanning (SAST) and LLM-usage-risk scanning are regex and pattern rules over source files (
eval(), shell-command string interpolation,pickle.loads, disabled TLS validation, LLM SDK calls near unescaped input, and so on) — not taint or data-flow analysis. Every finding from these two scanners is explicitly worded as a pattern worth reviewing rather than a confirmed vulnerability, and confidence is set to “potential” throughout for exactly that reason. - Authenticated differential-access probe (
/tools/auth-diff-probe, owner-only): sends live GET-only requests to an operator-specified third-party target using two operator-supplied credential sets, to flag responses worth a human look — not an automated IDOR/BFLA scanner. The target URL goes through the same SSRF check as webhooks, and every endpoint path is additionally required to resolve to the same origin as the target URL, so a malicious endpoint value can never redirect the supplied credentials to a different host. Neither the credentials nor the response bodies are ever persisted.
Alerting & integrations
- Webhook SSRF protection: a webhook URL is rejected if it — or any address it resolves to — is a private, loopback, link-local, or CGNAT address, including the common cloud metadata endpoint (169.254.169.254). This is checked both when the URL is saved and again immediately before each delivery. A residual DNS-rebinding gap remains between that final check and the actual outbound connection: this narrows the window, it doesn't eliminate it.
- Named alert-webhook formats: an asset's monitoring alert can be sent as generic JSON, a Slack incoming-webhook message, a PagerDuty Events API v2 event, a Datadog event, or a Splunk HTTP Event Collector event. The PagerDuty, Datadog, and Splunk paths POST to those services using a per-asset credential rather than a user-supplied destination URL. That credential is never echoed back after being saved, and is redacted to
(set)/nullbefore being written to the audit log.
Audit logging
A hash-chained audit log of security-relevant actions (logins and failures, scans triggered, asset and user settings changes, verification attempts) records actor, action, target, and IP at /audit-log. A user sees only events recorded under their own account; the owner sees every account's events plus a “Verify chain integrity” button — owner-only, since verifying the chain requires the full cross-account sequence and a filtered subset can't be verified on its own.
Every entry includes a SHA-256 hash of its own content plus the previous entry's hash, so editing or deleting a past entry breaks the chain from that point forward. This detects tampering; it does not prevent a database-level attacker from editing an entry and recomputing the chain forward from that point, and there is a narrow race where two events logged in the same instant could fork rather than break the chain. A logging failure never blocks the action it is recording.
Known limitations (not yet implemented)
- Data isolation is per-asset-or-scan, not a full multi-tenant model. It's enforced on the dashboard, scan list, scan detail/report pages, audit log, and monitor-run trigger, and on the scan/asset/SBOM API routes — but user management (
/users) remains owner-only by design. Finding history and diffing (new vs. persisting vs. fixed) is tracked per(asset, user)pair, so two users sharing a scanned asset never see each other's finding lifecycle. Assets created before this attribution existed have no recorded creator and are only visible to the owner. Signup remains open. - Local-path repository scanning reads arbitrary local filesystem paths with the server process's own permissions and is available to any signed-in
user, unless an operator has setCODE_SCAN_ALLOWED_ROOTS. That variable is optional and off by default, so setting it is strongly recommended once a deployment has more than one trusted account. - GitHub repository cloning has no size or time bound beyond a flat 30-second clone timeout — an unusually large repository may simply fail to clone rather than being scanned partially.
- Partial encryption at rest. Everything quoted from a scanned target is sealed with AES-256-GCM under a key held outside the database: raw scan results, each finding's evidence, and each finding's description — the last because it interpolates observed values such as a disclosed framework name. TOTP secrets and repository access tokens are sealed under the same scheme. The database file itself isnot encrypted, so every other column — email addresses, asset URLs and finding titles — is readable to anyone who obtains the file.
- No self-service (email-based) password reset — there is no email or SMTP infrastructure in this deployment, so a forgotten password requires an owner to set a new one directly.
- The audit log's hash chain lives in the same database it protects — there is no external append-only sink to detect a full database replacement, only tampering with individual entries in place.
- Organizations are not full multi-tenancy. No nested teams, no org-level API keys, no org-level billing, and no org-level audit log separate from the platform-wide one. Membership can currently only grow via invite, not shrink. An org-scoped asset only appears in that org's dashboard, not in a member's personal dashboard — those two views are intentionally kept separate rather than merged.
- No security certifications. This application has not been through a SOC 2 audit, an ISO 27001 certification, or a third-party penetration test. The compliance mappings in the product are best-effort CWE-to-control associations for situational awareness, not official crosswalks and not evidence that any control is implemented.
These limitations are listed deliberately rather than omitted. If you find a security issue beyond what is described here, see the Responsible Disclosure page.