From 86c707da355ca079fdff64f618296451edd3e8d0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:08:53 +0100 Subject: [PATCH] Preserve audit evidence in verification and recovery guidance Distinguish failed verification from a tampering diagnosis, unsigned history, query errors and runtime gates. Remove live-key replacement and regeneration advice, correct default store paths, and preserve matching keys and history for isolated recovery. Bind the shipped guides to regression checks and exercise recovery distinctions with the real signer and encryption manager on synthetic temporary data. Contract-Neutral: Audit documentation and synthetic regression controls only; no signing, storage, entitlement, API or frontend runtime behaviour changes. Change-source: pulse-maintainer --- docs/AUDIT_LOGGING.md | 69 ++++++++- docs/CONFIGURATION.md | 10 +- docs/DEPLOYMENT_MODELS.md | 10 +- docs/TROUBLESHOOTING.md | 39 +++-- frontend-modern/public/docs/AUDIT_LOGGING.md | 69 ++++++++- frontend-modern/public/docs/CONFIGURATION.md | 10 +- .../public/docs/DEPLOYMENT_MODELS.md | 10 +- .../public/docs/TROUBLESHOOTING.md | 39 +++-- pkg/audit/signer_recovery_guidance_test.go | 142 ++++++++++++++++++ scripts/tests/test_audit_recovery_docs.py | 86 +++++++++++ 10 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 pkg/audit/signer_recovery_guidance_test.go create mode 100644 scripts/tests/test_audit_recovery_docs.py diff --git a/docs/AUDIT_LOGGING.md b/docs/AUDIT_LOGGING.md index 403e91800..1e3f75ce6 100644 --- a/docs/AUDIT_LOGGING.md +++ b/docs/AUDIT_LOGGING.md @@ -4,6 +4,11 @@ Pulse's audit log records security-relevant events with tamper-evident signature **Requires:** Pro, legacy Pro+, or Cloud license with the `audit_logging` capability to query, export, and verify events via the API. Events are recorded on all plans, but the API endpoints are license-gated. +An active licence alone does not enable the audit UI/API on the public +community runtime. If the panel says **Pulse Pro runtime required**, follow +**Download Pulse Pro** to the private runtime for your deployment. Do not buy +a second licence or reset the data directory to clear this gate. + For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-audit-log-pro). --- @@ -116,7 +121,9 @@ sensitive data; keep it outside shared repositories and issue attachments. ## Tamper Detection -Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: +When signing is available, Pulse signs audit events at creation time. Events +captured without a signer remain unsigned. Use **Verify** in the signed-in +Audit Log panel, or the authenticated read below, to check a stored signature: ```bash curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ @@ -132,7 +139,49 @@ Response: } ``` -If `verified` is `false`, the event data has been tampered with since it was recorded. +`verified: true` means the signature matches the verifier's key and supported +signature format. It does not prove that the history is complete or that no +events were deleted. `verified: false` means the check failed, not that Pulse +has established why it failed. Keep that result as evidence. + +### Verification failures and safe recovery + +| Result | What it establishes | Safe next step | +| --- | --- | --- | +| **Not checked** | No verification result has been obtained. | Use **Verify** for the relevant event. | +| **Unsigned** | No signature was stored. | Check whether signing was available when the event was captured. Later enabling signing cannot authenticate it retroactively. | +| **Failed** / `verified: false` | The stored event and signature do not verify with the current key and supported format. | Preserve the evidence; a different key, missing or damaged signature, unsupported format or modified signed fields can cause this. | +| **Unavailable** / `available: false` | The current logger cannot offer verification. | Check the audit capability/runtime gate and bounded startup logs for persistent-store initialisation failures. | +| **Error** / non-successful HTTP response | The verification request did not return a usable result. | Keep the HTTP status and redacted error; it is not proof that the event failed verification. | + +Start with the event's time, the last known working version and whether the +data mount, service account, runtime or backup changed. Inspect a bounded +startup log excerpt locally; distinguish encryption/signing initialisation +errors from an audit database/query error. Do not print the key files or post +whole audit exports, service environments or backups. For an empty panel, +clear filters and check the selected organisation before assuming events were +lost. See [Getting Help](TROUBLESHOOTING.md#-getting-help) for safe evidence. + +**Preserve before changing anything.** Keep a consistent private backup of the +current data directory, including the audit store and its signing and +encryption keys. Use your established snapshot/backup procedure for a running +instance; copying a live `.db` file alone is not a consistent backup. For an +offline filesystem backup, stop Pulse first and retain the complete store, +including any SQLite sidecar files, with its original ownership and access. + +Do not delete or regenerate a key, replace a live signing key with an older +one, edit audit rows or re-sign old events to make verification pass. A newly +generated signing key does not verify events signed with the previous key. +The encrypted signing key also needs its matching `.encryption.key`; restoring +only one member is not a recovery. Swapping keys can break verification of +newer events while concealing the original problem. + +If a matching historical backup exists, restore and compare it in an isolated +test instance using the corresponding Pulse runtime/version, without +replacing the live data or contacting monitored systems and notification +destinations. Keep the current instance and both backups intact. If the +original key is lost or an event was unsigned, do not claim that a restart, +new key or successful check on a different event authenticated that history. --- @@ -160,16 +209,28 @@ See [Multi-Tenant Organizations](MULTI_TENANT.md) for details. | Export | License-gated (402) | Available | | `persistentLogging` API flag | `false` | `true` | -On all plans, audit events are written to the SQLite database. However, the query, verify, and export API endpoints require the `audit_logging` license feature and return `402 Payment Required` without it. The `persistentLogging` flag in API responses indicates whether the licensed query capabilities are available. +Pulse attempts SQLite-backed capture on all plans; failure to initialise the +store can leave console-only logging. The query, verify and export endpoints +also require the `audit_logging` capability and a compatible runtime. A +licence/runtime gate is not proof that events were never captured. For an +authorised query, `persistentLogging` describes the active logger, not a count +of historical events or proof that their signatures are valid. --- ## Storage -Audit events are stored in a SQLite database in the Pulse data directory: +The default audit store lives in the Pulse data directory: - **Single-tenant:** `{data-dir}/audit/audit.db` - **Multi-tenant:** `{data-dir}/orgs/{org-id}/audit/audit.db` +The default encrypted signing key is `.audit-signing.key` **inside the audit +store directory**, alongside `audit.db`, not at the data-directory root. Its +matching `.encryption.key` is in the data directory (or the organisation's data +directory for a non-default organisation). Keep both with the corresponding +history. A runtime may supply a different audit directory or managed signing +key; use that deployment's actual configuration rather than guessing a path. + Data directory locations: - systemd: `/etc/pulse/` - Docker/Kubernetes: `/data/` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4b30522e8..e1a12bbe6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | ------ | --------- | ---------------- | | `.env` | Authentication & Secrets | πŸ”’ **Critical** (Read-only by owner) | | `.encryption.key` | Encryption key for `.enc` files | πŸ”’ **Critical** | -| `.audit-signing.key` | Audit log signing key (Pro/legacy Pro+/Cloud, encrypted) | πŸ”’ **Sensitive** | +| `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 | | `nodes.enc` | Node Credentials | πŸ”’ **Encrypted** (AES-256-GCM) | | `alerts.json` | Alert Rules | πŸ“ Standard | @@ -35,7 +35,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | πŸ”’ **Sensitive** | | `update-history.jsonl` | Update history log (in-app updates) | πŸ“ Standard | | `metrics.db` | Persistent metrics history (SQLite) | πŸ“ Standard | -| `audit.db` | Audit log database (Pro/legacy Pro+/Cloud, SQLite) | πŸ”’ **Sensitive** | +| `audit/audit.db` | Default audit database; capture on all plans, licensed query/export | πŸ”’ **Sensitive** | | `baselines.json` | AI baseline data for anomaly detection | πŸ“ Standard | | `ai_correlations.json` | AI correlation analysis cache | πŸ“ Standard | | `ai_patterns.json` | AI pattern detection data | πŸ“ Standard | @@ -45,7 +45,11 @@ Pulse uses a split-configuration model to ensure security and flexibility. Guest metadata entries are keyed by the canonical guest ID format `instance:node:vmid` (for example, `pve1:node1:100`). Legacy dash-separated keys are migrated automatically. -All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default. +Paths are relative to `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by +default. The audit database and default signing key are in the `audit/` +subdirectory. Runtime-specific audit storage and non-default organisation +paths can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage). +Do not reset signing or encryption keys as a troubleshooting step. Path overrides: - `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token. diff --git a/docs/DEPLOYMENT_MODELS.md b/docs/DEPLOYMENT_MODELS.md index c522c48d1..554293931 100644 --- a/docs/DEPLOYMENT_MODELS.md +++ b/docs/DEPLOYMENT_MODELS.md @@ -25,7 +25,7 @@ Pulse uses a split config model: - **Local auth and secrets**: `.env` (managed by Quick Security Setup or environment overrides, not shown in the UI) - **Encryption key**: `.encryption.key` (required to decrypt `.enc` files) -- **Audit signing key**: `.audit-signing.key` (Pro/legacy Pro+/Cloud, encrypted) +- **Audit signing key**: `audit/.audit-signing.key` (default store, encrypted; preserve with its matching encryption key and history) - **System settings**: `system.json` (editable in the UI unless locked by env) - **Nodes and credentials**: `nodes.enc` (encrypted) - **Notification config**: `email.enc`, `webhooks.enc`, `apprise.enc` (encrypted) @@ -40,7 +40,7 @@ Pulse uses a split config model: - **AI pattern data**: `ai_patterns.json` - **AI remediation data**: `ai_remediations.json` - **AI incident tracking**: `ai_incidents.json` -- **Audit log database**: `audit.db` (Pro/legacy Pro+/Cloud, SQLite) +- **Audit log database**: `audit/audit.db` (default store; persistent capture on all plans, licensed query/export) - **Relay/Pro/legacy Pro+/Cloud license**: `license.enc` (encrypted) - **Host metadata**: `host_metadata.json` - **Docker metadata**: `docker_metadata.json` @@ -64,6 +64,12 @@ Path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +The audit paths above are the default store, not a licence-dependent guarantee +that storage initialised successfully. Runtime-specific storage can differ; +see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring +history or keys. Do not replace keys in a live instance to clear a verification +failure. + Enterprise/internal multi-org layout: - Default org uses the root data dir for backward compatibility. - Non-default orgs use `/orgs//`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 60109b2dc..d6a966caa 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -109,19 +109,40 @@ only the relevant port numbers and a redacted error if help is needed. - If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP. #### Audit Log verification shows unsigned events -- **Symptom**: Audit Log entries show β€œUnsigned” or verification fails in the UI. -- **Root cause**: Audit signing is disabled (crypto manager unavailable), so events are stored without signatures. -- **Fix**: Ensure `.encryption.key` is present and Pro/legacy Pro+/Cloud audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned. + +**Unsigned** means no signature was stored for that event; it is not the same +as **Failed** verification or a request **Error**. Signing can be unavailable +when Pulse cannot initialise its encryption manager. Check a bounded startup +log excerpt and the persistent data mount and access for the service account, +without printing key contents. Do not delete or regenerate `.encryption.key` +or an audit signing key to make the warning disappear. Restoring signing for +new events cannot authenticate an old unsigned event. + +See [Audit verification and safe recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery) +for the different results and evidence to retain. #### Audit Log is empty -- **Symptom**: Audit Log shows zero events or "Console Logging Only." -- **Root cause**: Community plan uses console logging only, or Pro/legacy Pro+/Cloud audit logging is not enabled. -- **Fix**: Use Pro, legacy Pro+, or Cloud with audit logging enabled, then generate new audit events (logins, token creation, password changes). + +Clear the event, user, date and success filters, and check the selected +organisation first. A query error is not an empty history. **Pulse Pro runtime +required** means an active licence is running on the public community runtime; +follow the panel's **Download Pulse Pro** link rather than buying another +licence or resetting storage. Without the audit capability, reads and exports +are gated, but Pulse still attempts to capture events persistently on all +plans. **Console Logging Only** can also reflect unavailable persistent +storage: inspect the bounded startup logs for audit initialisation errors. +Do not change passwords or create tokens merely to populate the panel. #### Audit Log verification fails for older events -- **Symptom**: Older events fail verification while newer events pass. -- **Root cause**: The audit signing key changed (for example, `.audit-signing.key` was regenerated), so signatures no longer match. -- **Fix**: Restore the previous `.audit-signing.key` from backup to verify older events. If rotated intentionally, expect older events to fail verification. + +A failed signature check does not by itself prove tampering. An event signed +with a different key can fail even when its contents are unchanged; missing +signatures and an unsupported or damaged signature format also cannot verify. +Keep the failure as evidence. Do not swap an old key into the live instance, +edit audit rows or re-sign old events. Preserve the current data and keys +privately before any recovery; compare a matching backup only in an isolated +restore, not by overwriting today's history. Follow +[safe audit recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery). ### Monitoring Data diff --git a/frontend-modern/public/docs/AUDIT_LOGGING.md b/frontend-modern/public/docs/AUDIT_LOGGING.md index 403e91800..1e3f75ce6 100644 --- a/frontend-modern/public/docs/AUDIT_LOGGING.md +++ b/frontend-modern/public/docs/AUDIT_LOGGING.md @@ -4,6 +4,11 @@ Pulse's audit log records security-relevant events with tamper-evident signature **Requires:** Pro, legacy Pro+, or Cloud license with the `audit_logging` capability to query, export, and verify events via the API. Events are recorded on all plans, but the API endpoints are license-gated. +An active licence alone does not enable the audit UI/API on the public +community runtime. If the panel says **Pulse Pro runtime required**, follow +**Download Pulse Pro** to the private runtime for your deployment. Do not buy +a second licence or reset the data directory to clear this gate. + For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-audit-log-pro). --- @@ -116,7 +121,9 @@ sensitive data; keep it outside shared repositories and issue attachments. ## Tamper Detection -Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: +When signing is available, Pulse signs audit events at creation time. Events +captured without a signer remain unsigned. Use **Verify** in the signed-in +Audit Log panel, or the authenticated read below, to check a stored signature: ```bash curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ @@ -132,7 +139,49 @@ Response: } ``` -If `verified` is `false`, the event data has been tampered with since it was recorded. +`verified: true` means the signature matches the verifier's key and supported +signature format. It does not prove that the history is complete or that no +events were deleted. `verified: false` means the check failed, not that Pulse +has established why it failed. Keep that result as evidence. + +### Verification failures and safe recovery + +| Result | What it establishes | Safe next step | +| --- | --- | --- | +| **Not checked** | No verification result has been obtained. | Use **Verify** for the relevant event. | +| **Unsigned** | No signature was stored. | Check whether signing was available when the event was captured. Later enabling signing cannot authenticate it retroactively. | +| **Failed** / `verified: false` | The stored event and signature do not verify with the current key and supported format. | Preserve the evidence; a different key, missing or damaged signature, unsupported format or modified signed fields can cause this. | +| **Unavailable** / `available: false` | The current logger cannot offer verification. | Check the audit capability/runtime gate and bounded startup logs for persistent-store initialisation failures. | +| **Error** / non-successful HTTP response | The verification request did not return a usable result. | Keep the HTTP status and redacted error; it is not proof that the event failed verification. | + +Start with the event's time, the last known working version and whether the +data mount, service account, runtime or backup changed. Inspect a bounded +startup log excerpt locally; distinguish encryption/signing initialisation +errors from an audit database/query error. Do not print the key files or post +whole audit exports, service environments or backups. For an empty panel, +clear filters and check the selected organisation before assuming events were +lost. See [Getting Help](TROUBLESHOOTING.md#-getting-help) for safe evidence. + +**Preserve before changing anything.** Keep a consistent private backup of the +current data directory, including the audit store and its signing and +encryption keys. Use your established snapshot/backup procedure for a running +instance; copying a live `.db` file alone is not a consistent backup. For an +offline filesystem backup, stop Pulse first and retain the complete store, +including any SQLite sidecar files, with its original ownership and access. + +Do not delete or regenerate a key, replace a live signing key with an older +one, edit audit rows or re-sign old events to make verification pass. A newly +generated signing key does not verify events signed with the previous key. +The encrypted signing key also needs its matching `.encryption.key`; restoring +only one member is not a recovery. Swapping keys can break verification of +newer events while concealing the original problem. + +If a matching historical backup exists, restore and compare it in an isolated +test instance using the corresponding Pulse runtime/version, without +replacing the live data or contacting monitored systems and notification +destinations. Keep the current instance and both backups intact. If the +original key is lost or an event was unsigned, do not claim that a restart, +new key or successful check on a different event authenticated that history. --- @@ -160,16 +209,28 @@ See [Multi-Tenant Organizations](MULTI_TENANT.md) for details. | Export | License-gated (402) | Available | | `persistentLogging` API flag | `false` | `true` | -On all plans, audit events are written to the SQLite database. However, the query, verify, and export API endpoints require the `audit_logging` license feature and return `402 Payment Required` without it. The `persistentLogging` flag in API responses indicates whether the licensed query capabilities are available. +Pulse attempts SQLite-backed capture on all plans; failure to initialise the +store can leave console-only logging. The query, verify and export endpoints +also require the `audit_logging` capability and a compatible runtime. A +licence/runtime gate is not proof that events were never captured. For an +authorised query, `persistentLogging` describes the active logger, not a count +of historical events or proof that their signatures are valid. --- ## Storage -Audit events are stored in a SQLite database in the Pulse data directory: +The default audit store lives in the Pulse data directory: - **Single-tenant:** `{data-dir}/audit/audit.db` - **Multi-tenant:** `{data-dir}/orgs/{org-id}/audit/audit.db` +The default encrypted signing key is `.audit-signing.key` **inside the audit +store directory**, alongside `audit.db`, not at the data-directory root. Its +matching `.encryption.key` is in the data directory (or the organisation's data +directory for a non-default organisation). Keep both with the corresponding +history. A runtime may supply a different audit directory or managed signing +key; use that deployment's actual configuration rather than guessing a path. + Data directory locations: - systemd: `/etc/pulse/` - Docker/Kubernetes: `/data/` diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 4b30522e8..e1a12bbe6 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | ------ | --------- | ---------------- | | `.env` | Authentication & Secrets | πŸ”’ **Critical** (Read-only by owner) | | `.encryption.key` | Encryption key for `.enc` files | πŸ”’ **Critical** | -| `.audit-signing.key` | Audit log signing key (Pro/legacy Pro+/Cloud, encrypted) | πŸ”’ **Sensitive** | +| `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 | | `nodes.enc` | Node Credentials | πŸ”’ **Encrypted** (AES-256-GCM) | | `alerts.json` | Alert Rules | πŸ“ Standard | @@ -35,7 +35,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | πŸ”’ **Sensitive** | | `update-history.jsonl` | Update history log (in-app updates) | πŸ“ Standard | | `metrics.db` | Persistent metrics history (SQLite) | πŸ“ Standard | -| `audit.db` | Audit log database (Pro/legacy Pro+/Cloud, SQLite) | πŸ”’ **Sensitive** | +| `audit/audit.db` | Default audit database; capture on all plans, licensed query/export | πŸ”’ **Sensitive** | | `baselines.json` | AI baseline data for anomaly detection | πŸ“ Standard | | `ai_correlations.json` | AI correlation analysis cache | πŸ“ Standard | | `ai_patterns.json` | AI pattern detection data | πŸ“ Standard | @@ -45,7 +45,11 @@ Pulse uses a split-configuration model to ensure security and flexibility. Guest metadata entries are keyed by the canonical guest ID format `instance:node:vmid` (for example, `pve1:node1:100`). Legacy dash-separated keys are migrated automatically. -All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default. +Paths are relative to `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by +default. The audit database and default signing key are in the `audit/` +subdirectory. Runtime-specific audit storage and non-default organisation +paths can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage). +Do not reset signing or encryption keys as a troubleshooting step. Path overrides: - `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token. diff --git a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md index c522c48d1..554293931 100644 --- a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md +++ b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md @@ -25,7 +25,7 @@ Pulse uses a split config model: - **Local auth and secrets**: `.env` (managed by Quick Security Setup or environment overrides, not shown in the UI) - **Encryption key**: `.encryption.key` (required to decrypt `.enc` files) -- **Audit signing key**: `.audit-signing.key` (Pro/legacy Pro+/Cloud, encrypted) +- **Audit signing key**: `audit/.audit-signing.key` (default store, encrypted; preserve with its matching encryption key and history) - **System settings**: `system.json` (editable in the UI unless locked by env) - **Nodes and credentials**: `nodes.enc` (encrypted) - **Notification config**: `email.enc`, `webhooks.enc`, `apprise.enc` (encrypted) @@ -40,7 +40,7 @@ Pulse uses a split config model: - **AI pattern data**: `ai_patterns.json` - **AI remediation data**: `ai_remediations.json` - **AI incident tracking**: `ai_incidents.json` -- **Audit log database**: `audit.db` (Pro/legacy Pro+/Cloud, SQLite) +- **Audit log database**: `audit/audit.db` (default store; persistent capture on all plans, licensed query/export) - **Relay/Pro/legacy Pro+/Cloud license**: `license.enc` (encrypted) - **Host metadata**: `host_metadata.json` - **Docker metadata**: `docker_metadata.json` @@ -64,6 +64,12 @@ Path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +The audit paths above are the default store, not a licence-dependent guarantee +that storage initialised successfully. Runtime-specific storage can differ; +see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring +history or keys. Do not replace keys in a live instance to clear a verification +failure. + Enterprise/internal multi-org layout: - Default org uses the root data dir for backward compatibility. - Non-default orgs use `/orgs//`. diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 60109b2dc..d6a966caa 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -109,19 +109,40 @@ only the relevant port numbers and a redacted error if help is needed. - If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP. #### Audit Log verification shows unsigned events -- **Symptom**: Audit Log entries show β€œUnsigned” or verification fails in the UI. -- **Root cause**: Audit signing is disabled (crypto manager unavailable), so events are stored without signatures. -- **Fix**: Ensure `.encryption.key` is present and Pro/legacy Pro+/Cloud audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned. + +**Unsigned** means no signature was stored for that event; it is not the same +as **Failed** verification or a request **Error**. Signing can be unavailable +when Pulse cannot initialise its encryption manager. Check a bounded startup +log excerpt and the persistent data mount and access for the service account, +without printing key contents. Do not delete or regenerate `.encryption.key` +or an audit signing key to make the warning disappear. Restoring signing for +new events cannot authenticate an old unsigned event. + +See [Audit verification and safe recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery) +for the different results and evidence to retain. #### Audit Log is empty -- **Symptom**: Audit Log shows zero events or "Console Logging Only." -- **Root cause**: Community plan uses console logging only, or Pro/legacy Pro+/Cloud audit logging is not enabled. -- **Fix**: Use Pro, legacy Pro+, or Cloud with audit logging enabled, then generate new audit events (logins, token creation, password changes). + +Clear the event, user, date and success filters, and check the selected +organisation first. A query error is not an empty history. **Pulse Pro runtime +required** means an active licence is running on the public community runtime; +follow the panel's **Download Pulse Pro** link rather than buying another +licence or resetting storage. Without the audit capability, reads and exports +are gated, but Pulse still attempts to capture events persistently on all +plans. **Console Logging Only** can also reflect unavailable persistent +storage: inspect the bounded startup logs for audit initialisation errors. +Do not change passwords or create tokens merely to populate the panel. #### Audit Log verification fails for older events -- **Symptom**: Older events fail verification while newer events pass. -- **Root cause**: The audit signing key changed (for example, `.audit-signing.key` was regenerated), so signatures no longer match. -- **Fix**: Restore the previous `.audit-signing.key` from backup to verify older events. If rotated intentionally, expect older events to fail verification. + +A failed signature check does not by itself prove tampering. An event signed +with a different key can fail even when its contents are unchanged; missing +signatures and an unsupported or damaged signature format also cannot verify. +Keep the failure as evidence. Do not swap an old key into the live instance, +edit audit rows or re-sign old events. Preserve the current data and keys +privately before any recovery; compare a matching backup only in an isolated +restore, not by overwriting today's history. Follow +[safe audit recovery](AUDIT_LOGGING.md#verification-failures-and-safe-recovery). ### Monitoring Data diff --git a/pkg/audit/signer_recovery_guidance_test.go b/pkg/audit/signer_recovery_guidance_test.go new file mode 100644 index 000000000..ad8ab2ba4 --- /dev/null +++ b/pkg/audit/signer_recovery_guidance_test.go @@ -0,0 +1,142 @@ +package audit_test + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/crypto" + "github.com/rcourtman/pulse-go-rewrite/pkg/audit" +) + +// These controls exercise the recovery guide's key/evidence distinctions with +// the real signer and encryption manager. They use only synthetic, private +// temporary directories: no SQLite store, installed instance or external call. +func TestSignerRecoveryGuidance(t *testing.T) { + dataDir := t.TempDir() + writeEncryptionKey := func(dir string, value byte) *crypto.CryptoManager { + t.Helper() + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{value}, 32)) + if err := os.WriteFile(filepath.Join(dir, ".encryption.key"), []byte(key), 0o600); err != nil { + t.Fatalf("write synthetic encryption key: %v", err) + } + manager, err := crypto.NewCryptoManagerAt(dir) + if err != nil { + t.Fatalf("load synthetic encryption manager: %v", err) + } + return manager + } + manager := writeEncryptionKey(dataDir, 'A') + auditDir := filepath.Join(dataDir, "audit") + signer, err := audit.NewSigner(auditDir, manager) + if err != nil { + t.Fatalf("create original signer: %v", err) + } + event := audit.Event{ + ID: "synthetic-recovery-evidence", + Timestamp: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC), + EventType: "login", + User: "synthetic-user", + Path: "/api/login", + Success: true, + Details: "synthetic recovery control", + } + event.Signature = signer.Sign(event) + if !signer.Verify(event) { + t.Fatal("original event must verify before recovery comparisons") + } + storedKey, err := os.ReadFile(filepath.Join(auditDir, ".audit-signing.key")) + if err != nil { + t.Fatalf("read synthetic encrypted key: %v", err) + } + + t.Run("reload preserves verification without regeneration", func(t *testing.T) { + reloaded, err := audit.NewSigner(auditDir, manager) + if err != nil || !reloaded.Verify(event) { + t.Fatalf("reload failed to verify unchanged event: %v", err) + } + currentKey, err := os.ReadFile(filepath.Join(auditDir, ".audit-signing.key")) + if err != nil || !bytes.Equal(storedKey, currentKey) { + t.Fatal("reload must not replace the stored signing key") + } + }) + + t.Run("new signing key fails unchanged historical event", func(t *testing.T) { + freshSigner, err := audit.NewSigner(filepath.Join(t.TempDir(), "audit"), manager) + if err != nil { + t.Fatalf("create unrelated signer: %v", err) + } + if freshSigner.Verify(event) { + t.Fatal("a new key must not verify an unchanged old event") + } + if !signer.Verify(event) { + t.Fatal("failed check with another key must not alter the original evidence") + } + }) + + t.Run("signing key alone cannot restore with different encryption key", func(t *testing.T) { + restoreDir := t.TempDir() + otherManager := writeEncryptionKey(restoreDir, 'B') + restoreAuditDir := filepath.Join(restoreDir, "audit") + if err := os.MkdirAll(restoreAuditDir, 0o700); err != nil { + t.Fatalf("create isolated restore directory: %v", err) + } + keyPath := filepath.Join(restoreAuditDir, ".audit-signing.key") + if err := os.WriteFile(keyPath, storedKey, 0o600); err != nil { + t.Fatalf("copy synthetic encrypted key: %v", err) + } + if _, err := audit.NewSigner(restoreAuditDir, otherManager); err == nil { + t.Fatal("wrong encryption key must not silently recover the signer") + } + after, err := os.ReadFile(keyPath) + if err != nil || !bytes.Equal(after, storedKey) { + t.Fatal("a failed restore must preserve the supplied encrypted key") + } + }) + + t.Run("matching key pair verifies in isolated restore", func(t *testing.T) { + restoreDir := t.TempDir() + restoredManager := writeEncryptionKey(restoreDir, 'A') + restoreAuditDir := filepath.Join(restoreDir, "audit") + if err := os.MkdirAll(restoreAuditDir, 0o700); err != nil { + t.Fatalf("create isolated restore directory: %v", err) + } + if err := os.WriteFile(filepath.Join(restoreAuditDir, ".audit-signing.key"), storedKey, 0o600); err != nil { + t.Fatalf("copy synthetic encrypted key: %v", err) + } + restoredSigner, err := audit.NewSigner(restoreAuditDir, restoredManager) + if err != nil || !restoredSigner.Verify(event) { + t.Fatalf("matching isolated key pair failed to verify old event: %v", err) + } + if !signer.Verify(event) { + t.Fatal("isolated restore must leave the original verifier usable") + } + }) + + t.Run("unsigned is not retrospectively authenticated", func(t *testing.T) { + unsigned := event + unsigned.Signature = "" + if signer.Verify(unsigned) { + t.Fatal("working signer must not authenticate an unsigned old event") + } + }) + + t.Run("altered data and unsupported format remain failures", func(t *testing.T) { + altered := event + altered.Details = "altered synthetic evidence" + if signer.Verify(altered) { + t.Fatal("changed signed content must still fail verification") + } + unsupported := event + unsupported.Signature = "v99:" + event.Signature + if signer.Verify(unsupported) { + t.Fatal("unsupported signature format must not be accepted") + } + if !signer.Verify(event) { + t.Fatal("negative comparisons must preserve the original evidence") + } + }) +} diff --git a/scripts/tests/test_audit_recovery_docs.py b/scripts/tests/test_audit_recovery_docs.py new file mode 100644 index 000000000..8240521ef --- /dev/null +++ b/scripts/tests/test_audit_recovery_docs.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Keep audit help from treating a failed check as a diagnosis or a reset task. + +Signer/key behaviour is exercised separately by TestSignerRecoveryGuidance; +these checks bind those distinctions to the actual canonical and shipped help. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("AUDIT_LOGGING", "TROUBLESHOOTING", "CONFIGURATION", "DEPLOYMENT_MODELS") + + +def read_guide(name: str) -> str: + return " ".join((ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8").split()) + + +class AuditRecoveryDocsTest(unittest.TestCase): + def test_shipped_guides_match_the_canonical_evidence(self): + for name in GUIDES: + with self.subTest(guide=name): + self.assertEqual((ROOT / "docs" / f"{name}.md").read_bytes(), + (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes()) + + def test_verification_results_are_not_conflated(self): + guide = read_guide("AUDIT_LOGGING") + for result in ("**Not checked**", "**Unsigned**", "**Failed**", "**Unavailable**", "**Error**"): + with self.subTest(result=result): + self.assertIn(result, guide) + self.assertIn("not that Pulse has established why it failed", guide) + self.assertIn("not proof that the event failed verification", guide) + self.assertIn("does not prove that the history is complete", guide) + self.assertNotIn("the event data has been tampered with since it was recorded", guide) + + def test_recovery_preserves_history_and_both_keys(self): + guide = read_guide("AUDIT_LOGGING") + for boundary in ("consistent private backup", "copying a live `.db` file alone", + "including any SQLite sidecar files", "matching `.encryption.key`", + "isolated test instance", "without replacing the live data", + "or contacting monitored systems and notification destinations", + "Do not delete or regenerate a key", "edit audit rows or re-sign old events"): + with self.subTest(boundary=boundary): + self.assertIn(boundary, guide) + self.assertIn("newly generated signing key does not verify events signed with the previous key", guide) + self.assertIn("Later enabling signing cannot authenticate it retroactively", guide) + + def test_troubleshooting_does_not_offer_a_live_key_swap(self): + guide = read_guide("TROUBLESHOOTING") + self.assertNotIn("restart Pulse to regenerate", guide) + self.assertNotIn("Restore the previous `.audit-signing.key` from backup to verify", guide) + self.assertIn("Do not swap an old key into the live instance", guide) + self.assertIn("cannot authenticate an old unsigned event", guide) + self.assertIn("AUDIT_LOGGING.md#verification-failures-and-safe-recovery", guide) + + def test_empty_and_gated_are_not_missing_history(self): + for name in ("TROUBLESHOOTING", "AUDIT_LOGGING"): + guide = read_guide(name) + with self.subTest(guide=name): + self.assertIn("Pulse Pro runtime required", guide) + self.assertIn("Download Pulse Pro", guide) + self.assertIn("filters", guide) + self.assertIn("organisation", guide) + short = read_guide("TROUBLESHOOTING") + self.assertIn("A query error is not an empty history", short) + self.assertIn("Do not change passwords or create tokens merely to populate the panel", short) + self.assertNotIn("Community plan uses console logging only", short) + + def test_storage_paths_match_the_default_store_not_the_old_root_path(self): + for name in ("CONFIGURATION", "DEPLOYMENT_MODELS"): + guide = read_guide(name) + with self.subTest(guide=name): + self.assertIn("`audit/audit.db`", guide) + self.assertIn("`audit/.audit-signing.key`", guide) + self.assertIn("AUDIT_LOGGING.md#storage", guide) + self.assertNotRegex(guide, r"(?:\| |\*\*: )`\.audit-signing\.key`") + guide = read_guide("AUDIT_LOGGING") + self.assertIn("**inside the audit store directory**", guide) + self.assertIn("organisation's data directory", guide) + self.assertIn("runtime may supply a different audit directory or managed signing key", guide) + + +if __name__ == "__main__": + unittest.main()