Skip to content

Commit 4ad3e35

Browse files
bogdanpricopclaude
andcommitted
feat(containers): CLI transparency and per-container isolation posture
Two features answering the same critique of opaque management UIs: show the operator what the tool actually does, and what their containers actually are. CLI transparency - Add src/services/cli-transparency.js, a pure derivation service mapping an action key plus typed params to the equivalent docker / docker compose command. Covers container lifecycle, remove, rename, bulk, run, image, volume, network, prune and Compose stack verbs. - Shell-escape every argument and mask secret-shaped env and label values inside the service, so all callers inherit both. The secret pattern is imported from secret-reference-admission.js rather than redefined; two independently maintained secret regexes drift, and the one that drifts leaks. - Add read-only POST /api/cli-preview and GET /api/cli-preview/actions, restricted to a fixed allowlist. The endpoint never accepts a caller-supplied command string. - Show the command, collapsed and copyable, in the container-remove and stack bulk-action confirmations, and record it on the matching audit entries. - Report an action with no exact equivalent as unavailable rather than guessing a partial command an operator might paste into a production shell. - Add an optional onMount hook to Modal.confirm so dialog markup can attach listeners without inline handlers (CSP script-src-attr none). Per-container isolation posture - inspectContainer now returns an additive isolation block: HostConfig.Runtime, privileged, capabilities, namespace modes, UsernsMode, security options. - Add src/services/isolation-posture.js (pure) enumerating how far a container can reach past its runtime, including Docker-socket mounts and disabled seccomp/AppArmor/SELinux confinement, which no existing check reported per container. - Add a posture check raising one finding per container, only when it has host-level reach, is not already sandboxed, and the host has a sandboxed runtime registered. That gate is both the product decision and the cost guard: the per-container inspect loop never runs on hosts where it would find nothing actionable. - Treat privileged / CapAdd / PidMode as inputs rather than findings so the check complements the CIS benchmark instead of repeating it, and cap severity at high so posture scoring does not double-count CIS. - Add GET /api/containers/:id/isolation and a container-detail card, keeping the runtime taxonomy server-side. - Add a shared per-scan docker info cache to the posture context. English and Romanian labels for both. 107 new tests across 4 suites, weighted toward shell-injection, secret redaction, the action allowlist and call-count assertions on the isolation cost gate. Full suite: 327 suites, 3523 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d4a003b commit 4ad3e35

30 files changed

Lines changed: 1952 additions & 16 deletions

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,55 @@
22

33
All notable changes to Docker Dash are documented here.
44

5+
## [8.94.0] - 2026-08-10 — CLI transparency and per-container isolation posture
6+
7+
Two features derived from the same critique of opaque management UIs: show the
8+
operator what the tool actually does, and what their containers actually are.
9+
10+
### Per-container isolation posture
11+
12+
- Expose the per-container isolation model (`HostConfig.Runtime`, privileged,
13+
capabilities, namespace modes, `UsernsMode`, security options) from
14+
`inspectContainer` as an additive `isolation` block.
15+
- Add a pure assessment service that enumerates how far a container can reach
16+
past its runtime, including Docker-socket mounts and disabled seccomp,
17+
AppArmor or SELinux confinement — signals no existing check reported per
18+
container.
19+
- Add a posture check that raises one finding per container, only when the
20+
container has host-level reach, is not already sandboxed, and the host has a
21+
sandboxed runtime registered; the same gate keeps the per-container inspect
22+
loop off hosts where it would find nothing actionable.
23+
- Treat privileged, capability and namespace switches as inputs rather than
24+
findings, so the check complements the CIS benchmark instead of repeating it.
25+
- Cap finding severity at high: the verdict is "this could be contained better",
26+
not "this is breached", and posture scoring must not double-count CIS.
27+
- Add `GET /api/containers/:id/isolation` and a container-detail card so the
28+
runtime taxonomy stays server-side and the teaching surface works on every
29+
host, not only where a sandboxed runtime exists.
30+
- Add a shared per-scan `docker info` cache to the posture context, English and
31+
Romanian labels, and 41 tests including call-count assertions on the cost gate.
32+
33+
### CLI transparency
34+
35+
- Add a pure derivation service that turns an action key plus typed parameters
36+
into the equivalent `docker` or `docker compose` command, covering container
37+
lifecycle, removal, rename, bulk, run, image, volume, network, prune and
38+
Compose stack verbs.
39+
- Shell-escape every argument and mask secret-shaped environment and label values
40+
inside the service, so all callers inherit both; a truncated secret is treated
41+
as a secret and replaced outright.
42+
- Add read-only `POST /api/cli-preview` and `GET /api/cli-preview/actions`,
43+
restricted to a fixed action allowlist; the endpoint never accepts a
44+
caller-supplied command string.
45+
- Show the equivalent command, collapsed and copyable, in the container-remove
46+
and stack bulk-action confirmations, and record it on the matching audit
47+
entries.
48+
- Report an action with no exact equivalent as unavailable rather than guessing a
49+
partial command an operator might paste into a production shell.
50+
- Add an optional `onMount` hook to `Modal.confirm` so dialog markup can attach
51+
listeners without inline handlers, English and Romanian labels, and 66 tests
52+
covering escaping, redaction and the allowlist.
53+
554
## [8.93.0] - 2026-08-06 — Signed Compose blueprint catalog
655

756
- Add a curated Compose catalog with owner, support level, lifecycle and

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ services:
44
context: .
55
dockerfile: Dockerfile
66
args:
7-
APP_VERSION: "${APP_VERSION:-8.93.0}"
8-
image: docker-dash:${APP_VERSION:-8.93.0}
7+
APP_VERSION: "${APP_VERSION:-8.94.0}"
8+
image: docker-dash:${APP_VERSION:-8.94.0}
99
container_name: docker-dash
1010
restart: unless-stopped
1111
env_file:
@@ -56,7 +56,7 @@ services:
5656
dd-egress-filter:
5757
build:
5858
context: ./docker/egress-filter
59-
image: docker-dash-egress-filter:${APP_VERSION:-8.93.0}
59+
image: docker-dash-egress-filter:${APP_VERSION:-8.94.0}
6060
container_name: dd-egress-filter
6161
restart: unless-stopped
6262
# Uses the default bridge so target containers on the default bridge can

docs/features/cli-transparency.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# CLI Transparency — "show me the command"
2+
3+
**Introduced:** v8.94.0
4+
**Always on. No configuration.**
5+
6+
Destructive and bulk container actions show the equivalent `docker` command
7+
before you confirm them, and the same command is recorded on the audit entry
8+
afterwards.
9+
10+
The point is not to turn Docker Dash into a CLI. It is to stop being a black box:
11+
a web UI that hides what it does asks you to trust it, and one that shows you the
12+
command earns that instead.
13+
14+
---
15+
16+
## 1. Where it appears
17+
18+
| Surface | What you see |
19+
|---------|--------------|
20+
| Container **remove** confirmation | A collapsed `CLI equivalent` row with the `docker rm …` command and a copy button |
21+
| Stack **bulk action** confirmation | One command line per affected container |
22+
| Audit log | `details.cli` on container action, remove, rename and bulk entries |
23+
24+
The row is **collapsed by default** — an operator who doesn't care pays no extra
25+
click. Start/stop/restart have no confirmation dialog and are unchanged; no
26+
confirmation was added just to hang a preview off it.
27+
28+
## 2. What it will and won't render
29+
30+
Commands are derived from a fixed action table, never from a free-form string:
31+
32+
`container.start` · `stop` · `restart` · `pause` · `unpause` · `kill` · `remove` ·
33+
`rename` · `bulk` · `run` · `image.pull` · `image.remove` · `volume.remove` ·
34+
`network.remove` · `prune.*` · `stack.up` · `down` · `restart` · `pull`
35+
36+
An action outside this table is reported as having **no equivalent**. It is never
37+
guessed. A subtly wrong command that an operator pastes into a production shell
38+
is worse than no command at all, which is also why provider CLIs (`qm`, `govc`,
39+
`xe`, `incus`) are deliberately out of scope for now — each has its own auth
40+
model and flag semantics.
41+
42+
## 3. Safety properties
43+
44+
- **Shell-escaped.** Every argument is single-quoted unless it consists only of
45+
characters with no shell meaning. A container named `$(id)` or `a'; rm -rf /`
46+
renders as one inert argument. Tested against command substitution, chaining,
47+
newlines and quote break-out.
48+
- **Secrets masked.** Environment and label values whose key matches the shared
49+
secret pattern render as `KEY=<redacted>`. The key stays visible — you need to
50+
know `DB_PASSWORD` is being set. The value is replaced outright, never hashed
51+
or truncated; a truncated secret is still a secret.
52+
The pattern is imported from `src/services/secret-reference-admission.js` rather
53+
than redefined, because two independently-maintained secret regexes drift, and
54+
the one that drifts is the one that leaks.
55+
- **Redaction lives in the service**, not at the call sites, so every caller —
56+
UI preview and audit entry alike — inherits it.
57+
- **No host flag.** The command is labelled *"as run on `<host>`"* instead of
58+
carrying `--host`. Pasting a command that silently targets the wrong machine
59+
because of your local Docker context is a failure mode worth designing out.
60+
61+
## 4. API
62+
63+
```
64+
POST /api/cli-preview { action, params } → { available, command, hostLabel, redacted, reason }
65+
GET /api/cli-preview/actions → { actions: [...] }
66+
```
67+
68+
`requireAuth` only — deriving a string changes no state, and gating it behind
69+
`operator` would hide the explanation from exactly the viewers who most need it.
70+
No `writeable`, no audit entry: nothing happened.
71+
72+
`POST` despite being read-only because the parameters are structured (bulk
73+
subject arrays, full container definitions) and do not survive a query string
74+
intact — the same reason GraphQL queries use POST. An unknown action key returns
75+
`400`; a known action with unusable parameters returns `200` with
76+
`available: false`.
77+
78+
## 5. Limitations
79+
80+
- Provider CLIs are not covered (see §2).
81+
- There is no global "CLI mode" that annotates every page — v1 covers the two
82+
confirmations where the trust question actually gets asked.
83+
- The reverse direction (paste a command, run it) is not part of this feature.
84+
`src/services/docker-run-parser.js` converts `docker run` into a Compose
85+
service for the Stacks converter; executing pasted commands is a different
86+
feature with a different threat model.

docs/features/isolation-posture.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Per-container Isolation Posture
2+
3+
**Introduced:** v8.94.0
4+
**Always on. No configuration.**
5+
6+
Docker Dash has detected sandboxed OCI runtimes (Kata Containers, gVisor/runsc,
7+
Firecracker) at the **host** level since v8.8.1, badging them on the System page.
8+
That answers "is a stronger runtime available here?" but not the question that
9+
follows: **which containers aren't using it?**
10+
11+
This feature is the per-container half.
12+
13+
---
14+
15+
## 1. Two surfaces, deliberately different audiences
16+
17+
| Surface | Who sees it | When |
18+
|---------|-------------|------|
19+
| **Container detail → Isolation card** | Everyone | Always |
20+
| **Security Posture finding** | Operators with a sandboxed runtime installed | Only when actionable |
21+
22+
The card is unconditional: it shows which runtime backs the container, whether
23+
that runtime is sandboxed, and — if the container can reach past it — exactly how.
24+
That is useful whether or not you run gVisor.
25+
26+
The posture finding is conditional, on purpose. See §3.
27+
28+
## 2. What counts as "reach"
29+
30+
Ways a container can reach past its runtime toward the host:
31+
32+
| Signal | Severity |
33+
|--------|:--------:|
34+
| Privileged | critical |
35+
| Docker socket mounted | critical |
36+
| `CapAdd=ALL`, `SYS_MODULE` | critical |
37+
| `SYS_ADMIN`, `SYS_RAWIO` | high |
38+
| Host PID namespace | high |
39+
| `seccomp=unconfined`, `apparmor=unconfined` | high |
40+
| `SYS_PTRACE`, `SYS_BOOT`, `NET_ADMIN`, `DAC_READ_SEARCH` | medium |
41+
| Host network / IPC namespace | medium |
42+
| SELinux `label=disable` | medium |
43+
44+
Capability spelling is normalised (`SYS_ADMIN`, `cap_sys_admin`, `CAP_SYS_ADMIN`
45+
are one signal) and de-duplicated. When a container is already privileged its
46+
capabilities are **not** re-listed — privileged implies all of them, and
47+
enumerating them again is noise.
48+
49+
Disabled seccomp/AppArmor/SELinux confinement and Docker-socket mounts are
50+
reported **per container** here for the first time; the CIS benchmark checks
51+
seccomp and AppArmor on the daemon, not per workload.
52+
53+
## 3. Why the posture check is usually silent
54+
55+
A finding is raised only when all three hold:
56+
57+
1. the container has host-level reach, **and**
58+
2. it is not already on a sandboxed runtime, **and**
59+
3. the host has a sandboxed runtime registered.
60+
61+
Condition 3 means most estates see nothing. That is the intended behaviour.
62+
Without it, the remediation would read "go install gVisor and reconfigure your
63+
daemon" — a large ask, and one the CIS benchmark already opens by flagging the
64+
privileged container on its own terms. **Docker Dash does not nag you about
65+
software you have not installed.**
66+
67+
The same condition is evaluated from one cached `docker info` *before* any
68+
container work, so on hosts without a sandboxed runtime the per-container inspect
69+
loop never runs. The product decision and the performance guard are the same line.
70+
71+
Per-host scan cap: 200 running containers. When it truncates, the finding says so
72+
rather than silently under-reporting.
73+
74+
## 4. Relationship to the CIS benchmark
75+
76+
They are complementary, not overlapping:
77+
78+
- **CIS** reports privileged / `CapAdd` / `PidMode=host` as failures in their own
79+
right — *the door is open*.
80+
- **Isolation posture** treats those same switches as **inputs** and asks a
81+
different question — *you own a lock and haven't used it*.
82+
83+
One finding per container, never one per switch. Severity is capped at **high**
84+
even for critical reach, because the verdict is "this could be contained better",
85+
not "this is breached" — inflating it would make the posture score double-count
86+
what CIS already counted.
87+
88+
## 5. Remediation is guidance, not a button
89+
90+
The finding suggests recreating the container with `--runtime=<sandboxed>`
91+
(Compose: `runtime:` on the service), and warns that Kata and gVisor do not
92+
support every syscall or device passthrough. There is no one-click fix: moving a
93+
workload onto a sandboxed runtime can break it, and Docker Dash does not make
94+
that call for you. Same principle as the exposed-port check, which refuses to
95+
close ports on your behalf.
96+
97+
If the workload must stay on the shared kernel, the alternative remediation is to
98+
reduce the reach — drop capabilities, remove the privileged flag or the socket
99+
mount.
100+
101+
## 6. API
102+
103+
```
104+
GET /api/containers/:id/isolation
105+
→ { runtime, sandboxed, sandboxAvailable, sandboxOptions, signals, severity, actionable }
106+
```
107+
108+
`requireAuth`, read-only. This endpoint exists so the runtime taxonomy (which
109+
names count as sandboxed) stays in one place — `_categorizeRuntimes` in
110+
`src/services/docker.js` — instead of being duplicated in the frontend.
111+
112+
## 7. Limitations
113+
114+
- **Docker and Podman only.** Kubernetes, Nomad and Incus have their own
115+
isolation models (RuntimeClass, task drivers); mapping them onto Docker's
116+
`HostConfig.Runtime` would be a guess.
117+
- **Running containers only.** A stopped container has no reach.
118+
- **User-namespace remapping is shown but not scored.** `userns-remap` is off by
119+
default nearly everywhere, and a signal that fires on almost every container is
120+
not a signal.
121+
- **`youki` is classified as sandboxed** by the pre-existing host-level runtime
122+
patterns, which is arguable — it is a standard OCI runtime written in Rust. Left
123+
unchanged because that classification also drives the System page badge.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-dash",
3-
"version": "8.93.0",
3+
"version": "8.94.0",
44
"description": "Full-featured Docker management dashboard",
55
"main": "src/server.js",
66
"scripts": {

public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@
391391
<script src="/js/components/error-boundary.js?v=__VERSION__"></script>
392392
<script src="/js/components/toast.js?v=__VERSION__"></script>
393393
<script src="/js/components/modal.js?v=__VERSION__"></script>
394+
<script src="/js/components/cli-preview.js?v=__VERSION__"></script>
394395
<script src="/js/components/template-configurator.js?v=__VERSION__"></script>
395396
<script src="/js/components/table.js?v=__VERSION__"></script>
396397
<script src="/js/components/context-menu.js?v=__VERSION__"></script>

public/js/api.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ const Api = {
145145
sendPasswordReset(id, lang) { return this.post(`/auth/users/${id}/send-reset`, { lang, origin: window.location.origin }); },
146146
sendInvitation(id, lang) { return this.post(`/auth/users/${id}/send-invite`, { lang, origin: window.location.origin }); },
147147

148+
// ─── CLI Transparency ────────────────────────────
149+
// Read-only: derives the equivalent docker command for an action. POST because
150+
// the params are structured, not because anything changes. v8.94.0.
151+
getCliPreview(action, params = {}) { return this.post('/cli-preview', { action, params }); },
152+
getCliPreviewActions() { return this.get('/cli-preview/actions'); },
153+
148154
// ─── Containers ──────────────────────────────────
149155
getContainers(all = true) { return this.get(`/containers?all=${all}`); },
150156
getContainer(id) { return this.get(`/containers/${id}/inspect`); },
@@ -164,6 +170,7 @@ const Api = {
164170
return this.get(`/containers/logs/multi?${params.toString()}`);
165171
},
166172
getContainerStats(id) { return this.get(`/containers/${id}/stats`); },
173+
getContainerIsolation(id) { return this.get(`/containers/${id}/isolation`); },
167174
containerAction(id, action) { return this.post(`/containers/${id}/${action}`); },
168175
removeContainer(id, force = false) { return this.delete(`/containers/${id}?force=${force}`); },
169176
renameContainer(id, name) { return this.post(`/containers/${id}/rename`, { name }); },

0 commit comments

Comments
 (0)