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
This commit is contained in:
pulse-triage[bot]
2026-10-03 07:41:56 +01:00
parent 6f297a5dc8
commit f273e86189
11 changed files with 542 additions and 54 deletions
+79 -16
View File
@@ -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='<complete-bcrypt-hash>'
```
<details>
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
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"
)
```
</details>
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=<complete-bcrypt-hash>
```
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.
<details>
<summary><strong>Advanced: OIDC / SSO</strong></summary>
+11 -4
View File
@@ -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 <https://pulserelay.pro/download.html>.
@@ -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:
+9 -3
View File
@@ -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
+9 -3
View File
@@ -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
@@ -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; });
+79 -16
View File
@@ -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='<complete-bcrypt-hash>'
```
<details>
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
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"
)
```
</details>
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=<complete-bcrypt-hash>
```
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.
<details>
<summary><strong>Advanced: OIDC / SSO</strong></summary>
+11 -4
View File
@@ -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 <https://pulserelay.pro/download.html>.
@@ -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:
@@ -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
@@ -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
@@ -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()
+10 -2
View File
@@ -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 <pod> -- /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",