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.
This commit is contained in:
Anso
2026-06-23 16:58:26 -04:00
committed by GitHub
parent b753d2d5e0
commit 4c47c47a27
9 changed files with 31 additions and 14 deletions
+5 -3
View File
@@ -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;
+10 -4
View File
@@ -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';
@@ -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';
@@ -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);
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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.
<Note>
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.
</Note>
## What the inventory shows
For every variable, Sencho records its source, its scope, and a status:
+1 -1
View File
@@ -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