From f273e861892b4b62551713a5b746f8ecffb0e7e2 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:41:56 +0100 Subject: [PATCH] Keep Docker setup credentials private across help languages Remove the shared-password override from German and Spanish Compose setup and the password-on-argv example from configuration help. Default to bootstrap setup; exercise private-file permissions, argument boundaries and failed preparation with synthetic credentials. Contract-Neutral: Help and regression fixtures only; authentication, container and API contracts are unchanged. Change-source: pulse-maintainer --- docs/CONFIGURATION.md | 95 +++++++-- docs/DOCKER.md | 15 +- docs/i18n/de/README.md | 12 +- docs/i18n/es/README.md | 12 +- .../browser-tests/private-auth-help.cjs | 124 +++++++++++ frontend-modern/public/docs/CONFIGURATION.md | 95 +++++++-- frontend-modern/public/docs/DOCKER.md | 15 +- frontend-modern/public/docs/i18n/de/README.md | 12 +- frontend-modern/public/docs/i18n/es/README.md | 12 +- scripts/tests/test_configuration_auth_docs.py | 192 ++++++++++++++++++ scripts/tests/test_localized_public_docs.py | 12 +- 11 files changed, 542 insertions(+), 54 deletions(-) create mode 100644 frontend-modern/browser-tests/private-auth-help.cjs create mode 100644 scripts/tests/test_configuration_auth_docs.py diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e1a12bbe6d..f95747ce41 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | File | Purpose | Security Level | | ------ | --------- | ---------------- | -| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) | +| `.env` | Authentication & Secrets | 🔒 **Critical** (Owner access only) | | `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** | | `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | 🔒 **Sensitive** | | `system.json` | General Settings | 📝 Standard | @@ -66,30 +66,93 @@ Multi-tenant layout: ## 🔐 Authentication (`.env`) -This file controls access to Pulse. It is **never** exposed to the UI. +For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and +complete the bootstrap-token setup in your browser. See +[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password. -```bash -# /etc/pulse/.env +The Pulse-generated `.env` controls local authentication and is not exposed +to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too: +someone who obtains it can attempt offline password cracking. -# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup) +```dotenv +# Pulse-generated file, not a command to paste into a shell PULSE_AUTH_USER='admin' -PULSE_AUTH_PASS='$2a$12$...' +PULSE_AUTH_PASS='' ``` -
-Advanced: Automated Setup (Skip UI) +Deployment-supplied environment values take precedence over this file. Changing +the generated file or using Pulse's password-change UI does not replace a +password supplied by your deployment manager; update that managed source too. -You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup. +### Private Docker authentication file + +If automation must skip browser setup, keep the credentials in a private file, +not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared +Compose file. On the Docker host, prepare the file below. These commands do not +erase an existing file; edit it locally, without printing its contents: ```bash -# Docker Example -docker run -d \ - -e PULSE_DEPLOYMENT_METHOD=docker_run \ - -e PULSE_AUTH_USER=admin \ - -e PULSE_AUTH_PASS=secret123 \ - rcourtman/pulse:latest +( + set -e + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/docker-auth.env" + if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` -
+ +In that editor, enter these two settings and replace the placeholder with a +**complete bcrypt hash** generated by a trusted local tool with an interactive +password prompt. Do not put the password in that tool's command arguments. +Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted +values; keep the hash's `$` characters literal, with no `$$` substitution: + +```dotenv +PULSE_AUTH_USER=admin +PULSE_AUTH_PASS= +``` + +For a **new Community container**, replace `vX.Y.Z` with the exact published +tag you intend to install, then run: + +```bash +( + set -e + if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then + printf 'Prepare the private authentication file first.\n' >&2 + exit 1 + fi +docker run -d \ + --name pulse \ + -p 7655:7655 \ + -v pulse_data:/data \ + --env-file "$HOME/.config/pulse/docker-auth.env" \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ + --restart unless-stopped \ + rcourtman/pulse:vX.Y.Z +) +``` + +This keeps credential values out of the command arguments, **not out of Docker's +container environment**: administrators with Docker access can still read them. +Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but +does not scrub its original value from Docker or your deployment file. Keep the +file private, outside the repository, and do not share `docker inspect` or +resolved Compose configuration. Do not source the file into your shell. + +For an existing or paid Pro deployment, retain its image, mounts and managed +configuration; this new-container example is not an upgrade procedure. Compose +interpolation and Docker `--env-file` are different formats. Prefer bootstrap +setup for Compose unless your deployment manager already has a private +credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
Advanced: OIDC / SSO diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 9d919d9197..c87e2e9d0f 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -45,9 +45,6 @@ services: environment: - TZ=Europe/London - PULSE_DEPLOYMENT_METHOD=docker_compose - # Optional: Pre-configure auth (skips setup wizard) - # - PULSE_AUTH_USER=admin - # - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -55,6 +52,13 @@ volumes: Run with: `docker compose up -d` +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a +shared example password to the Compose file. If automation must skip setup, +use a private deployment-managed credential source; the +[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file) +example is for `docker run`, not Compose interpolation. + The `PULSE_IMAGE` variable lets the same compose file run either the public community image or, for eligible paid customers, the private Pulse Pro image shown on . @@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid > **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events. > **Note**: API tokens are managed in the UI and stored in `api_tokens.json`. -> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup. +> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication +> at startup, but remain in the container environment and deployment file. +> Docker administrators can read those values. Prefer bootstrap setup; never +> share full `docker inspect` or resolved Compose output. For SSD-sensitive installs, keep `/data` persistent and put only metrics history on tmpfs: diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index 12d0d1110f..b1750aaf22 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -90,14 +90,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation +ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token +ein. Verwende kein gemeinsames Beispielpasswort. Für automatisierte Einrichtung +nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +Passwörter gehören nicht in Befehlsargumente oder eine geteilte Compose-Datei. +Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin +lesen, auch wenn Pulse sie für die Anmeldung hasht. + ## Erste Anmeldung Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 922051c1f6..106b98cd2e 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -89,14 +89,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalación +nueva y completa el primer acceso con el token de arranque. No uses una +contraseña de ejemplo compartida. Para una configuración automatizada, usa +una fuente privada de credenciales gestionada por el despliegue; consulta +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +No pongas contraseñas en argumentos de comandos ni en un archivo Compose +compartido. Un administrador de Docker aún puede leer las credenciales en el +entorno del contenedor, aunque Pulse las convierta en hash para la autenticación. + ## Primer inicio de sesión En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para diff --git a/frontend-modern/browser-tests/private-auth-help.cjs b/frontend-modern/browser-tests/private-auth-help.cjs new file mode 100644 index 0000000000..4c69dd9550 --- /dev/null +++ b/frontend-modern/browser-tests/private-auth-help.cjs @@ -0,0 +1,124 @@ +// Verify shipped setup guidance in the production Docs/router/Markdown/CSS. +// No backend, Docker daemon, credentials or container startup is exercised. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'private-auth-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-private-auth-help'), + server: { host: '127.0.0.1', port: 5249, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5249/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + await page.getByRole('link', { name: '← All documentation' }).click(); + await page.getByRole('link', { name: 'Configure Pulse', exact: true }).click(); + const privateHeading = page.getByRole('heading', { name: 'Private Docker authentication file', exact: true }); + await privateHeading.waitFor(); + let text = await page.locator('article').innerText(); + for (const phrase of ['complete bcrypt hash', 'no $$ substitution', 'Do not source the file', + 'Deployment-supplied environment values take precedence', 'not out of Docker\'s', + 'does not scrub its original value', 'this new-container example is not an upgrade procedure']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('secret123')); + const shell = await page.locator('pre code.language-bash').allTextContents(); + assert.ok(shell.some((block) => block.includes('--env-file "$HOME/.config/pulse/docker-auth.env"'))); + assert.ok(shell.every((block) => !/-e\s+PULSE_AUTH_(PASS|USER)=/.test(block))); + const firstLogin = page.getByRole('link', { name: 'First login', exact: true }); + await firstLogin.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('step-1-get-the-token'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token')); + await page.goBack(); + await privateHeading.waitFor(); + await privateHeading.evaluate((h) => h.scrollIntoView({ block: 'start' })); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-file.png`) }); + const launchCode = page.locator('pre').filter({ hasText: 'docker run -d' }); + await launchCode.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${engine}-docker-credentials.png`) }); + + await page.getByRole('link', { name: '← All documentation' }).click(); + await page.getByRole('link', { name: 'Docker and Podman', exact: true }).click(); + await page.getByRole('heading', { name: '📦 Docker Compose', exact: true }).waitFor(); + text = await page.locator('article').innerText(); + assert.ok(!text.includes('secret123')); + assert.ok(text.includes('Leave authentication overrides unset')); + assert.ok(text.includes('remain in the container environment and deployment file')); + const privateLink = page.getByRole('link', { name: 'private Docker authentication file', exact: true }); + await privateLink.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('private-docker-authentication-file'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + + for (const [locale, label, heading, safeguard] of [ + ['de', 'Deutsch', 'Erste Anmeldung', 'Verwende kein gemeinsames Beispielpasswort'], + ['es', 'Español', 'Primer inicio de sesión', 'No uses una contraseña de ejemplo compartida'], + ]) { + await page.getByRole('link', { name: '← All documentation' }).click(); + const link = page.getByRole('link', { name: label, exact: true }); + await link.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: heading, exact: true }).waitFor(); + text = await page.locator('article').innerText(); + assert.ok(!text.includes('secret123')); + assert.ok(!text.includes('PULSE_AUTH_PASS=')); + assert.ok(text.includes('docker exec pulse /app/pulse bootstrap-token')); + assert.ok(text.includes(safeguard)); + const compose = page.locator('pre').filter({ hasText: 'services:' }); + await compose.scrollIntoViewIfNeeded(); + assert.ok(!(await compose.innerText()).includes('PULSE_AUTH_')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-${locale}-bootstrap.png`) }); + const target = page.locator('a[href="/docs/CONFIGURATION#private-docker-authentication-file"]'); + await target.focus(); + await page.keyboard.press('Enter'); + await privateHeading.waitFor(); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + } + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, firstLoginKeyboard: true, + privateFileRendering: true, noCommandCredential: true, envVisibilityWarning: true, + dockerFragmentKeyboard: true, translatedBootstrap: ['de', 'es'], errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped setup help and links, not native Docker or password changes', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index e1a12bbe6d..f95747ce41 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | File | Purpose | Security Level | | ------ | --------- | ---------------- | -| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) | +| `.env` | Authentication & Secrets | 🔒 **Critical** (Owner access only) | | `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** | | `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | 🔒 **Sensitive** | | `system.json` | General Settings | 📝 Standard | @@ -66,30 +66,93 @@ Multi-tenant layout: ## 🔐 Authentication (`.env`) -This file controls access to Pulse. It is **never** exposed to the UI. +For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and +complete the bootstrap-token setup in your browser. See +[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password. -```bash -# /etc/pulse/.env +The Pulse-generated `.env` controls local authentication and is not exposed +to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too: +someone who obtains it can attempt offline password cracking. -# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup) +```dotenv +# Pulse-generated file, not a command to paste into a shell PULSE_AUTH_USER='admin' -PULSE_AUTH_PASS='$2a$12$...' +PULSE_AUTH_PASS='' ``` -
-Advanced: Automated Setup (Skip UI) +Deployment-supplied environment values take precedence over this file. Changing +the generated file or using Pulse's password-change UI does not replace a +password supplied by your deployment manager; update that managed source too. -You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup. +### Private Docker authentication file + +If automation must skip browser setup, keep the credentials in a private file, +not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared +Compose file. On the Docker host, prepare the file below. These commands do not +erase an existing file; edit it locally, without printing its contents: ```bash -# Docker Example -docker run -d \ - -e PULSE_DEPLOYMENT_METHOD=docker_run \ - -e PULSE_AUTH_USER=admin \ - -e PULSE_AUTH_PASS=secret123 \ - rcourtman/pulse:latest +( + set -e + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/docker-auth.env" + if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` -
+ +In that editor, enter these two settings and replace the placeholder with a +**complete bcrypt hash** generated by a trusted local tool with an interactive +password prompt. Do not put the password in that tool's command arguments. +Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted +values; keep the hash's `$` characters literal, with no `$$` substitution: + +```dotenv +PULSE_AUTH_USER=admin +PULSE_AUTH_PASS= +``` + +For a **new Community container**, replace `vX.Y.Z` with the exact published +tag you intend to install, then run: + +```bash +( + set -e + if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then + printf 'Prepare the private authentication file first.\n' >&2 + exit 1 + fi +docker run -d \ + --name pulse \ + -p 7655:7655 \ + -v pulse_data:/data \ + --env-file "$HOME/.config/pulse/docker-auth.env" \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ + --restart unless-stopped \ + rcourtman/pulse:vX.Y.Z +) +``` + +This keeps credential values out of the command arguments, **not out of Docker's +container environment**: administrators with Docker access can still read them. +Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but +does not scrub its original value from Docker or your deployment file. Keep the +file private, outside the repository, and do not share `docker inspect` or +resolved Compose configuration. Do not source the file into your shell. + +For an existing or paid Pro deployment, retain its image, mounts and managed +configuration; this new-container example is not an upgrade procedure. Compose +interpolation and Docker `--env-file` are different formats. Prefer bootstrap +setup for Compose unless your deployment manager already has a private +credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
Advanced: OIDC / SSO diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index 9d919d9197..c87e2e9d0f 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -45,9 +45,6 @@ services: environment: - TZ=Europe/London - PULSE_DEPLOYMENT_METHOD=docker_compose - # Optional: Pre-configure auth (skips setup wizard) - # - PULSE_AUTH_USER=admin - # - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -55,6 +52,13 @@ volumes: Run with: `docker compose up -d` +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a +shared example password to the Compose file. If automation must skip setup, +use a private deployment-managed credential source; the +[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file) +example is for `docker run`, not Compose interpolation. + The `PULSE_IMAGE` variable lets the same compose file run either the public community image or, for eligible paid customers, the private Pulse Pro image shown on . @@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid > **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events. > **Note**: API tokens are managed in the UI and stored in `api_tokens.json`. -> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup. +> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication +> at startup, but remain in the container environment and deployment file. +> Docker administrators can read those values. Prefer bootstrap setup; never +> share full `docker inspect` or resolved Compose output. For SSD-sensitive installs, keep `/data` persistent and put only metrics history on tmpfs: diff --git a/frontend-modern/public/docs/i18n/de/README.md b/frontend-modern/public/docs/i18n/de/README.md index 12d0d1110f..b1750aaf22 100644 --- a/frontend-modern/public/docs/i18n/de/README.md +++ b/frontend-modern/public/docs/i18n/de/README.md @@ -90,14 +90,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation +ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token +ein. Verwende kein gemeinsames Beispielpasswort. Für automatisierte Einrichtung +nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +Passwörter gehören nicht in Befehlsargumente oder eine geteilte Compose-Datei. +Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin +lesen, auch wenn Pulse sie für die Anmeldung hasht. + ## Erste Anmeldung Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den diff --git a/frontend-modern/public/docs/i18n/es/README.md b/frontend-modern/public/docs/i18n/es/README.md index 922051c1f6..106b98cd2e 100644 --- a/frontend-modern/public/docs/i18n/es/README.md +++ b/frontend-modern/public/docs/i18n/es/README.md @@ -89,14 +89,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalación +nueva y completa el primer acceso con el token de arranque. No uses una +contraseña de ejemplo compartida. Para una configuración automatizada, usa +una fuente privada de credenciales gestionada por el despliegue; consulta +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +No pongas contraseñas en argumentos de comandos ni en un archivo Compose +compartido. Un administrador de Docker aún puede leer las credenciales en el +entorno del contenedor, aunque Pulse las convierta en hash para la autenticación. + ## Primer inicio de sesión En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para diff --git a/scripts/tests/test_configuration_auth_docs.py b/scripts/tests/test_configuration_auth_docs.py new file mode 100644 index 0000000000..ab4972bd72 --- /dev/null +++ b/scripts/tests/test_configuration_auth_docs.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Exercise private-file auth recipes with synthetic credentials, not Docker. + +The recorders verify file permissions, argument boundaries and failure stops. +They do not establish native container startup or a password-change outcome. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("CONFIGURATION.md", "DOCKER.md", "i18n/de/README.md", "i18n/es/README.md") +HASH = "$2a$12$" + "a" * 53 # Synthetic file/argv sentinel, not a real password. +CONTENT = f"PULSE_AUTH_USER=admin\nPULSE_AUTH_PASS={HASH}\n" + + +def recipe(needle: str) -> str: + text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8") + matches = [block for block in re.findall(r"```bash\n(.*?)```", text, re.S) + if needle in block] + if len(matches) != 1: + raise AssertionError(f"expected one configuration recipe containing {needle!r}") + return matches[0] + + +EDITOR = '''#!/usr/bin/env python3 +import json, os, stat, sys +from pathlib import Path +p = Path(sys.argv[1]) +assert p == Path.home() / '.config/pulse/docker-auth.env' +assert stat.S_IMODE(p.stat().st_mode) == 0o600 +assert stat.S_IMODE(p.parent.stat().st_mode) == 0o700 +(Path.home() / 'editor.json').write_text(json.dumps(sys.argv[1:])) +sys.exit(int(os.environ.get('EDITOR_EXIT', '0'))) +''' + +DOCKER = '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +args = sys.argv[1:] +p = Path(args[args.index('--env-file') + 1]) +assert p == Path.home() / '.config/pulse/docker-auth.env' +assert p.read_text() == os.environ['EXPECTED_FILE'] +assert os.environ['FIXTURE_HASH'] not in ' '.join(args) +assert not any(a.startswith('PULSE_AUTH_PASS=') for a in args) +(Path.home() / 'docker.json').write_text(json.dumps(args)) +sys.exit(int(os.environ.get('DOCKER_EXIT', '0'))) +''' + + +def environment(home: Path, **overrides: str) -> dict[str, str]: + tools = home / "tools" + tools.mkdir(exist_ok=True) + for name, body in (("vi", EDITOR), ("docker", DOCKER)): + script = tools / name + script.write_text(body, encoding="utf-8") + script.chmod(0o700) + return {**os.environ, "HOME": str(home), "PATH": f"{tools}:{os.environ['PATH']}", + "FIXTURE_HASH": HASH, "EXPECTED_FILE": CONTENT, **overrides} + + +def private_file(home: Path, content: str = CONTENT) -> Path: + path = home / ".config/pulse/docker-auth.env" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.write_text(content, encoding="utf-8") + path.chmod(0o600) + return path + + +class ConfigurationAuthDocsTest(unittest.TestCase): + def test_no_shared_password_or_secret_in_command_arguments(self): + for name in GUIDES: + with self.subTest(guide=name): + text = (ROOT / "docs" / name).read_text(encoding="utf-8") + self.assertNotIn("secret123", text) + shell = "\n".join(re.findall(r"```bash\n(.*?)```", text, re.S)) + self.assertNotRegex(shell, r"(?:-e|--env)\s+PULSE_AUTH_(?:PASS|USER)=") + self.assertNotRegex(shell, r"\b(?:export\s+)?PULSE_AUTH_PASS=") + if name != "CONFIGURATION.md": + self.assertNotRegex(text, r"PULSE_AUTH_PASS\s*[:=]") + + def test_mirrors_and_sensitive_runtime_limit(self): + for name in GUIDES: + self.assertEqual((ROOT / "docs" / name).read_bytes(), + (ROOT / "frontend-modern/public/docs" / name).read_bytes()) + text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8") + for expected in ("not out of Docker's", "does not scrub its original value", + "Do not source the file", "Deployment-supplied environment values take precedence", + "complete bcrypt hash", "unquoted", "no `$$` substitution", + "this new-container example is not an upgrade procedure"): + self.assertIn(expected, text) + + def test_preparation_preserves_existing_file_and_protects_both_paths(self): + for existing in (False, True): + with self.subTest(existing=existing), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = home / ".config/pulse/docker-auth.env" + if existing: + private_file(home) + path.chmod(0o644) + path.parent.chmod(0o755) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + self.assertEqual(path.read_text(), CONTENT if existing else "") + self.assertEqual(json.loads((home / "editor.json").read_text()), [str(path)]) + self.assertNotIn(HASH.encode(), result.stdout + result.stderr) + + def test_preparation_refuses_symlink_without_touching_target(self): + for linked in ("file", "directory"): + with self.subTest(linked=linked), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + target = home / "retained" + target.mkdir() + credential = target / "docker-auth.env" + credential.write_text(CONTENT) + credential.chmod(0o644) + directory = home / ".config/pulse" + directory.parent.mkdir() + if linked == "directory": + directory.symlink_to(target, target_is_directory=True) + else: + directory.mkdir() + (directory / "docker-auth.env").symlink_to(credential) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home), capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"Refusing a symlinked credential path", result.stderr) + self.assertEqual(credential.read_text(), CONTENT) + self.assertEqual(stat.S_IMODE(credential.stat().st_mode), 0o644) + self.assertFalse((home / "editor.json").exists()) + + def test_editor_failure_is_not_reported_as_preparation_success(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home, EDITOR_EXIT="23"), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 23) + self.assertEqual(path.read_text(), CONTENT) + + def test_launch_passes_only_private_path_and_preserves_hash_bytes(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + argv = json.loads((home / "docker.json").read_text()) + self.assertEqual(argv, ["run", "-d", "--name", "pulse", "-p", "7655:7655", + "-v", "pulse_data:/data", "--env-file", str(path), "-e", + "PULSE_DEPLOYMENT_METHOD=docker_run", "--restart", "unless-stopped", + "rcourtman/pulse:vX.Y.Z"]) + self.assertNotIn(HASH, json.dumps(argv)) + self.assertNotIn(HASH.encode(), result.stdout + result.stderr) + self.assertEqual(path.read_text(), CONTENT) + + def test_absent_or_empty_file_prevents_docker_start(self): + for empty in (False, True): + with self.subTest(empty=empty), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + if empty: + private_file(home, "") + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home), capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"Prepare the private authentication file first", result.stderr) + self.assertFalse((home / "docker.json").exists()) + + def test_docker_failure_remains_failed(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home, DOCKER_EXIT="31"), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 31) + self.assertEqual(path.read_text(), CONTENT) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_localized_public_docs.py b/scripts/tests/test_localized_public_docs.py index ae09887c67..7012652368 100644 --- a/scripts/tests/test_localized_public_docs.py +++ b/scripts/tests/test_localized_public_docs.py @@ -28,8 +28,6 @@ MACHINE_FACING_STRINGS = ( "docker run -d", "rcourtman/pulse:vX.Y.Z", "image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}", - "PULSE_AUTH_USER=admin", - "PULSE_AUTH_PASS=secret123", "docker exec pulse /app/pulse bootstrap-token", "kubectl exec -it -- /app/pulse bootstrap-token", "sudo pulse bootstrap-token", @@ -70,6 +68,16 @@ class LocalizedPublicDocsTest(unittest.TestCase): self.assertIn("[Agent Security](../../AGENT_SECURITY.md)", content) self.assertIn("[Plans and entitlements](../../PULSE_PRO.md)", content) + def test_localized_compose_uses_bootstrap_not_shared_credentials(self) -> None: + for rel in LOCALIZED_DOCS: + content = read(rel) + with self.subTest(path=rel): + self.assertNotIn("secret123", content) + self.assertNotIn("PULSE_AUTH_USER=", content) + self.assertNotIn("PULSE_AUTH_PASS=", content) + self.assertIn("../../CONFIGURATION.md#private-docker-authentication-file", content) + self.assertIn("docker exec pulse /app/pulse bootstrap-token", content) + def test_localized_docs_do_not_reintroduce_paid_capacity_claims(self) -> None: forbidden = ( "unlimited",