From 4c47c47a27b0e6878c7f8041370d5603823d8151 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 23 Jun 2026 16:58:26 -0400 Subject: [PATCH] docs: caveat interpolated secrets in structural Compose fields (#1425) The effective-model read surfaces (Networking, Dossier/Anatomy, Storage, and Compose Doctor) avoid environment, label, and command values, but docker compose config resolves any ${VAR} interpolation before the model is parsed, leaving no provenance. A secret interpolated into a structural field (a bind path, network name, published port, or extra_hosts entry) is therefore returned resolved. That value is already readable at the same stack:read scope through the stack's files, so this documents the caveat rather than changing behavior. - Reword the "secret-safe / never shows a secret value" claims on the Networking, Dossier, Storage, and Doctor docs pages, and add a canonical note to the Environment and Secrets Guardrails page steering secrets to environment:/env_file: injection. - Make the matching code comments honest in effectiveAnatomy, composeNetworkInspector, effectiveModel (extra_hosts), and the effective-anatomy route. --- backend/src/routes/stacks.ts | 8 +++++--- backend/src/services/effectiveAnatomy.ts | 14 ++++++++++---- .../services/network/composeNetworkInspector.ts | 7 ++++++- backend/src/services/preflight/effectiveModel.ts | 4 ++-- docs/features/compose-doctor.mdx | 2 +- docs/features/compose-networking.mdx | 2 +- docs/features/compose-storage.mdx | 2 +- docs/features/environment-guardrails.mdx | 4 ++++ docs/features/stack-dossier.mdx | 2 +- 9 files changed, 31 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 69236515..d9b93798 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1166,9 +1166,11 @@ stacksRouter.get('/:stackName/storage', async (req: Request, res: Response) => { // Effective Stack Anatomy: structural facts (services, ports, volumes, networks, // restart) from the fully-merged effective model, so a multi-file Git source's // dossier and doc-drift reflect every override file, not just the root compose. -// Read-only and advisory; auto-proxies to the active node. Secret-safe: the -// response carries only structural fields; resolved env, label, and command -// values in the rendered model are never extracted into the payload. +// Read-only and advisory; auto-proxies to the active node. The response carries +// only structural fields; resolved env, label, and command values are never +// extracted. A secret interpolated INTO a structural field still resolves into +// the payload, but is already readable at the same stack:read scope via the +// stack's files (see docs/features/environment-guardrails). stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; diff --git a/backend/src/services/effectiveAnatomy.ts b/backend/src/services/effectiveAnatomy.ts index b945eec2..b1e4f34b 100644 --- a/backend/src/services/effectiveAnatomy.ts +++ b/backend/src/services/effectiveAnatomy.ts @@ -7,10 +7,16 @@ * facts. Rendering the merged model and extracting the same anatomy shape keeps * those signals honest. * - * Secret-safe by construction: the extractor reads only structural fields - * (service keys, ports, volumes, restart, network keys). It never reads - * `environment`, `command`, `entrypoint`, `labels`, `secrets`, or `configs`, so a - * resolved secret VALUE in the rendered model can never reach this payload. + * Reads only structural fields (service keys, ports, volumes, restart, network + * keys); it never reads `environment`, `command`, `entrypoint`, `labels`, + * `secrets`, or `configs`, so a secret INJECTED through those fields can never + * reach this payload. The exception is a secret interpolated INTO a structural + * field (a bind path, network name, or published port built from a `${VAR}`): + * `docker compose config` resolves it before this parse, leaving no provenance + * to distinguish it, so the resolved value is returned. That value is already + * readable at the same `stack:read` scope via the stack's files, so this is a + * documented caveat (see docs/features/environment-guardrails), not a new + * exposure. */ import { ComposeService } from './ComposeService'; import { parseMissingRequiredVars } from '../helpers/envVarParse'; diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index 5176af18..2853b9b8 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -4,7 +4,12 @@ * Community user reads (network map, membership, published ports/bindings, * network_mode, extra_hosts, and runtime drift). Advisory and read-only; it * renders the AUTHORED model only (no Mesh overrides) and never returns or logs - * raw docker stderr, env values, or label values. + * raw docker stderr, env values, or label values. One caveat: a secret + * interpolated into a structural field (a network name, published port, or + * `extra_hosts` entry built from a `${VAR}`) is resolved by `docker compose + * config` before this reads the model, so its value does appear; that value is + * already readable at the same `stack:read` scope via the stack's files + * (documented caveat, see docs/features/environment-guardrails). */ import DockerController, { type DependencySnapshot } from '../DockerController'; import { ComposeService } from '../ComposeService'; diff --git a/backend/src/services/preflight/effectiveModel.ts b/backend/src/services/preflight/effectiveModel.ts index 926fa043..5d0ba1a0 100644 --- a/backend/src/services/preflight/effectiveModel.ts +++ b/backend/src/services/preflight/effectiveModel.ts @@ -64,7 +64,7 @@ export interface EffService { envKeys: string[]; /** Network membership by network key, with any aliases. */ networks: EffServiceNetwork[]; - /** `extra_hosts` entries as `host:value` strings (host names / static IPs, never secrets). */ + /** `extra_hosts` entries as `host:value` strings (host names / static IPs; a value built from a `${VAR}` is resolved upstream by `docker compose config`, so it can carry an interpolated secret). */ extraHosts: string[]; /** Label KEY names only. Values are never extracted (a label value can carry a secret). */ labelKeys: string[]; @@ -282,7 +282,7 @@ function parseServiceNetworks(networks: unknown): EffServiceNetwork[] { return []; } -/** `extra_hosts` (list `host:ip` or map `{host: ip}`) → `host:value` strings. Infra facts, not secrets. */ +/** `extra_hosts` (list `host:ip` or map `{host: ip}`) → `host:value` strings. A value built from a `${VAR}` is resolved upstream by `docker compose config`, so it can carry an interpolated secret. */ function parseExtraHosts(extraHosts: unknown): string[] { if (Array.isArray(extraHosts)) { return extraHosts.map(e => str(e)).filter((s): s is string => s !== undefined); diff --git a/docs/features/compose-doctor.mdx b/docs/features/compose-doctor.mdx index 6074851e..3af6b6fe 100644 --- a/docs/features/compose-doctor.mdx +++ b/docs/features/compose-doctor.mdx @@ -7,7 +7,7 @@ The **Doctor** tab in the right-hand **Anatomy** panel answers one question befo The check is advisory. It never blocks a deploy and never changes a stack; it tells you what it found so you can decide. It runs on demand: press **run preflight** and Sencho renders the model, runs the checks, and stores the result so the tab still shows it the next time you open the stack. -Compose Doctor never shows a secret value. It reads the structure of the effective model, service names, images, ports, volumes, and the *names* of environment variables, but never their values, so nothing sensitive appears in the report, the stored run, or the logs. +Compose Doctor reads the structure of the effective model, service names, images, ports, volumes, and the *names* of environment variables, never their values, so a secret injected through `environment:` or `env_file:` never appears in the report, the stored run, or the logs. One caveat: a secret interpolated into a structural field, such as a port, an image tag, or a volume path built from a `${VAR}`, is resolved before the model is read, so its value does show. Keep secrets in `environment:` or `env_file:` rather than interpolating them into these fields. See [Environment and Secrets Guardrails](/features/environment-guardrails). ## Severity diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index fd583198..ac1b8425 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -7,7 +7,7 @@ The **Networking** tab in the right-hand **Anatomy** panel answers a Compose-fir The view is read-only with respect to the stack: it never changes a deployment. The one thing you can edit here is the stack's *exposure intent*, which is stored separately so Sencho can flag mismatches over time. -Compose Networking never shows a secret value. It reads the structure of the model, network names, service-to-network membership, published ports, and the *names* of environment variables and labels, but never their values, so nothing sensitive appears in the view or the logs. +Compose Networking reads the structure of the model, network names, service-to-network membership, published ports, and the *names* of environment variables and labels, never their values, so a secret injected through `environment:` or `env_file:` never appears in the view or the logs. One caveat: a structural field built by interpolating a secret, such as a network name, a published port, or an `extra_hosts` entry assembled from a `${VAR}`, is resolved before the tab reads it, so its value does show. Keep secrets in `environment:` or `env_file:` rather than interpolating them into these fields. See [Environment and Secrets Guardrails](/features/environment-guardrails). ## Networks diff --git a/docs/features/compose-storage.mdx b/docs/features/compose-storage.mdx index 3d979fe5..ce3e21dd 100644 --- a/docs/features/compose-storage.mdx +++ b/docs/features/compose-storage.mdx @@ -5,7 +5,7 @@ description: See every mount a stack depends on, learn whether it is portable or The **Storage** tab in the right-hand **Anatomy** panel answers a Compose-first question: *what storage does this stack depend on, and what happens to it if I move or restore the stack?* It renders the effective Compose model, the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied, lists every mount, and gives the stack a single portability verdict. -The view is read-only with respect to the stack and the host: it never changes a mount, and it never reads, moves, or changes the ownership of any file. It inspects structure only, mount type, source and target paths, the read-only flag, and a host path's existence, type, and owner, so nothing inside a volume or bind mount is ever opened. +The view is read-only with respect to the stack and the host: it never changes a mount, and it never reads, moves, or changes the ownership of any file. It inspects structure only, mount type, source and target paths, the read-only flag, and a host path's existence, type, and owner, so nothing inside a volume or bind mount is ever opened. One caveat: a bind source or target assembled from a secret `${VAR}` is resolved before the model is read, so its value does show. Keep secrets in `environment:` or `env_file:` rather than interpolating them into mount paths. See [Environment and Secrets Guardrails](/features/environment-guardrails). ## Storage inventory diff --git a/docs/features/environment-guardrails.mdx b/docs/features/environment-guardrails.mdx index 35bfb821..350815e0 100644 --- a/docs/features/environment-guardrails.mdx +++ b/docs/features/environment-guardrails.mdx @@ -16,6 +16,10 @@ Compose treats environment in two distinct ways, and mixing them up is a common The inventory labels each variable with how it is used, so you can tell at a glance whether a variable feeds Compose interpolation, is injected into a service, or both. + + Interpolation resolves a `${VAR}` into the Compose file before the container is created, so a variable used in a **structural** field (a bind path, a network name, a published port, or an `extra_hosts` entry) is substituted before any tab reads the model. Its resolved value then shows in the Storage, Networking, and Dossier facts to anyone with read access to the stack, the same access that can open the stack's Compose and `.env` files. Container injection is different: values under `environment:` and `env_file:` are only ever reported by name. Keep secrets in injection, and avoid interpolating them into structural fields. + + ## What the inventory shows For every variable, Sencho records its source, its scope, and a status: diff --git a/docs/features/stack-dossier.mdx b/docs/features/stack-dossier.mdx index a9514f75..9ff69be2 100644 --- a/docs/features/stack-dossier.mdx +++ b/docs/features/stack-dossier.mdx @@ -21,7 +21,7 @@ The top of the tab shows a read-only summary derived live from the stack's curre | **Env file** | The env file, its variable count, and any referenced `${VAR}` with no value | | **Source** | Git source when the stack is linked, otherwise local | -Secret values are never read or shown. Only env variable **names** and **counts** appear, exactly as in the Anatomy tab. +Environment and label values are never read or shown: only variable **names** and **counts** appear, exactly as in the Anatomy tab. The one exception is a secret interpolated into a structural fact, such as a published port written as `${DB_PORT}`, which Compose resolves before the facts are read, so its value appears. The same is true of the resolved paths and network names shown on the Storage and Networking tabs. Keep secrets in `environment:` or `env_file:` rather than interpolating them into structural fields. See [Environment and Secrets Guardrails](/features/environment-guardrails). ## Operator notes