diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 90c8a18..b261ec8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -74,6 +74,18 @@ jobs: { printf -- "$front"; cat _app/CHANGELOG.md; } > source/_reference/changelog.md { printf -- "$front6"; cat _app6/CHANGELOG.md; } > source/v6/_reference/changelog.md + # The published security advisories of the app, for the Security advisories page + # (source/_security_assessment/security-assessment/advisories.md). Fetched on every build, so a + # new advisory reaches the site with the next build; a failed fetch fails the build rather than + # publishing a list that silently lost its entries. + - name: Bring in the security advisories + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api 'repos/ansibleforms/ansibleforms/security-advisories?state=published&per_page=100' > source/_data/advisories.json + echo "$(jq length source/_data/advisories.json) published advisories" + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.gitignore b/.gitignore index 058b7dc..cf6ce30 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ vendor/ _app/ _app6/ source/_data/help.yaml +source/_data/advisories.json # A language other than English is assembled here before it is built (scripts/build-language.sh) _build/ diff --git a/i18n/es.yml b/i18n/es.yml index ffe9343..1a72e08 100644 --- a/i18n/es.yml +++ b/i18n/es.yml @@ -27,6 +27,8 @@ just_the_docs: name: Guía de usuario integrate: name: Integrar + security_assessment: + name: Seguridad reference: name: Referencia diff --git a/source/_config.yml b/source/_config.yml index dcfe759..9c2ee20 100644 --- a/source/_config.yml +++ b/source/_config.yml @@ -126,6 +126,9 @@ collections: integrate: permalink: "/:path:output_ext" output: true + security_assessment: + permalink: "/:path:output_ext" + output: true reference: permalink: "/:path:output_ext" output: true @@ -156,6 +159,10 @@ just_the_docs: name: Integrate icon: plug nav_fold: true + security_assessment: + name: Security + icon: shield-halved + nav_fold: true reference: name: Reference icon: book diff --git a/source/_security_assessment/security-assessment/advisories.md b/source/_security_assessment/security-assessment/advisories.md new file mode 100644 index 0000000..6c1cec5 --- /dev/null +++ b/source/_security_assessment/security-assessment/advisories.md @@ -0,0 +1,79 @@ +--- +layout: default +title: Security advisories +nav_order: 5 +--- + +# Security advisories +{: .no_toc } + +Every published vulnerability, its severity and its fix +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +--- + +## Published advisories + +Read from the [Security tab of the repository](https://github.com/ansibleforms/ansibleforms/security/advisories) at every build of this site: + +{% assign af_advisories = site.data.advisories | sort: "published_at" | reverse %} +{% if af_advisories.size > 0 %} + + + + + + + + + + + + + {% for a in af_advisories %} + + + + + + + + + {% endfor %} + +
AdvisorySeverityWeaknessAffectedFixed inPublished
+ {{ a.summary | escape }}
+ {{ a.ghsa_id }}{% if a.cve_id %}
{{ a.cve_id }}{% endif %} +
+ {{ a.severity | capitalize }} + {%- assign af_cvss = a.cvss_severities.cvss_v3 | default: a.cvss -%} + {%- if af_cvss.score %}
CVSS {{ af_cvss.score }}{% endif %} +
+ {%- for w in a.cwes %}{{ w.cwe_id }}{% unless forloop.last %}
{% endunless %}{% endfor -%} +
{% for v in a.vulnerabilities %}{{ v.vulnerable_version_range }}{% unless forloop.last %}
{% endunless %}{% endfor %}
{% for v in a.vulnerabilities %}{{ v.patched_versions | default: "-" }}{% unless forloop.last %}
{% endunless %}{% endfor %}
{{ a.published_at | date: "%Y-%m-%d" }}
+{% else %} +No advisory has been published. +{% endif %} + +Each advisory on GitHub gives the details: the impact, the conditions, the CVSS vector and the fix. + +--- + +## What this means for your version + +An advisory names the versions it affects and the first version that is fixed. The fix ships in the supported releases, +[7.x and 6.5.x](development.html#supported-versions), and older releases are not patched: upgrading is the fix. + +Compare the version of an instance (**About** in the help menu, or `GET /api/v2/version`) with **Affected**. + +--- + +## Being told of new advisories + +GitHub notifies the watchers of the repository when an advisory is published: + +* On [github.com/ansibleforms/ansibleforms](https://github.com/ansibleforms/ansibleforms), choose **Watch > Custom > Security alerts**. +* The [changelog](../changelog.html) lists every security fix under the release that ships it. diff --git a/source/_security_assessment/security-assessment/development.md b/source/_security_assessment/security-assessment/development.md new file mode 100644 index 0000000..b5b66a3 --- /dev/null +++ b/source/_security_assessment/security-assessment/development.md @@ -0,0 +1,91 @@ +--- +layout: default +title: Secure development +nav_order: 3 +--- + +# Secure development +{: .no_toc } + +How changes are checked and released, and what is supported +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +--- + +## The source code + +AnsibleForms is open source, so everything on this page can be checked in the repository itself: + +* The code, its history and its build pipelines are public on [github.com/ansibleforms/ansibleforms](https://github.com/ansibleforms/ansibleforms). +* The [license](https://github.com/ansibleforms/ansibleforms/blob/main/LICENSE) is GPL-3.0. +* The [changelog](../changelog.html) lists every change of every release, security fixes included. + +--- + +## How a change is merged + +Every change reaches `main` through a pull request, under a ruleset that nobody can bypass: + +| Rule | What it means | +|---|---| +| Pull request required | Nothing is pushed to `main` directly | +| Required checks | The pull request merges only when every check below passes | +| No force push, no deletion | The history of `main` cannot be rewritten or removed | +| Linear history | Every pull request becomes one commit, so each change can be traced and reverted on its own | + +The checks a pull request must pass: + +| Check | What it does | +|---|---| +| Client, Server | The unit tests of the web interface and of the server | +| CodeQL | GitHub's static analysis of the JavaScript and TypeScript code, for injection, cross-site scripting and other weaknesses | +| Public schema is up to date | The database schema the code expects matches the one it ships | +| Title, Branch | The pull request follows the naming rules, so the changelog stays accurate | + +Two more run on every pull request and report on it: + +* **Database** tests the server on MySQL and MariaDB, and **Browser** drives the interface in a browser. +* **Dependency review** blocks a new dependency with a known high or critical vulnerability. + +--- + +## Code analysis and dependencies + +The code and what it depends on are checked again after they merge: + +* **CodeQL** also analyses every push to `main` and to the release branches, and the whole code base every week: + a new CodeQL rule then also checks the code merged before it. +* **Dependabot** opens a pull request for each new version of a dependency: the npm packages of the server and the + interface, the Python packages of the runtime environment, and the GitHub Actions of the pipelines every week, and + the base images of the containers every day. +* The base images are pinned by digest (`node:26-trixie-slim@sha256:...`), so a build uses exactly the reviewed image. + Dependabot moves the digest when the image is rebuilt upstream. + +--- + +## Releases and images + +A release is built and published by GitHub Actions only, never from a developer's machine: + +* A release is cut from `main` (or `release/6.x`), with a changelog built from its pull requests. +* The images are built by the [Publish workflow](https://github.com/ansibleforms/ansibleforms/blob/main/.github/workflows/publish.yml) + and pushed to the GitHub Container Registry, `ghcr.io/ansibleforms/ansibleforms`, with the runtime environment images + next to it. The workflow and its logs are public, so anyone can see what went into an image. +* The application and runtime environment images run as the unprivileged `node` user (uid 1000), never as root. + +--- + +## Supported versions + +Security fixes go into the newest release of each supported line: + +| Version | Receives security fixes | +|---|---| +| 7.x | Yes | +| 6.5.x | Yes | +| Older than 6.5 | No: upgrading is the fix | + +The [upgrade guide](../upgrading/) covers moving to a newer release, and [Upgrading to v7](../upgrade-7.html) the move from 6.5. diff --git a/source/_security_assessment/security-assessment/faq.md b/source/_security_assessment/security-assessment/faq.md new file mode 100644 index 0000000..731a656 --- /dev/null +++ b/source/_security_assessment/security-assessment/faq.md @@ -0,0 +1,85 @@ +--- +layout: default +title: Assessment FAQ +nav_order: 6 +--- + +# Assessment FAQ +{: .no_toc } + +Short answers to the questions security questionnaires ask most +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +--- + +## Data and hosting + +Where the data lives, and who can reach it: + +| Question | Answer | +|---|---| +| Where is our data stored? | In the MySQL or MariaDB database you run, and in the persistent folder of your instance. AnsibleForms is self-hosted, with no cloud service behind it. | +| Does it send data to its makers? | No. There is no telemetry, no usage reporting and no update check. | +| What does it connect to? | Only the systems an administrator configures: see [Outbound connections](measures.html#outbound-connections). | +| Does it use AI or send data to a model provider? | Only with the [chat assistant](../chat/) on, which is off by default. It then sends form definitions and the values being filled in to the provider you configure. | +| Can it run without internet access? | Yes. The application needs no internet access; the playbooks need whatever their tasks reach. | + +--- + +## Encryption + +What is encrypted, and with which keys: + +| Question | Answer | +|---|---| +| Is data encrypted in transit? | With [HTTPS](../security/https.html), served by the application or by your reverse proxy, and `wss://` between the application and its runtime environments. | +| Is data encrypted at rest? | Every stored secret is encrypted by the application with AES-256. Jobs and their output are stored as they are: use the database's own encryption at rest. See [Stored secrets](measures.html#stored-secrets). | +| Who holds the keys? | You do: the [`ENCRYPTION_SECRET`](../security/secrets.html) is set by you, kept out of backups, and can be rotated. | +| Can secrets stay in our vault? | Yes: [HashiCorp Vault and CyberArk](../secret-stores/) are read when a job needs them. | + +--- + +## Access and accountability + +Who can sign in, what they can do, and what is recorded: + +| Question | Answer | +|---|---| +| Does it support single sign-on? | Yes: LDAP / Active Directory, OpenID Connect and Entra ID. See [Authentication](../authentication/). | +| Does it support multi-factor authentication? | Through single sign-on: your identity provider's MFA applies. Local accounts have none of their own. | +| How are permissions managed? | With roles mapped to groups, checked by the server on every request. See [Permissions](measures.html#permissions). | +| Is there an audit log? | Yes: every change and every refused attempt, kept 365 days by default and forwardable to syslog. See [Audit trail](measures.html#audit-trail). | +| Is there a default password? | The first admin account has a public default password, which must be changed at the first sign-in. See [The admin account](../security/hardening.html#the-admin-account). | + +--- + +## Vulnerabilities and updates + +How problems are found, fixed and announced: + +| Question | Answer | +|---|---| +| Have vulnerabilities been found? | Yes. Every published one is listed in [Security advisories](advisories.html), with the version that fixed it. | +| How do we report one? | Privately, through GitHub: see [Reporting a vulnerability](reporting.html). | +| How is the code checked? | CodeQL static analysis and dependency review on every pull request, automated tests, and Dependabot updates. See [Secure development](development.html). | +| Which versions get security fixes? | 7.x and 6.5.x. See [Supported versions](development.html#supported-versions). | +| How do we hear of a new advisory? | Watch the repository's security alerts on GitHub. See [Being told of new advisories](advisories.html#being-told-of-new-advisories). | +| Has it been penetration tested? | No third-party penetration test report is published. The code is public, so your own team or tester can assess it, and findings are reported as above. | + +--- + +## Images and supply chain + +Where the images come from, and how to check them: + +| Question | Answer | +|---|---| +| Where are the images published? | On the GitHub Container Registry, `ghcr.io/ansibleforms/ansibleforms`, built by public GitHub Actions workflows. See [Releases and images](development.html#releases-and-images). | +| Do the containers run as root? | No: as the unprivileged `node` user. | +| Are the images signed? | Not yet. Pull them by digest (`@sha256:...`) to be sure you run the image you reviewed. | +| Is there an SBOM? | None is published with the images yet. A scanner such as Trivy or Syft builds one from the image. | +| Are the images scanned for vulnerabilities? | Not in the release pipeline. Their base images are pinned by digest and kept current by Dependabot; scan the images in your own registry to apply your own policy. | +| What license is it under? | GPL-3.0, open source, without warranty. | diff --git a/source/_security_assessment/security-assessment/index.md b/source/_security_assessment/security-assessment/index.md new file mode 100644 index 0000000..4357493 --- /dev/null +++ b/source/_security_assessment/security-assessment/index.md @@ -0,0 +1,39 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /security-assessment/ +--- + +# Security assessment +{: .no_toc } + +For security reviews: measures, development and advisories +{: .fs-6 .fw-300 } + +--- + +## In short + +AnsibleForms runs playbooks and holds their credentials, so it is part of your trust chain: + +* It is **self-hosted**: it runs on your infrastructure, keeps its data in your database, and sends nothing to its makers. +* It is **open source** (GPL-3.0): the code, its pipelines and every advisory are public. +* Every stored secret is **encrypted**, every change **audited**, and **roles** checked on every request. +* Vulnerabilities are **reported privately**, fixed in every supported release, and **published**. + +--- + +## In this section + +The pages answer the questions of a security review, from the design to the CVE list: + +| Page | What it covers | +|---|---| +| [Security measures](measures.html) | Sign-in, permissions, stored secrets, the audit trail, the browser, the server, connections and containers | +| [Secure development](development.html) | How a change is reviewed and tested, code and dependency analysis, how images are built, supported versions | +| [Reporting a vulnerability](reporting.html) | How to report a problem privately, and what happens next | +| [Security advisories](advisories.html) | Every published vulnerability, with its CVE, severity and fixed version | +| [Assessment FAQ](faq.html) | Short answers to the questions security questionnaires ask most | + +To secure an instance rather than assess it, see [Security](../security/) in the administrator's guide. diff --git a/source/_security_assessment/security-assessment/measures.md b/source/_security_assessment/security-assessment/measures.md new file mode 100644 index 0000000..1ceeb1a --- /dev/null +++ b/source/_security_assessment/security-assessment/measures.md @@ -0,0 +1,156 @@ +--- +layout: default +title: Security measures +nav_order: 2 +--- + +# Security measures +{: .no_toc } + +How sign-in, permissions, secrets and connections are protected +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +--- + +## Where it runs + +AnsibleForms is self-hosted, on your infrastructure, with its data in a database you run. + +* There is no AnsibleForms cloud service or licence server, and no telemetry or update check. +* The interface is served by the application itself and loads nothing from another site. +* The application connects only to the systems an administrator configures, listed in [Outbound connections](#outbound-connections). + +The parts and what each one holds: + +| Part | What it does | What it holds | +|---|---|---| +| Application | The web interface, the REST API, the scheduler | No data of its own: everything is in the database | +| MySQL or MariaDB | The application's database | Users, groups, credentials (encrypted), jobs with their extravars and output, the audit log | +| Runtime environment (RTE) | Runs the playbooks, in its own container | The files of the jobs it runs, and a job's secrets only while that job runs | +| Persistent folder | The configuration, forms, playbooks and uploads | The files the forms and playbooks use | + +See [How it works](../how-it-works.html) for the full picture, and [RTE](../rte/) for the runtime environment. + +--- + +## Signing in + +Every page and API call that reads or changes data needs a signed-in user. The few endpoints open without one serve the +sign-in itself, the version, the name and logo of the instance, the API documentation, and the database setup of a first start, +which [`ALLOW_SCHEMA_CREATION`](../security/hardening.html#the-database)`=0` closes: + +| Method | How it works | +|---|---| +| Local accounts | Passwords hashed with bcrypt, a [minimum length](../security/hardening.html#sign-in-limits-and-passwords), and the public default admin password must be changed at the first sign-in | +| LDAP / Active Directory | The directory checks the password, and its groups map to roles. See [LDAP](../authentication/ldap.html) | +| OpenID Connect, Entra ID | Single sign-on through your identity provider, which also brings its own multi-factor authentication. See [Authentication](../authentication/) | + +* Failed sign-ins [lock the account](../security/hardening.html#sign-in-limits-and-passwords), and the sign-in is rate limited. +* A sign-in returns a signed access token valid 30 minutes and a refresh token valid 24 hours. +* Tokens can be ended before they expire: signing out, a password change, a blocked user and a revoked API token end them on + every node at once, see [Tokens](../security/secrets.html#tokens). +* AnsibleForms has no multi-factor authentication of its own: use single sign-on where it is required. + +--- + +## Permissions + +What a user may do comes from the roles of their groups, checked by the server on every request: + +* Roles are defined in [config.yaml](../config/roles.html): which forms a user sees, and which options they have, + such as the designer, the settings pages or the jobs of other users. +* The server checks every launch against the form's rules (its fields, their values and the user's access), including + launches through the REST API, see [Launch validation](../launch-validation/). +* The admin role runs any playbook by design. Other roles get only what is granted: a user with several roles gets an + option only when none of those roles refuses it, so one restrictive role is enough to withhold it. + +--- + +## Stored secrets + +Passwords, tokens and keys are encrypted before they are written to the database: + +* Every stored secret, credentials, the LDAP bind password, runner tokens, single sign-on client secrets, webhook + addresses and API keys, is encrypted with AES-256 and a random initialisation vector per value, under the + [`ENCRYPTION_SECRET`](../security/secrets.html#the-two-master-secrets) you set. +* No API call or settings page shows a stored secret, and backups omit the master secrets. +* Secrets can stay in your vault: [HashiCorp Vault and CyberArk](../secret-stores/) are read when a job needs them. +* A job's secrets reach the runtime environment sealed with AES-256-GCM, under a key derived from its token. +* Extravars whose name looks like a secret are masked in the job view, and password fields always are, see [Job data](../security/hardening.html#job-data). + +The rest of the database, jobs, their extravars and their output, is stored as it is: protect it with the database's own +encryption at rest and access control, see [The database](../security/hardening.html#the-database). + +--- + +## Audit trail + +Every change, and every attempt that is refused, is recorded: + +* Each create, change and delete, through the interface or the API, is written to the audit log with the user, their + address, the action, the object and the outcome. Refusals and sign-ins are recorded too, failed ones included. +* Values that look like secrets are redacted before they are written. +* The log is kept 365 days by default, and can be copied to syslog over UDP, TCP or TLS. + +--- + +## In the browser + +The interface is protected against code injected into a page: + +* A [Content-Security-Policy](../security/hardening.html#the-content-security-policy) allows only the application's own scripts and frames. +* Every answer carries protective headers: nosniff, frame protection, and HSTS over HTTPS. +* The HTML of forms, help texts and chat answers is sanitized, and job output is escaped. + +--- + +## On the server + +The code that handles user input avoids the classic injections: + +* Database queries pass their values as parameters, never as text glued into the query. +* Commands, git included, run from an argument list, never a shell, so shell characters do nothing. +* Server expressions pass an [expression sanitizer](../security/hardening.html#expressions-and-outbound-calls), and their REST calls a host filter. +* The [MCP server](../mcp/), the [chat assistant](../chat/) and the metrics endpoint are off until you turn them on. + +--- + +## Connections + +Traffic to and from AnsibleForms can be encrypted end to end: + +* The application serves [HTTPS](../security/https.html) itself, or sits behind your reverse proxy. +* A runtime environment talks to the application over a WebSocket, `wss://` with an HTTPS address, and the application + pins the certificate of an inbound one, see [RTE modes](../rte/modes.html). +* LDAP, single sign-on, the runners, git, the secret stores and the REST calls of expressions check the certificate of the + server they reach, unless an administrator turns the check off for a server with a self-signed certificate. + +### Outbound connections + +AnsibleForms connects only to what an administrator configures: + +| Connection | When | +|---|---| +| LDAP, OpenID Connect, Entra ID (Microsoft login and Graph) | When that sign-in method is set up | +| AWX / AAP / Ascender, Semaphore, Rundeck, Jenkins | When a form runs on that runner | +| Git servers | When a repository is set up | +| Mail server, Slack, Teams, Telegram, webhooks | When a notification channel is set up | +| HashiCorp Vault, CyberArk | When a secret store is set up | +| A model provider (Anthropic, OpenAI and others) | Only with the [chat assistant](../chat/) on, which is off by default | +| Hosts and APIs named in forms | When a form's expressions or a playbook call them | +| Syslog servers | When audit or log forwarding is set up | + +--- + +## The containers + +The images are built to run with as little privilege as possible: + +* The application and the runtime environment run as the unprivileged `node` user, not as root. +* They are built on slim Debian images pinned by digest, see [Secure development](development.html#code-analysis-and-dependencies). +* The application listens on one port, 8000. + +For the steps that secure a new instance, follow [Hardening](../security/hardening.html). diff --git a/source/_security_assessment/security-assessment/reporting.md b/source/_security_assessment/security-assessment/reporting.md new file mode 100644 index 0000000..382775f --- /dev/null +++ b/source/_security_assessment/security-assessment/reporting.md @@ -0,0 +1,65 @@ +--- +layout: default +title: Reporting a vulnerability +nav_order: 4 +--- + +# Reporting a vulnerability +{: .no_toc } + +How to report a security problem privately, and what happens next +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +--- + +## Report it privately + +AnsibleForms holds credentials and vault tokens, so never report a security problem in a public issue: + +1. Open **[Report a vulnerability](https://github.com/ansibleforms/ansibleforms/security/advisories/new)**: a private advisory only the maintainers see. +2. If that page is not available to you, open a normal issue that says only that you have a security report and asks + for a private channel: no details, no reproducer, no version numbers. A maintainer opens a private advisory and + invites you to it. + +The full policy is the repository's [SECURITY.md](https://github.com/ansibleforms/ansibleforms/blob/main/SECURITY.md). + +--- + +## What to include + +The more of this a report gives, the faster it is fixed: + +* What an attacker gains: another user's data, a playbook they may not run, the host, or admin rights. +* The version, and how it is deployed: Docker, Kubernetes, or installed natively. +* A reproducer, or the request that triggers it. +* Whether it needs a signed-in user, and with which [role options](../config/roles.html). + +--- + +## What happens next + +A report goes through the same private advisory from the first message to the publication: + +| Step | What you get | +|---|---| +| Acknowledgement | Confirmation that the report arrived, and whether it reproduces | +| Accepted | A fix in the next release of every [supported version](development.html#supported-versions), and a published advisory that credits you, unless you would rather not be named | +| Declined | The reasoning, usually that the behaviour needs a permission the reporter already had | + +Once the fix is released, the advisory is published and appears in [Security advisories](advisories.html). + +--- + +## Scope + +The policy draws the line where the role model draws it: + +| In scope | Out of scope | +|---|---| +| Anything a signed-in user can reach beyond what their role grants | Findings that need the admin account to be compromised already: an admin running any playbook is the product | +| Anything reachable without signing in | Scanner reports with no demonstrated impact | +| Stored credentials becoming readable, or written to a log | | +| Injection into the shell, the database or the browser | | diff --git a/source/assets/css/custom.css b/source/assets/css/custom.css index d1802f3..29ee142 100644 --- a/source/assets/css/custom.css +++ b/source/assets/css/custom.css @@ -256,3 +256,13 @@ pre.highlight .language-treeview { margin-right: 0; } } + +/* Paragraphs, list items and table cells never end on a word or two alone on their last line, at any + width : the browser evens out the lines of a short one (balance, up to six lines) and + rebalances the last lines of a longer one (pretty). Browsers without them wrap as before */ +.main-content p, +.main-content li, +.main-content td { + text-wrap: pretty; + text-wrap: balance; +}