Security and limits¶
Security of the server itself¶
- Two processes: the MCP server (exposed to the agents) and the controller (REST API, web
interface, administration) run separately and only share the database (see
Architecture and deployment). The MCP server only accepts
the
mcpandadmintokens, the controller only web sessions andadminandapitokens, pluscion its pipeline routes (/api/v1/ci/: gate, SARIF and trend in read only, scans that store reports of its projects) and nowhere else. The controller exposes no write route without a token: it can stay on an internal network, and the compromise of the exposed MCP server gives no access to the administration interface. - HTTP transport: MCP authentication by Bearer token and TLS are built in but optional (see
HTTP server, authentication and TLS). Tokens are stored in the
database and only their SHA-256 digests are kept. Every request looks up the digest of the
presented token, which reveals nothing usable without a preimage, then checks it in constant
time: a revocation is therefore immediate on every instance. A token created with
--projectis limited to that project in the MCP tools and the pipeline routes: any other project, report or finding is handled as missing (see tokens restricted to a project). While no token exists, the MCP server stays closed (fail closed: only enrollment is reachable), unless it listens on localhost orSECREVIEW_ALLOW_UNAUTHENTICATED=true. If it does not listen on localhost, a warning is logged when authentication or TLS is missing. The server never generates a TLS certificate: HTTPS uses the certificate given by the operator, signed by a certificate authority. There is no rate limiting. Every token has a role (mcp,api,admin,ci,metrics); a storedreaderortriagertoken, left by an older version, authenticates nothing. Anapitoken only holds rights of the access control, read at each request: those of its user (personal token, deleted with the user, never rotated by someone else, whose new secret would carry the rights of the user) or of its role on its projects (role token, whose creator must hold each permission). A token never creates another personal token. Anmcptoken without a project reaches every MCP tool and every project; a personalmcptoken reaches the projects its user may read now, none when the user has none (never every project by default), and stops when the user is deleted or must change a password set by an administrator. A web user holds at mostSECREVIEW_PERSONAL_TOKENS_PER_USERpersonal tokens of each role (1 by default). - Expiry and rotation of tokens: an expired token authenticates nothing (
401), andSECREVIEW_TOKEN_MAX_DAYSbounds the validity of every token created or rotated. Expired tokens still count as configured: a server whose tokens have all expired stays closed. A rotation stores the digest of the new secret only; the previous digest is kept during the grace period (at most 168 hours), compared in constant time like the current one, and the previous secret of an expired token never comes back (see expiry and rotation). - Rejected tokens on
/mcp: an unknown, revoked or expired token gets a401whose description tells the user to sign in to the web interface and create a new personal MCP token (see rejected token); it never reaches the enrollment server. Each rejection is also logged as a warning by the MCP server (Rejected unauthenticated request/Rejected expired token, with the client address); thesecreview_auth_rejections_totalmetric only counts the rejections of the controller.SECREVIEW_WEB_URL, given in that description, is a public URL: it holds no credentials (refused at start-up). - Audit log: triage decisions, agent proposals, creations, rotations and revocations of
tokens and enrollments are written in the same transaction as the change,
with their actor (
user:,token:,cli,anonymous). The details are bounded and masked, and never hold a token or a password; no tool nor route edits an event, only the purge ofSECREVIEW_AUDIT_RETENTION_DAYSdeletes them (see audit log). - Metrics:
/metricsexists on the controller only (the MCP server keeps/mcp,/api/v1/enrolland/healthz), behind ametricsoradmintoken; ametricstoken reaches nothing else. The answer holds counts and project names, never a finding, a token label or a secret, and every label value is escaped (see Prometheus metrics). - Pipeline tokens (
ci): always restricted to projects (acitoken without a project cannot be created, and one found without a project reaches none). It only reads the gate, the SARIF log and the trend of its projects; the other routes of the controller,/mcpand the web interface answer403, the other projects404. The pipeline client (security-review-ci) opens no database, reads the token from the environment or stdin only, refuses plain HTTP outside localhost, credentials in the URL and redirects, and bounds the answers. - Enrollment (enabled by default,
SECREVIEW_ENROLLMENT): a client without a token reaches a restricted MCP server that only lets it request its project, andPOST /api/v1/enrollis public on the MCP server (body limited to 4 KiB). The enrollment never creates a token and never gives any access: a requested project is pending until a user withprojects.createapproves it on the controller, which creates its project group without any member; only an administrator adds a user to it, and the developer creates a personal MCP token in the web interface, after logging in. The answer is the same whether the project existed or not. The enrollments are bounded (20 pending projects, 5 per client address and per hour,429beyond), a request nobody approved withinSECREVIEW_PROJECT_ENROLLMENT_TTL_DAYSis deleted, and the free text of the requester is masked and truncated: check the requester and the address before approving. Every enrollment is audited. See enrollment. - Outgoing network calls: the MCP server makes none. The
security-review-mcp enrollandscan-depscommands contact, at the request of the operator, the URL they give (scan-depssends the dependency files given on its command line, never through a symbolic link); they refuse plain HTTP outside localhost (unless--allow-http), credentials in the URL and redirects. The controller only downloads the OSV advisories if an administrator enables it (SECREVIEW_OSV_AUTO_UPDATEoradvisories update): only fromSECREVIEW_OSV_URLover HTTPS with certificate verification, redirects limited to HTTPS, bounded size (SECREVIEW_OSV_MAX_DOWNLOAD_MB), optional proxy whose credentials are never logged. The Update now button of the web interface (settings.managepermission) is only accepted ifSECREVIEW_OSV_AUTO_UPDATE=true. The outgoing proxy (HTTPS_PROXY/NO_PROXY, or the Outgoing proxy screen) serves the OSV download and the OIDC provider; only the name of the changed settings is logged. A proxy set in the web interface is stored in the database with its credentials, in clear: the API never returns them (password replaced by***) and the error messages of the history are scrubbed of them, but preferHTTPS_PROXY(Kubernetes secret) if the database is not protected like a secret. See the automatic update. With the OIDC login (SECREVIEW_OIDC_ENABLED, disabled by default), the controller also contacts the configured identity provider: HTTPS only (HTTP only on localhost), redirects limited to HTTPS, answers bounded to 1 MiB, 10 second timeout. This configuration can also be changed in the interface by an administrator, with the same checks (an invalid configuration is refused); the Test the connection button contacts the entered issuer, under the same conditions. The client secret entered in the interface is stored in the database in clear: the API never returns it (***) and the logs only name the changed settings; preferSECREVIEW_OIDC_CLIENT_SECRET(Kubernetes secret) if the database is not protected like a secret. WithSECREVIEW_UPDATE_CHECK_ENABLED=true(disabled by default), the About page asksapi.github.comfor the latest release of the repository: HTTPS only, through the outgoing proxy, redirects limited to HTTPS, answer bounded to 512 KiB, 10 second timeout, at most one request per controller process every 6 hours (15 minutes after a failure); only the version and the link of the release are kept (a link outside the releases of the repository is replaced), and the page only gets a short error code. Once a connector is switched on in the Connectors screen (every one off by default, all locked off bySECREVIEW_TICKETING_ENABLED=false), the controller creates tickets in the configured Jira and ServiceNow: HTTPS only, through the outgoing proxy, no redirect, answer bounded to 1 MiB, 15 second timeout; the texts sent are masked again and, in Jira, never read as wiki markup; the secrets entered in the Connectors screen are stored in clear and never returned (***) and stay bound to the host of their URL; a third-party connector is only loaded whenSECREVIEW_TICKETING_PLUGINSnames it and only reaches its configured URL through the client of the controller, see ticketing. WithSECREVIEW_AUDIT_SYSLOG_ADDRESSor a server set on the Audit log page (disabled by default;settings.managepermission,udp://ortcp://only, the change audited), the controller sends the audit events to that syslog server (UDP, TCP or Unix socket, neither TLS nor proxy: use a local relay for TLS); the events hold no secret (masked details), and the MCP server still connects nowhere. - Initial administrator: created by the controller at its first start with a random
password that nobody knows, never logged nor passed through a variable; the only way to a
password is
security-review-controller user reset-password, run by an operator, which prints it once. WithSECREVIEW_OIDC_ONLY=trueand OIDC enabled, the password login of the local accounts is refused (403); a disabled or invalid OIDC configuration never locks the local accounts out. - Web interface (controller): the REST API always requires a session or an
adminorapitoken; without a user or a token, it answers503instead of granting anonymous rights. Only a session or anapitoken with thefindings.triagepermission, or anadmintoken, changes the status of a finding (action logged with the user or the label of the token), and only a person logged in to the interface (session) accepts a risk or marks a false positive: a bearer token is refused (403), evenadmin. These two decisions need a reason and an expiry date (at mostSECREVIEW_MAX_DISMISSAL_DAYSdays); the approver is the logged-in user, and the finding counts as open again once the date has passed. Only an acceptance may have no expiry date, unlessSECREVIEW_PERMANENT_ACCEPTANCE_ENABLED=false: set it to force a periodic review of every accepted risk. A risk less severe thanSECREVIEW_RISK_ACCEPTANCE_MIN_SEVERITY(mediumby default) is accepted with no reason nor date, still by a logged-in person who is recorded as approver; leave the variable empty to require them for every acceptance. Comments on a finding follow the same rule (session withfindings.triage) and are append-only: no route edits or deletes one. So does raising the severity of a finding: with a justification, logged, never below the severity of the report (lowering a finding goes through a risk acceptance). - Agents never close a finding: the MCP server only offers
propose_finding_status, which stores a proposal (reason masked and bounded, author taken from the token) without changing the status nor the gate. The reviewed code is untrusted: a prompt injection cannot dismiss a finding. Pages are served with a strict CSP and the anti-clickjacking headers, and every answer of the controller (API, refusals included) carriesX-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY, a closed CSP when the route sets none and, over HTTPS (TLS of the controller, orX-Forwarded-Protoof a proxy listed inSECREVIEW_FORWARDED_ALLOW_IPS),Strict-Transport-Security: max-age=31536000; the token is kept in thesessionStorageof the tab. The Swagger documentation and its OpenAPI schema are disabled by default (SECREVIEW_SWAGGER_ENABLED); once enabled they are public, served from local files only with a strict CSP, and the data routes stay protected. The check is made twice: by the Bearer middleware, then by every route of the API, which refuses (401) any request without an authenticated identity. The API thus never grants rights by default, even if it is served without the middleware. Every JSON answer goes through its declared response model: a field a service would add by mistake (a digest, a secret) is dropped instead of being returned. The answers that hold a secret (session, new or rotated token, generated password) are sent withCache-Control: no-store. - User accounts (user name and password): only
a salted scrypt digest (N=2¹⁴, r=8, p=5, random 16-byte salt) is stored; a stored digest
with out-of-bounds parameters is refused instead of exhausting the memory. Passwords of 12 to
256 characters, with a lowercase letter, an uppercase letter, a digit and a character that is
neither (checked by the controller, generated passwords included); a password set by someone
else (first administrator, creation or reset by an administrator) must be changed at the first
login: until then the session only reaches My account (
403elsewhere), user names limited to[A-Za-z0-9._@-]. The check takes the same time whether the user name exists or not (dummy digest), and a failure always returns the same401. Failures are limited in the database, shared by every replica (5 per user name and address, 20 per address, over 15 minutes, then429), and so are the requests of an address to the REST API (SECREVIEW_RATE_LIMIT_*, counted before the token is checked, so that guessing tokens is limited too; the counters store SHA-256 digests, never the addresses); behind a reverse proxy, the client address is only read fromX-Forwarded-Forfor the proxies listed inSECREVIEW_FORWARDED_ALLOW_IPS(*lets any client choose its address and get round the limits). Only valid user names are logged, never passwords. The session is a token signed withSECREVIEW_SECRET_KEY, like the OIDC one, but the account is read again from the database at every request: deletion, new role or new projects apply immediately, and a password change draws a new random session version that invalidates every previous session. The session must also carry the identifier of the account: it never opens an account created again under the same internal number. The last administrator, local or OIDC (by its role or the role of its groups), can be neither deleted nor demoted. The password of the initialadminaccount appears once in the logs of the controller: protect them and change it at the first login. - OIDC login (OpenID Connect), optional: authorization code flow with
S256PKCE, state bound to the browser by a signedHttpOnly/SameSite=Laxcookie of 10 minutes, nonce, verified ID token (signature by the JWKS keys of the provider, asymmetric algorithms only, nevernonenor HMAC; issuer, audience,azp, expiry). The permissions come from the groups of the account: those given by an administrator and the groups open to OIDC whose name is exactly a value of the groups claim (other values are ignored, a group not open to OIDC is never joined through the claim); an OIDC session never opens/mcp. The account is identified by the issuer and thesubof its first login (unique in the database), never by a name: a name changed at the provider keeps the account. It is named at its creation by the exact identifier of theSECREVIEW_OIDC_USERNAME_CLAIMclaim (no fallback toemailorsub, value refused if it is not a valid user name), which also binds an account created beforehand by an administrator: choose a claim that the user cannot change at the provider. By default (SECREVIEW_OIDC_AUTO_CREATE_USERS=false), only an OIDC account created by an administrator can log in. An OIDC login never opens a local account of the same name, and the account is bound to the issuer and thesubof its first login: anothersubor another issuer is refused. A deleted OIDC account is revoked, by its name and its issuer andsub: the automatic creation does not create it again, until an administrator creates it again or allows it again. Check the requester of an enrollment (free text, not verified) before giving them read access to the project. A session restricted to projects is restricted like a--projecttoken. The session is a signed token (HMAC-SHA256, key derived fromSECREVIEW_SECRET_KEY) that names the account, read again at every request: the deletion of the account cuts it at once. It is accepted by the controller only, never by the MCP server. The return URL after login is limited to local paths. Codes, tokens and secrets are never logged by the controller; the authorization code does appear in the access logs of a reverse proxy or of uvicorn, which is harmless: it is single-use and useless without the PKCE verifier of the cookie. The secret settings (SECREVIEW_SECRET_KEY,SECREVIEW_OIDC_CLIENT_SECRET,SECREVIEW_TLS_KEYFILE_PASSWORD,SECREVIEW_DATABASE_URL) are typed as secrets: they never appear in a dump of the settings. WithoutSECREVIEW_SECRET_KEY, the controller draws a random session secret at every start, kept in memory only (a warning is logged): sessions end at restart. - No disk access on the MCP side:
scan_codeonly works on the given content; the server reads no file from the disk, which avoids path traversals. For the whole repository scan, the pipeline client (security-review-ci scan-code), run by the operator on a folder they choose, reads the files without following symbolic links and sends their content to the controller, which scans it without reading its own disk. - Pipeline scans:
POST /api/v1/ci/scan/codeand/scan/depsneed acioradmintoken (a web session is refused) and stay within the projects of the token; they store a report only withsave. The request body is bounded (SECREVIEW_MAX_SCAN_REQUEST_MB,413beyond, read after the token check), as are the number of files (SECREVIEW_MAX_SCAN_FILES) and their size; secrets are masked in the excerpts and in the stored report. - Request bodies of the controller: every body is bounded before it is parsed,
413beyond: 512 KiB for the JSON routes, public ones included (login, SSO), 8 MiB for the pack imports andSECREVIEW_MAX_SCAN_REQUEST_MBfor the pipeline scans. The declaredContent-Lengthis checked first, a chunked body as it is read. - Request bodies of the MCP server: a body on
/mcpis read only after the token check and bounded (413beyond):SECREVIEW_MAX_MCP_REQUEST_MBfor an authenticated client, 64 KiB for a client without a token served by the enrollment server. A rejected token gets its401before its body is read. - Database: SQL queries go through the ORM (bound parameters); input sizes, the number of findings and the pagination are bounded. Every project name is normalized before use: only letters, digits and single dashes reach the database, the logs, the metric labels and the file names. The schema is migrated by Alembic; the HTTP MCP server never changes it and refuses to serve a database that is not at the expected revision.
- Scan rules: the default rules (
default_rules.toml) are copied into the database at first start. If the database cannot be reached or was never initialized, the server applies this file directly: the scan never runs without rules. A default rule can be edited or disabled by an administrator, but not deleted (see Internal rules). - Development standards: their content is handed as is to the AI assistant as instructions;
only the administrators of the controller write them (validated and bounded fields, 200
standards of 50,000 characters at most). The MCP server only reads them, and the task given to
the
development-standardsprompt is fenced so that it cannot escape. The prompt reminds that a standard never prevails over security (see Development standards). - Logs on stderr (stdout is reserved for the protocol with stdio).
Protect the database
The reports hold sensitive information about your vulnerabilities: protect the database (file permissions, dedicated PostgreSQL account, encrypted backups).
Known limits¶
- The pattern scan produces leads and false positives; it replaces neither a SAST, nor a full SCA analysis (no transitive resolution of the manifests, database to keep up to date), nor a human review.
- Rules are line-by-line patterns: they see neither the data flow nor the missing checks (missing authorization, for example). This work remains the agent's, guided by the checklists, and your SAST/SCA tools'.
- The coverage of a review (checklist results,
rules of the scan leads) is declared by the agent: the server only keeps the ids it knows and
marks the skipped items
not_covered, it cannot check that the agent really read the code. - The
authorof a report (developer username) is declared by the client: anyone holding a token of the project can name someone else. Onlysubmitted_by, the label of the token that saved the report, is set by the server (see author of a report). Anauthoris often an email address, that is personal data: it is visible to everyone who reaches the project and kept as long as the report.