Skip to content
Pages in Concepts

Security model and guarantees

The promises Noust makes about itself, how each can be checked, and the failure each one prevents.

Reference Updated View as Markdown

Noust runs as root and deploys code from repositories onto the machine it manages, so what it promises about itself has to be something you can check. This page lists each promise, the failure in other products that it answers (with a source anyone can read), how Noust enforces it, and the tests that fail if it stops being true. It also says, as plainly, what Noust does not promise.

It is not prose that can go stale. The tables below are generated from the code, and tests/test_security_model.py fails when this page, the code or the tests it names disagree:

Shell
python scripts/security_model.py            # check that this page is current
python scripts/security_model.py --write    # regenerate the generated parts

How Noust is built to be safe to operate (the threat model, every control, every file mode) is security.md. The contact for a vulnerability and the timelines are SECURITY.md. The ENS mapping is ENS.md.

What is trusted, in one paragraph

Root on the machine, and whoever can run noust as root, are trusted: Noust does not defend a machine against its own administrator. Accounts with the admin role and tokens that hold root_equivalent can write units, cron commands and site configuration that run as root, which is why those actions ask for sudo mode and, where approvals are on, for a second person. Everything else is treated as hostile input: a repository, a form field, a webhook, a notification URL, a central that has been compromised, an application that has been compromised.

The guarantees

1. No process is started from a string of shell

The failure it answers. Command injection is a class of advisory this kind of product has had: Coolify's GHSA-234r-xrrg-m8f3 (CVE-2025-64419, critical) and Dokploy's GHSA-w223-vw9m-4f9c (CVE-2026-72901, critical, remote code execution) are two. In the Coolify 4.4.0 tree read on 2026-10-13 a remote action is a shell string sent over SSH (instant_remote_process is called 281 times in app/ and bootstrap/) and escapeshellarg appears 451 times in the same files.

The guarantee. Every process Noust starts is an argument list. A string where a list is expected, an empty list and a NUL byte are refused before anything runs; a metacharacter is data. Every call has a timeout. Secrets reach a process through its environment or its standard input, never its command line.

How it is enforced. src/noust/core/runner.py is the only module that imports subprocess (or starts a process any other way); --dry-run is implemented in the same place. Text a person or a repository supplies is judged by the validators listed under Where text reaches the machine before it becomes an argument.

What fails if it stops holding.

  • tests/test_architecture.py::TestExecutionSeam::test_nothing_else_imports_subprocess
  • tests/test_architecture.py::TestExecutionSeam::test_nothing_spawns_a_process_through_asyncio
  • tests/test_architecture.py::TestExecutionSeam::test_no_shell_execution_anywhere
  • tests/test_runner.py::TestArgvOnly::test_rejects_a_string_command
  • tests/test_runner.py::TestArgvOnly::test_rejects_nul_bytes
  • tests/test_runner.py::TestArgvOnly::test_shell_metacharacters_are_literal_data
  • tests/test_runner.py::TestSecrets::test_the_secret_never_appears_in_argv
  • tests/test_adversarial_inputs.py::TestTheRunnerRefusesWhatCannotBeAnArgumentList
  • tests/conftest.py::forbid_real_subprocess

What it does not cover. It removes a class; it is not immunity. A flaw in a validator, in a program Noust calls, or in how an argument is interpreted by that program (a value that starts with - is an option to git) is still possible, which is why each validator is attacked by tests/test_adversarial_inputs.py and why values that could be options are refused or placed after --.

2. The code of a repository does not run as root without a decision on record

The failure it answers. A helper container that mounts the Docker socket makes the permission to deploy equal to root on the host. In the Coolify 4.4.0 tree read on 2026-10-13, the deployment job starts its helper with -v /var/run/docker.sock:/var/run/docker.sock (app/Jobs/ApplicationDeploymentJob.php), and the build commands are the repository's.

The guarantee. Installing dependencies, building and running a deploy hook happen as noust-build (or the application's own account) in a transient systemd unit with a read-only system, no access to Noust's configuration or store, and no new privileges. Before the first sandboxed build a canary proves the sandbox holds, and the build stops, instead of running as root, when it does not (failing closed). Building as root is a per-application decision recorded with a name and a reason. A Compose stack is refused privileged, the Docker socket and a bind mount of Noust's state or of another application's files unless an exception is recorded.

How it is enforced. noust.core.runner.SandboxSpec and deployers/helpers/sandbox.py; deployers/helpers/compose_guard.py for Compose; docs/threat-model.md#builds.

What fails if it stops holding.

  • tests/test_build_sandbox.py::test_the_sandbox_failing_its_self_test_stops_the_build_instead_of_running_as_root
  • tests/test_build_sandbox.py::test_building_as_root_needs_a_reason_where_the_server_asks_for_one
  • tests/test_build_sandbox.py::test_another_applications_env_outside_the_apps_directory_is_inaccessible
  • tests/test_runner_sandbox.py::TestSandboxArgv::test_every_level_one_property_is_there
  • tests/test_sandbox_default.py::test_a_passing_trial_turns_the_sandbox_on_and_the_update_builds_in_it
  • tests/test_sandbox_default.py::test_an_operators_decision_to_build_as_root_is_respected
  • tests/test_compose_guard.py::TestInspect::test_privileged_is_refused
  • tests/test_compose_guard.py::TestInspect::test_the_docker_socket_is_refused_in_either_syntax
  • tests/test_compose_guard.py::TestInspect::test_noust_own_state_is_refused_named_or_contained
  • tests/test_compose_guard.py::TestInspect::test_another_applications_files_are_refused
  • tests/test_monorepo_identity.py::TestANewMonorepo::test_it_is_given_an_account_of_its_own
  • tests/test_repository_symlinks.py::test_the_seam_refuses_to_chmod_through_a_link

What it does not cover. Docker Compose builds run in the Docker daemon, outside any sandbox Noust controls, and applications deployed before 3.1 are tried in the sandbox on their next update, not at once. Applications run as the account of their unit, not inside a container: Noust is not a multi-tenant isolation boundary (see What Noust does not guarantee).

3. A compromised central does not get a shell on the servers it manages

The failure it answers. A panel that holds the keys of its servers and does not check theirs is a shell on every server for whoever takes the panel or sits between it and a server. In the Coolify 4.4.0 tree read on 2026-10-13, the private keys of the servers it manages are written as plain files on its own host (PrivateKey::storeInFileSystem, app/Models/PrivateKey.php:205) and its SSH calls use -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null (app/Helpers/SshMultiplexingHelper.php:426).

The guarantee. A central reaches a node through a tunnel, to its console port and nowhere else: the account is noust-tunnel (no home, no shell, no password), forced to run /usr/bin/false, with no terminal, no agent or X11 forwarding, no reverse or Unix-socket forwarding, and one permitted destination. The node, not the central, sets the ceiling (read, deploy or admin, and host access off by default) and enforces it whatever the central claims. Sudo mode and approvals are vouched for by the central and checked again by the node. A node's host key is pinned at enrolment and a change closes the tunnel.

How it is enforced. fleet/authorize.py (the sshd block and the key options printed below), fleet/policy.py, fleet/tunnels.py, web/api/node_proxy.py; docs/CENTRAL.md.

What fails if it stops holding.

  • tests/test_fleet_guarantees.py::TestTheSshdBlock
  • tests/test_fleet_guarantees.py::TestTheKeyLine
  • tests/test_fleet_authorize.py::TestAuthorizedKeys::test_remote_forwarding_is_pinned_to_a_port_nothing_uses
  • tests/test_fleet_authorize.py::TestTunnelAccount::test_a_missing_account_is_created_without_a_home_a_shell_or_a_password
  • tests/test_fleet_access.py::TestAdmission::test_a_read_ceiling_withholds_writes_from_any_central
  • tests/test_fleet_access.py::TestPermits::test_no_ceiling_reaches_the_node_s_own_accounts_security_or_audit
  • tests/test_fleet_role_admission.py::test_the_nodes_ceiling_narrows_the_role
  • tests/test_fleet_tunnels.py::TestTunnels::test_a_changed_host_key_is_refused_with_ssh_s_words
  • tests/test_node_proxy.py::TestElevation::test_an_elevated_operation_needs_the_centrals_sudo_mode
  • tests/test_fleet_token_scope.py::test_create_api_token_refuses_fleet_with_a_pointer

What it does not cover. The node's API runs as root: a remote code execution in it is a root shell on that node. The claim is that a compromised central gets no shell and no more than the node's own ceiling, not that nodes cannot be attacked. An external review of the tunnel has not been done (see Independent review).

4. Every route declares its permission, and ownership is checked where the data is

The failure it answers. Authorization by convention fails in the route that forgets it. The advisory GHSA-725v-f5gh-22q9 (CVE-2026-57498, critical) against Coolify is a cross-team IDOR: Livewire components accepted a server_id without scoping it to the team while the API did.

The guarantee. Every API route names the permission it needs in web/permissions/routes_<area>.py; one global dependency enforces it and publishes it as x-noust-permission; a route without one fails a test and is refused when the server runs. Every WebSocket does the same, at the handshake. What a team may see is decided in the data layer (ScopedStore), and a resource of another team answers 404 with the body of one that does not exist. A unit Noust did not create cannot be touched through the service manager, whatever the caller.

How it is enforced. web/permissions/ (the maps, enforce.py, scopes.py), core/teams/scoped_store.py, ServiceManager. The matrix of roles and permissions is generated below.

What fails if it stops holding.

  • tests/test_permission_routes.py::TestTheMapsAreComplete::test_every_route_declares_a_permission
  • tests/test_permission_routes.py::TestTheMapsAreComplete::test_every_other_route_authenticates
  • tests/test_permission_routes.py::test_every_route_refuses_a_role_without_its_permission
  • tests/test_permission_routes.py::TestWebSocketsDeclareAPermission::test_every_websocket_route_declares_a_permission
  • tests/test_scope_architecture.py::TestEveryRouteIsHeldToTheScope::test_every_route_is_classified
  • tests/test_scope_architecture.py::TestNoRouterReadsAScopedResourceFromTheStore::test_the_api_asks_the_scoped_store
  • tests/test_scoped_store.py::TestApplications::test_a_member_lists_and_reads_its_teams_applications_only
  • tests/test_service_manager.py::test_update_config_refuses_a_foreign_unit
  • tests/test_service_manager.py::test_follow_logs_refuses_a_foreign_unit
  • tests/test_get_routes_read_only.py::test_no_get_route_is_named_for_an_action
  • tests/test_web_auth.py::test_every_lockout_path_is_a_real_route

What it does not cover. The CLI is not held to roles: it is the emergency channel, requires root, and is audited with the operating-system identity that ran it. A team is a boundary of permissions and organisation, not of execution.

5. Root-equivalent actions need a fresh proof, and sometimes a second person

The failure it answers. A confirmation that stays valid for hours is not a fresh proof. Coolify's password confirmation lasts three hours (password_timeout is 10800 in config/auth.php:113; 4.4.0, read on 2026-10-13); Noust's sudo mode closes fifteen minutes after the last elevated action, and a window it opened never outlives its ceiling.

The guarantee. Five roles with least privilege. Actions that are destructive or root-equivalent need sudo mode, a fresh proof of who is asking with a password or a passkey, for every kind of identity (a single-sign-on user's provider never stands in for the second factor). Where approvals are on, a root-equivalent change waits for a second person, and the approver cannot be the requester. The master token is break-glass, not the normal way in, and is reduced to account recovery under the ENS profile.

How it is enforced. web/api/deps.py (require_elevated), core/accounts/approvals.py, web/permissions/roles.py.

What fails if it stops holding.

  • tests/test_permission_routes.py::TestTheRoles::test_operators_restart_renew_and_deploy_but_do_not_change
  • tests/test_permission_routes.py::TestTheRoles::test_admin_does_not_govern_security_accounts_or_the_audit_under_the_ens_matrix
  • tests/test_web_sudo.py::test_revoking_a_certificate_requires_elevation
  • tests/test_web_sudo.py::test_an_idle_window_closes_fifteen_minutes_after_the_last_elevated_action
  • tests/test_web_oidc.py::TestTheWholeSignIn::test_the_providers_claims_about_its_own_mfa_do_not_skip_ours
  • tests/test_web_oidc.py::TestTheWholeSignIn::test_the_provider_alone_is_never_the_whole_sign_in
  • tests/test_approvals.py::TestExecution::test_only_the_requester_executes
  • tests/test_approvals.py::TestDeciding::test_security_approves_and_the_requester_executes_once

What it does not cover. Four-eyes approval is off by default and a single-person installation cannot use it.

6. The sign-in surface is limited by a client that cannot choose its own identity

The failure it answers. A limit per address means nothing when the client chooses the address it appears to come from. In the Coolify 4.4.0 tree read on 2026-10-13, TrustProxies is *, so X-Forwarded-* is believed from anyone. Its published advisories include a rate-limit bypass (GHSA-688j-rm43-5r8x), reset-link poisoning (GHSA-cgj8-7m5q-x5gv) and a GET that reset a password (GHSA-389w-cc6x-wr2m).

The guarantee. Forwarding headers are believed only from a proxy the operator declared, and by default from nobody. A request is counted before its endpoint runs: per address when it carries no valid credential (an IPv6 client by its /64), per credential when it does; a wrong credential is counted as anonymous and toward the lockout. Failed sign-ins lock the address and the account, separately. A body is capped before authentication. A credential holds a bounded number of WebSockets and event streams, and each is closed after twelve hours. No GET changes state, so the CSRF protection of the other methods is enough. Every account must have a second factor, an authenticator app or a passkey.

How it is enforced. web/auth/ (get_client_ip, RateLimiter, BruteForceProtection, limiter_key), web/server.py (SecurityMiddleware); the numbers are generated below.

What fails if it stops holding.

  • tests/test_web_auth.py::test_rotating_x_forwarded_for_does_not_bypass_lockout
  • tests/test_web_auth.py::test_x_real_ip_does_not_bypass_lockout
  • tests/test_web_auth.py::test_forwarded_header_is_honoured_for_configured_proxies
  • tests/test_web_auth.py::test_mutation_with_cookie_and_no_csrf_token_is_rejected
  • tests/test_web_rate_limit.py::test_a_wrong_credential_is_counted_as_anonymous
  • tests/test_web_rate_limit.py::test_the_lockout_still_stops_credential_guessing
  • tests/test_web_rate_limit_ipv6.py::TestThroughTheConsole
  • tests/test_web_rate_limit_ipv6.py::TestTheLockout
  • tests/test_web_event_stream_limit.py::test_a_credential_holds_a_few_streams_and_the_next_is_refused
  • tests/test_get_routes_read_only.py::test_no_get_route_asks_for_sudo_mode_or_approval_unless_it_is_a_sensitive_read

What it does not cover. A deployment behind a proxy it did not declare sees the proxy's address for everyone, and the console says so. Rate limiting is per process.

7. The browser is held to a strict policy that a real browser tests

The failure it answers. A page with no policy depends on every line of its markup being right. In the Coolify 4.4.0 tree searched on 2026-10-13, no Content-Security-Policy, frame, content-type or transport-security header appears in app/, config/, bootstrap/, routes/ or docker/, and its session cookie is same_site => lax (config/session.php:199). Dokploy has a published clickjacking advisory (GHSA-c94j-8wgf-2q9q).

The guarantee. The console loads nothing from any other origin and runs under a Content Security Policy with no inline script or style and Trusted Types. Every response, including those refused before routing, carries the same hardening headers (printed below). Session cookies are HttpOnly, SameSite=Strict, and Secure over TLS; every mutation made with a session carries a CSRF token. The end-to-end suite fails on any CSP violation and runs axe in both themes.

How it is enforced. web/server.py (HARDENING_HEADERS, CONTENT_SECURITY_POLICY), panel/e2e/ (the gate), docs/security-console.md#the-browser.

What fails if it stops holding.

  • tests/test_console_headers.py::TestEveryKindOfAnswer
  • tests/test_console_headers.py::TestTheTransport
  • tests/test_console_headers.py::TestThePolicyItself
  • tests/test_web_auth.py::test_security_headers_are_present
  • tests/test_web_auth.py::test_hsts_is_sent_when_https_is_required
  • tests/test_web_auth.py::test_login_sets_httponly_samesite_cookie
  • tests/test_web_auth.py::test_cookie_is_secure_over_https
  • tests/test_architecture.py::TestSelfContained::test_no_external_assets
  • tests/test_architecture.py::TestSelfContained::test_the_built_console_references_only_same_origin_assets
  • panel/e2e/fixtures.ts::test

What it does not cover. A policy is enforced by the browser, so a user of a browser that ignores Trusted Types or CSP is protected by less. The header snapshot below is of what the code sends, not of what a proxy in front of it may rewrite.

8. What was done is recorded, tamper-evident, and shipped off the machine

The failure it answers. An audit trail that is deleted, rewritable or lost with the machine cannot answer who did something after an incident. In the Coolify 4.4.0 tree (commit e793472 of 2026-10-06, read on 2026-10-21) the audit_events table has no hash or signature column (database/migrations/2026_08_20_000000_create_audit_events_table.php) and AuditEvent::pruneExpired deletes the rows older than ninety days (app/Models/AuditEvent.php:231-236).

The guarantee. Every change made through the API and every command that changes something is an event from a closed catalogue (a test scans the code for undeclared ones). Each line carries the MAC of the previous one; noust audit verify finds the first broken link; lines are shipped to journald, syslog or standard output as they are written. When the log cannot be written the action still happens (an action must not fail for being recorded), the failure is kept, and noust health and the console report it as an issue, not a warning: under the ENS profile an action that leaves no record is a failure of its own.

How it is enforced. core/audit/ (record, chain, catalog, sinks, status); docs/security-audit.md#audit-log.

What fails if it stops holding.

  • tests/test_audit_catalog.py::test_every_event_the_code_records_is_in_the_catalog
  • tests/test_audit_chain.py::TestWriting::test_two_writers_on_one_file_extend_one_chain
  • tests/test_audit_chain.py::TestVerify::test_an_unchained_line_after_the_chain_is_found
  • tests/test_audit_record.py::TestRecord::test_secrets_never_reach_the_log
  • tests/test_audit_shipping.py::TestJournald::test_every_recorded_event_reaches_journald
  • tests/test_audit_flood.py::test_identified_actors_are_never_held_back
  • tests/test_health_builds_and_audit.py::TestTheAuditTrail::test_a_failing_trail_is_an_issue

What it does not cover. Root holds the chain's key and could rewrite a consistent log: a verified chain proves consistency, not originality, and comparing its head with the copy shipped off the machine is what proves the log original.

9. Secrets are not left in the open, and backups leave encrypted

The failure it answers. A secret on a command line is readable by every local user in ps. In the 4.4.0 tree read on 2026-10-13, Coolify passes a PostgreSQL password to docker exec as -e PGPASSWORD=... and S3 keys to mc alias set (app/Jobs/DatabaseBackupJob.php:687 and :892).

The guarantee. A secret is never in a command line or a log: it travels in the environment or on standard input, and a value Noust knows is scrubbed from build output, job logs and the audit trail. Files that hold one are 0600. A central can seal its keys and tokens under a passphrase that is never stored (scrypt, then AES-256-CBC with an HMAC). A remote destination made with noust backup destination add is encrypted unless --no-encrypt says otherwise; with backup.encryption: required or the ENS profile nothing leaves for an unencrypted destination, and the refusal comes before rclone runs. A backup's metadata is signed and a scheduled backup is deep-verified before it is sent. noust health names the destinations that send in clear.

How it is enforced. core/redact.py, core/sealing.py, managers/backup_destinations.py, managers/security_posture.py.

What fails if it stops holding.

  • tests/test_central_sealing.py::TestLocked::test_a_locked_process_cannot_read
  • tests/test_central_sealing.py::TestTampering::test_a_file_in_clear_in_a_sealed_store_is_refused
  • tests/test_redact.py::test_a_build_that_echoes_a_secret_leaves_no_secret_in_the_history
  • tests/test_redact.py::TestScrubber::test_leaves_short_values_alone
  • tests/test_ens_backups.py::TestTheSidecarMac::test_archive_and_checksum_replaced_together_are_caught
  • tests/test_ens_checks.py::TestFailures::test_an_unencrypted_destination_fails
  • tests/test_cli_backup_destinations.py::test_a_remote_destination_is_encrypted_unless_told_otherwise
  • tests/test_security_posture.py::TestBackupsLeaveEncrypted
  • tests/test_security_posture.py::TestTheCentralsKeys
  • tests/test_backup_destination_hosts.py::TestConfiguringADestination
  • tests/test_runner.py::TestSecrets::test_the_secret_never_appears_in_argv

What it does not cover. The scrubber works by value: it does not recognise a secret it was not told, and it leaves values shorter than six characters alone. config.yaml holds SMTP and destination secrets in clear, 0600; a key beside the ciphertext does not protect from a compromised host, which is why the central can be sealed. Losing a backup's encryption key loses the backup.

10. A release can be checked, and a fix is announced with its release

The failure it answers. A release nobody can check is trusted on the word of the channel it came from, and a fix announced after its release leaves everyone who updates on time unwarned. In the 4.4.0 tree read on 2026-10-13, Coolify updates itself by downloading a script and running it with bash (app/Actions/Server/UpdateCoolify.php:122-131), and its SECURITY.md has a contact address and no timeline. On 2026-10-13 Coolify had published 70 advisories (21 critical) and Dokploy 60 (34 critical), counted from the published advisories of each repository.

The guarantee. Every release is built by GitHub Actions from its tag, signed from 4.0.0 on with the maintainer's SSH key. Its files have a signed checksum file and a build-provenance attestation; the wheel has its SBOM attested; the container image is signed and attested by digest, and a fixable critical vulnerability in it stops the release; PyPI publishes with attestations; the installer is published with its own signed checksum. The update the product performs is one fixed command per installation method; nothing of the request reaches the command line. A release that fixes a vulnerability is titled (security release), which the update check, noust health and the console report. SECURITY.md commits to acknowledging in 3 working days, confirming in 10 and fixing in 90.

How it is enforced. .github/workflows/release.yml, .github/workflows/security.yml (CodeQL, gitleaks, Trivy, dependency review), scripts/release.py, SECURITY.md, .well-known/security.txt, managers/self_update.py.

What fails if it stops holding.

  • tests/test_security_workflows.py::TestTheReleaseIsSignedAndAttested
  • tests/test_security_workflows.py::TestTheSecurityWorkflow
  • tests/test_security_workflows.py::TestPinning
  • tests/test_security_workflows.py::TestASecurityReleaseSaysSo
  • tests/test_security_policy.py::TestSecurityTxt
  • tests/test_release_script.py::test_the_real_security_policy_lists_the_current_line
  • tests/test_sbom_script.py::TestTheSbom::test_the_real_lockfile_reads
  • tests/test_security_release.py::TestReadingTheReleases
  • tests/test_security_release.py::TestTheUpdateCheckAsksOnlyWhenThereIsSomethingNewer
  • tests/test_self_update.py::TestStart::test_each_method_has_its_one_command

What it does not cover. The pip and pipx update path trusts TLS and PyPI (and the attestations PyPI shows) without pinning hashes; the OBS repositories are signed by their own key. No independent review has been done.

11. A compliance profile is one module that every area reads

The failure it answers. A hardening profile that each area applies for itself drifts: the profile is whatever the last area decided. A profile that is one module every area reads can be checked in one place.

The guarantee. security.profile: ens-medium fixes, in one module, the values Spain's Esquema Nacional de Seguridad (RD 311/2022) expects of a category MEDIUM system: sessions, lockout, passwords, token lifetimes, audit retention, approvals, reasons for changes, backup encryption, the console's certificate. noust ens check compares the server with it and noust ens report writes the evidence.

How it is enforced. core/ens/profile.py; the mapping control by control is docs/ENS.md.

What fails if it stops holding.

  • tests/test_ens_profile.py::TestEveryAreaReadsTheOneModule
  • tests/test_ens_profile.py::TestTheProfilesNumbers::test_outside_the_profile_nothing_is_forced
  • tests/test_ens_checks.py::TestFailures::test_an_old_or_unverified_backup_warns_and_a_missing_one_fails
  • tests/test_ens_incident.py::TestThePackage::test_every_file_is_in_the_manifest_with_its_sha256

What it does not cover. It is a mapping of controls to settings, not a certification, and the organisational measures stay the organisation's.

Where text from a person reaches the machine

Noust turns text it was given into paths, argument lists, unit files, web server configuration and SQL. There is no list of bad strings: each class of text is judged by an allowlist at one place, and each place is attacked by tests/test_adversarial_inputs.py with generated text and with the payloads that have beaten validators before. Two properties are held for every one: it answers or refuses with its own error (never a crash on hostile input), and what it accepts is inert where it goes. Two defects were found this way when the suite was written, both in code that claimed to refuse what it accepted: a pattern ending in $ matches before a trailing newline, so validate_app_name("shop\n") was accepted (a ratchet, tests/test_regex_anchors.py, now stops a new one), and a domain was checked after being stripped and returned before.

The database server, not Noust, enforces what a SQL statement may do: on PostgreSQL, MySQL and MariaDB the console's read mode runs as an account that can only SELECT, in a read-only transaction, and a keyword list is not asked to do that job. MongoDB and Redis have no read-only mode, so every statement there is a write that needs databases.write and sudo mode.

Class Comes in as Judged by Goes to Accepted means Attacked by
Application, service, database and file names name of an application, service, database, database user, upload or backup file noust.validators.names:validate_app_name a directory under the apps directory, a unit file name, a SQL identifier one path segment of letters, digits and ._@-, starting with an alphanumeric, at most 64 characters (63 for SQL); never ., .. or a reserved device name; a trailing newline is refused tests/test_adversarial_inputs.py::TestNamesThatBecomePathsUnitsAndSql
Paths inside a directory persistent paths, release and backup member names, a file inside an application noust.validators.names:resolve_within a file the process opens or writes as root a path that, resolved (symlinks included), is strictly inside the base directory tests/test_adversarial_inputs.py::TestPathContainment
Domains and host names domain of an application, site, preview or node noust.validators.domain:validate_domain web server server_name, certbot -d, a directory and a unit name lowercase letters, digits, hyphens and dots; labels of at most 63 characters and 253 in all; scheme, path and port are cut before the check tests/test_adversarial_inputs.py::TestDomains
Environment variable names and values, and unit values env_vars, .env entries, a unit's description, command and working directory noust.validators.environment:validate_unit_value EnvironmentFile=, Environment= and every directive of a systemd unit no control character, so a value cannot end its line; names are POSIX identifiers; a value is escaped for the quote it sits in tests/test_adversarial_inputs.py::TestEnvironmentAndUnitValues
tests/test_adversarial_inputs.py::TestTheUnitTemplateKeepsEveryValueOnItsOwnLine
Sources: Git remotes, refs, tags, commits and archives source, branch, tag, commit of an application; an archive URL noust.validators.source:validate_source git and git clone arguments, a directory name, an outbound HTTP request an http(s), ssh or git URL, an absolute local path or a download URL; never a remote helper (ext::, fd::), a value starting with -, a control character or a host inside the machine's own network tests/test_adversarial_inputs.py::TestSources
tests/test_source_archive_hosts.py::TestTheDownload
Hosts a request is sent to a notification URL, a backup destination host, a source archive URL, a repository index noust.core.outbound:require_public_host an outbound connection made as root a host whose every address is outside loopback, private, link-local and the other networks of the machine, unless the operator named it tests/test_outbound.py::TestRequirePublicHost
tests/test_backup_destination_hosts.py::TestConfiguringADestination
tests/test_source_archive_hosts.py::TestTheRedirects
Health check path and ports health_path, port, a container's published port noust.validators.health:check_health_path the request line of the deploy's health gate; a socket a path on the application (not a host), printable ASCII only; a port from 1 to 65535 tests/test_adversarial_inputs.py::TestHealthCheckPath
tests/test_adversarial_inputs.py::TestPorts
Container images and options image, a digest, build arguments, volumes, a restart policy, a registry noust.validators.container:check_image_reference arguments of docker and a generated compose file a reference that cannot be read as an option, with no whitespace or URL scheme tests/test_adversarial_inputs.py::TestContainerReferences
Identifiers Noust made a release id, a backup id, a destination name in a path parameter noust.core.appdir.releases:is_release_id a directory or an archive read or deleted as root exactly the shape Noust generates, matched against the whole string tests/test_adversarial_inputs.py::TestIdentifiersOfThingsNoustWrote
Command lines of any process every argument list Noust builds, including the repository's hooks and build commands noust.core.runner:_validate execve a sequence of strings, never one string; not empty; no NUL byte tests/test_adversarial_inputs.py::TestTheRunnerRefusesWhatCannotBeAnArgumentList

The one place text deliberately reaches a configuration as written is custom_directives in a repository's noust.nginx.yaml, which goes into the site's server block behind only the web server's own test: read it as you would read a site before deploying a repository you do not control.

The numbers behind the guarantees

Who may do what

The roles Noust ships, and the permissions each holds out of the box. A server has one list of roles, which its operator edits (GET /api/roles); the columns are what a server is seeded with, the administrator as the ENS review decided it, and the protected owner. The master token is not a role: while no account exists it holds every permission, afterwards (it is break-glass) all but audit.manage, and under the ENS profile only what recovering an account takes.

Permission What it allows viewer operator admin (ships) security auditor admin (ENS review) owner
accounts.manage Create, change, lock and remove accounts; others' sessions yes yes yes
accounts.read See accounts and separation-of-duties exceptions yes yes yes yes
apps.deploy Update, roll back, activate a release, rebuild yes yes yes yes
apps.manage Create, configure and delete applications, sites and domains yes yes yes
apps.operate Start, stop and restart; renew certificates; run cron jobs yes yes yes yes
apps.read See applications, sites, certificates, jobs, logs and deployments yes yes yes yes yes yes yes
apps.terminal A terminal and one-off commands as an application's own account, with its secrets yes yes yes
audit.manage Manage the audit log and activity history yes yes yes
audit.read Read the whole audit log yes yes yes yes
backups.manage Restore, delete, and configure destinations yes yes yes
backups.read See backups, schedules and destinations yes yes yes yes yes yes yes
backups.run Create and verify backups yes yes yes yes
compliance.read Read the ENS compliance check and its evidence report yes yes yes yes yes
databases.manage Install engines, drop databases, restore dumps yes yes yes
databases.read See database engines, databases and users yes yes yes yes yes yes yes
databases.write Create databases and users, run queries yes yes yes
fleet.manage Add and remove servers yes yes yes
fleet.read See the servers this central manages yes yes yes yes yes yes yes
root_equivalent Raw units, cron commands, backup hooks, raw site configuration yes yes yes
secrets.reveal Read secrets in clear: .env values, exports, keys yes yes yes
security.manage Change security settings: console access, sign-in policy yes yes yes
self Own session, second factor, password, tokens and usage notice yes yes yes yes yes yes yes
server.host_access Change the host itself: SSH, firewall, packages yes yes yes
server.manage Manage the server: services, the monitor yes yes yes
server.read See the server: system, services, metrics, monitor yes yes yes yes yes yes yes
server.terminal A root shell on the server itself, in the browser yes yes yes
settings.manage Change the configuration outside security settings yes yes yes
settings.read Read the configuration, secrets redacted yes yes yes yes yes yes yes
teams.manage Create and delete teams, projects and environments, and their members yes yes yes
teams.read See the teams, projects and environments one belongs to yes yes yes yes yes yes yes

The routes

  • 1087 API operations. 1045 need a permission (x-noust-permission) and 42 are public on purpose (the sign-in, the health probe, the forge webhooks, which check their own credential); a route that is neither fails a test and is refused when the server runs.
  • 364 operations need sudo mode and 40 can need a second person.
  • 6 WebSocket routes, each with its permission, checked at the handshake.
Permission Operations
accounts.manage 37
accounts.read 15
apps.deploy 36
apps.manage 146
apps.operate 34
apps.read 148
apps.terminal 12
audit.manage 1
audit.read 10
backups.manage 28
backups.read 25
backups.run 14
compliance.read 4
databases.manage 53
databases.read 69
databases.write 13
fleet.manage 51
fleet.read 26
public 42
root_equivalent 18
secrets.reveal 16
security.manage 8
self 41
server.host_access 21
server.manage 57
server.read 78
server.terminal 1
settings.manage 34
settings.read 25
teams.manage 14
teams.read 10

The browser

Header Value
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-origin
Permissions-Policy accelerometer=(), bluetooth=(), browsing-topics=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), hid=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), serial=(), usb=()
X-Permitted-Cross-Domain-Policies none
Strict-Transport-Security (over TLS, direct or through a declared proxy) max-age=31536000; includeSubDomains
Cache-Control no-store, except hashed files under /assets/: public, max-age=31536000, immutable

The policy:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self' https://github.com;
  object-src 'none';
  require-trusted-types-for 'script'

Browser features switched off for the console: accelerometer, bluetooth, browsing-topics, camera, display-capture, geolocation, gyroscope, hid, interest-cohort, magnetometer, microphone, midi, payment, serial, usb.

Limits

Limit Default
Requests without a valid credential, per client (an IPv6 client by its /64) 120 per 60 seconds
Requests with a valid credential, per credential 1200 per 60 seconds
Failed sign-ins before an address, and separately an account, is locked 5, for 15 minutes
A request body 1 MiB (5 MiB for a forge webhook)
Open WebSockets per credential 8
Open event streams per credential 16, each closed after 12 hours
Bad webhook signatures before an application's hook is locked per application, for 15 minutes

The fleet's tunnel

What noust fleet authorize writes on a node so that the central it enrols can reach the node's console and nothing else:

In sshd_config (a drop-in that noust fleet authorize writes, validates with sshd -t and puts back if sshd refuses it):

Match User noust-tunnel
    AuthorizedKeysFile /etc/ssh/noust/%u.keys
    PubkeyAuthentication yes
    AuthenticationMethods publickey
    DisableForwarding no
    AllowTcpForwarding local
    AllowStreamLocalForwarding no
    PermitOpen 127.0.0.1:8443
    PermitListen none
    GatewayPorts no
    PermitTunnel no
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    PermitUserRC no
    ForceCommand /usr/bin/false

And on the central's key, the same limits repeated where they cannot be overridden by another part of the configuration:

restrict,port-forwarding,permitopen="127.0.0.1:8443",permitlisten="127.0.0.1:1",command="/usr/bin/false" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlaceholderPlaceholderPlaceholderPlaceho noust-central:central-1

The audit trail

The catalogue holds 520 events.

Category Events
access 48
account 40
config 30
change 340
read 21
denial 16
system 22
host 3

Compared with Coolify and Dokploy

Dated 2026-10-13, against Coolify's main (its own version string reads 4.4.0) and Dokploy's published advisories. Every cell of the Coolify column was read in that source tree, at the path given; Dokploy's source was not read, so nothing is claimed of it beyond what its advisories say. It compares designs; it is not a ranking, and a project with more users and more researchers has more advisories whatever its code.

Coolify Dokploy Noust
Published security advisories (Coolify, Dokploy) 70: 21 critical, 26 high, 15 medium, 8 low 60: 34 critical, 17 high, 8 medium, 1 low none published, and see What Noust does not guarantee
A remote action a shell string sent over SSH (instant_remote_process, 281 calls; escapeshellarg, 451) not read an argument list through one module; no shell anywhere
SSH host keys not checked: StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null (app/Helpers/SshMultiplexingHelper.php:426) not read pinned at enrolment; a change closes the tunnel
What a central or panel holds the servers' private keys as plain files on its host (app/Models/PrivateKey.php:205) not read a key that can only open a tunnel to the console port; the central's secrets can be sealed
Build helper mounts the Docker socket (app/Jobs/ApplicationDeploymentJob.php:2752) not read a transient unit as an unprivileged account, read-only system, no socket
Forwarding headers trusted from anyone (TrustProxies::$proxies = '*', app/Http/Middleware/TrustProxies.php) not read from a declared proxy only, nobody by default
Session cookie same_site is lax (config/session.php:199) not read SameSite=Strict, HttpOnly, Secure over TLS
Security headers in the repository (CSP, frame, content type, HSTS) none found in app/, config/, bootstrap/, routes/, docker/ a clickjacking advisory (GHSA-c94j-8wgf-2q9q) on every response, printed above
Database password and S3 keys in a docker exec command line (app/Jobs/DatabaseBackupJob.php:687, :892) not read environment or standard input, never argv
How it updates itself downloads upgrade.sh and runs it with bash (app/Actions/Server/UpdateCoolify.php:122-131) not read one fixed command per installation method; nothing of the request in it
Disclosure policy SECURITY.md: a contact address, no timeline not read 3 working days to acknowledge, 10 to confirm, 90 to fix, safe harbour
Signed releases no signing, attestation or SBOM step found in .github/ not read signed checksums, build provenance, SBOM and image attestations

What Noust does not guarantee

These are the limits, stated before you find them.

  • Noust requires root, and its API runs as root. A remote code execution in the console is a root shell on that machine. The claims above are that a compromised central gets no shell and no more than a node's own ceiling, and that code from a repository does not run as root, not that the console cannot be attacked. Noust never offers a "non-root mode" that only looks like one.
  • It is not a multi-tenant isolation boundary. An application created since 3.2 (and a monorepo since 4.0) runs as its own account and cannot read another's files; one from before keeps the shared account until noust app identity migrate moves it. Applications still share the kernel, the network and the machine, and a team is a boundary of permissions, not of execution. A Compose stack is kept to its own files by the compose check, but its processes are the containers' and run as the users their images say.
  • The absence of published advisories is not evidence of safety. Noust has none; the products it is compared with have many because many people look at them. Until independent people have looked at Noust, "fewer advisories" would be a statement about attention, not about security.
  • Whole classes are removed by construction; the product is not immune. Shell injection, a route without a permission and an unscoped query are made hard to write and caught by tests. A logic flaw in an authorization rule, in a validator or in a program Noust calls is still possible.
  • The audit log proves consistency, not originality, against root: root holds the chain's key. Ship it off the machine (audit.syslog) and compare the head.
  • A redactor works by value. It scrubs what Noust knows is a secret and nothing it does not, and it leaves values shorter than six characters alone.
  • A key beside the ciphertext does not protect from a compromised host. config.yaml holds SMTP and notification secrets in clear, 0600, and the central can be sealed with noust central seal; neither helps if the attacker is root on the same machine. What is guaranteed is about the copies: no read of the API, no noust store export and no incident package carries a secret value unless it was asked for and is audited (docs/security.md).
  • security.profile: ens-medium is a mapping, not a certification.
  • Dated. The counts of other products' advisories are those of 2026-10-13, read from GitHub's published advisories of each repository. Nothing here is a ranking: advisories count attention as much as flaws, and a fix in a release's notes without an advisory is invisible to a count.

Independent review

No external penetration test has been done, so none is claimed. If you are going to look, these are the places the guarantees depend on most, in the order that matters:

  1. The fleet tunnel (guarantee 3): no shell, no reverse or Unix-socket forwarding, the ceiling a compromised central cannot exceed, and the host-key pin.
  2. Authorization of every route (guarantee 4), including teams and the WebSockets.
  3. The build sandbox and the compose check (guarantee 2).
  4. Sign-in: the passkey and single-sign-on paths, sudo mode and approvals (guarantee 5).
  5. The update path and the installer (guarantee 10).

Anything found on your own installation, reported as SECURITY.md describes, is welcome and credited. A summary of any review that is done will be published here with its date and scope.

Verifying a release

A release is built by GitHub Actions from its tag (signed from 4.0.0 on with the maintainer's SSH key), and what it publishes can be checked without trusting whoever uploaded it. VERSION below is the release, for example 4.0.0.

The files of a release (wheel, source distribution, Debian packages, SBOM, installer) have one checksum file, signed by the release workflow with a keyless Sigstore certificate that names the workflow:

Shell
base=https://github.com/Perkybeet/noust/releases/download/vVERSION
curl -fsSLO "$base/noust-VERSION.SHA256SUMS"
curl -fsSLO "$base/noust-VERSION.SHA256SUMS.sig"
curl -fsSLO "$base/noust-VERSION.SHA256SUMS.pem"
 
cosign verify-blob \
  --certificate noust-VERSION.SHA256SUMS.pem \
  --signature noust-VERSION.SHA256SUMS.sig \
  --certificate-identity-regexp '^https://github.com/Perkybeet/noust/\.github/workflows/release\.yml@refs/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  noust-VERSION.SHA256SUMS
 
sha256sum -c --ignore-missing noust-VERSION.SHA256SUMS      # after downloading the files you want

Each file also has a build-provenance attestation in GitHub, and the wheel has its SBOM attested:

Shell
gh attestation verify noust-VERSION-py3-none-any.whl --repo Perkybeet/noust
gh attestation verify noust-VERSION-py3-none-any.whl --repo Perkybeet/noust \
  --predicate-type https://cyclonedx.org/bom

The central's container image is signed and attested by digest:

Shell
cosign verify \
  --certificate-identity-regexp '^https://github.com/Perkybeet/noust/\.github/workflows/release\.yml@refs/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/perkybeet/noust:VERSION
 
gh attestation verify oci://ghcr.io/perkybeet/noust:VERSION --repo Perkybeet/noust

The installer is verified with its own SHA256SUMS, signed the same way: see PLATFORMS.md. The Debian, Ubuntu, Fedora and openSUSE repositories are signed by their repository key, and noust on PyPI is published by trusted publishing with attestations, which PyPI shows on the release's page.

A release that fixes a vulnerability is titled (security release) on GitHub, and an installation that is behind one says so in noust health, in its CLI banner and in Settings > About.

The wheel and the source distribution can also be rebuilt from the tag and compared byte for byte with the files of the release: docs/reproducible-builds.md.

How this page is kept true

  • The parts between <!-- generated:NAME --> markers are written by scripts/security_model.py from the code: the roles from web/permissions/roles.py, the headers from the middleware that sends them, the routes from the application's own OpenAPI document, the sshd block from the function that writes it.
  • Every test named under a guarantee must exist; the script checks it.
  • The check is a test, tests/test_security_model.py, so CI and the release gate run it with the rest of the suite: a release does not go out with a page that disagrees with the code.
  • When you change a guarantee, change it in scripts/security_model.py, regenerate, and change the test that proves it in the same commit.

Something on this page is wrong or unclear? Report it on GitHub.

Help, contributing and the project’s policies are under Project.

Search the documentation

Type to search every page.

↑ ↓ to move, Enter to open, Esc to close