v1.7.0-rc.7
Pre-release
Pre-release
v1.7.0-rc.7
Full Changelog: v1.7.0-rc.6...v1.7.0-rc.7
[1.7.0-rc.7] — 2026-08-29
Security
- Every cookie-less request authenticating with an
Authorization: Basicheader persisted a 30-day session row that was never reaped.requireAuthenticationcalledpassport.authenticate(getAllIds(), { session: true })unconditionally, and passport'sreq.logInregenerates and saves a session regardless ofsaveUninitialized: false, which only governs the auto-save on response end. Cookie-less Basic polling against/auth/useris the documented pattern for wud-card and Homepage integrations, so a single poller accumulated one session document per request. Worse, connect-loki never receives attl(onlyttlInterval), so LokiJS's own expiry sweep never ran and the store grew/store/dd.jsonwithout bound until it was rebuilt by hand.requireAuthenticationnow passessession: falsewhenever the request carries anAuthorizationheader, so passport still authenticates the request but skips the regenerate/save entirely; cookie-based logins through/auth/loginare unaffected. - A container could point another service's compose
image:at its own repository. The compose action picks which service to rewrite from the container's owncom.docker.compose.servicelabel and never compares the two images, so a container carrying a label for a service it is not an instance of had that service'simage:line rewritten to its own repository, and the operator's nextdocker compose upwould run that image with the victim service's volumes and privileges. The one runtime-versus-compose comparison there was,reconciliationMode, compares full references and so cannot tell a tag drift from a different repository, and in its defaultwarnmode it only logs. The write also lands before the runtime update and is rolled back only if that update throws, which it doesn't, because it is the labelling container's own recreate. Setting a label needs write access to the socket, which is root on plain Docker, so this is only a boundary in the shapes where container-create isn't: Portainer with bind mounts disabled for non-admins, rootless Docker with one watcher per user socket, an authz plugin scoping mounts per client identity. A repository mismatch between the composeimage:and what the container actually runs now throws before anything is written, in everyreconciliationModeincludingoff, because a different repository doesn't mean the file has drifted, it means the container is not that service. The check runs again under the selected compose file's lock against a freshly read effective multi-file chain, so concurrent external edits and images inherited from another chain file cannot bypass it; automatic backups are created only after that validation passes. Tag-only drift keeps its existing warn, block and off behaviour, and the Docker Hub aliases (docker.io/,index.docker.io/,registry-1.docker.io/,library/) plus digest-pinned references still compare equal to the same repository (#938). - The HTTP trigger's bearer credential was written to the log in cleartext on every registration.
Component.register()logs the configuration it was handed, and both layers that are supposed to scrub it missed this one field: the shared trigger redactor knewtoken,passwordandapikeybut notbearer, andHttphad nomaskConfiguration()of its own, so the base implementation handed the configuration back untouched. The credential sits nested underauth, which is also why the flatmaskFields()helper the other providers use would not have reached it. That log line is not only container stdout: it feeds the buffered stream behind the log API, so the credential was readable over HTTP as well as on disk.beareris now an infrastructure key in the shared redactor, which covers both the registration log and the/api/v1/triggersresponse, andHttpmasks the nestedbearerandpassworddirectly so the mask holds for any caller that readsmaskConfiguration(). GET /api/v1/debug/dumpreturned cloud registry credentials, chat bot tokens and agent secrets in the clear. The dump's redactor split a key on non-alphanumerics and required a whole segment to equal one of its known tokens, so every compound single-word field name slipped past it:SECRETACCESSKEY,ACCESSKEYID,CLIENTSECRET,BOTTOKEN,ACCESSTOKENandAGENTSECRETall came back verbatim, for ECR, ACR, Telegram, Matrix and the agent secret. The providers' ownmaskConfiguration()masks those fields correctly andstate.registries/state.triggersgo through it; the leak was the separateenvironment.ddEnvVarsblock, which is a flat copy of everyDD_*variable with__FILEsecrets already resolved to plaintext. The strong tokens now match anywhere in the key, while the short ones (pass,key,pat) stay segment-exact soCOMPASS_MODE,KEYFILEandDISPATCHare still readable. Pushover'suseris its user key rather than an address, so it is resolved by provider rather than by wideninguserfor everyone and hiding the SMTP mailbox with it. Any value carrying URL credentials is redacted regardless of its key, matching whatGET /containersalready did. This is the fourth fix to the same class of bug, which is why the docs now state the actual rule instead of "all sensitive values are redacted".- The debug dump served raw container environment variables, and no audit or rate limit sat on the route. It read containers through
getContainersRaw(), whose own comment says it is "for internal callers that do not return container data to users" — but the dump is downloaded and pasted into support threads.POST /:id/env/revealshows the same values behind a 10-per-minute limit and anenv-revealaudit row; the dump had neither, and it also missed the substring and URL-credential checks that path applies, soMINIO_SECRETKEYandREDIS_AUTHTOKENwere readable through it and not through the API. The dump now reads the same redacted clonesGET /containersserves, records adebug-dumpaudit entry on success, and is limited to 5 requests per minute. - Six registries matched lookalike hostnames and sent the operator's credentials to them. ACR, GHCR, GCR, Quay, LSCR and DHI decided whether an image belonged to them with a regex of the shape
/^.*\.?azurecr.io$/— an optional separator, and in four of the six an unescaped dot — soevilazurecr.io,evilghcr.io,ghcrXioandghcr.io.attacker.comall matched. Provider selection prefers a credentialed instance, and the request keeps the image's own host, so a container simply runningevilghcr.io/victim-org/private:1(a typo-squat, or a compromised compose file) was enough: no label needed. ACR then attaches a staticclientid:clientsecretBasic header unconditionally; the others mint a pull token from the operator's PAT at the real token endpoint and send it to the lookalike host. All six now use a sharedmatchRegistryHosthelper that accepts the base host exactly or as a dot-suffixed subdomain, which is what Hub, DOCR, Mau and Trueforge already did — the loose form was inherited from the upstream project's first commit, not chosen. Regional GCR (eu.gcr.io,asia.gcr.io) and per-tenant ACR (myregistry.azurecr.io) are unaffected.matchUrlPatternhad no callers left and is gone. - The command trigger's env sanitizer stopped shell injection but not argument injection. It replaced the metacharacters
`,$,;,&,|,<,>,(and)with_, and left spaces,-, and the globbing characters alone. The docs' own canonical example expands a variable unquoted, soimage_nameset to--registry evil.example/backdoor *reached an operator'sprintf "[%s]\n" $image_nameas--registry, thenevil.example/backdoor, then one word per file in the working directory — a flag and extra arguments injected into whatever the script does with them. Reachable without touching a container:POST /api/v1/triggers/command/:namedocuments caller-supplied container JSON as simulated test data, so any authenticated caller could set the field. Scalar container fields now also lose whitespace,*,?,[,],{,},~,\,"and'to_, and a leading-is dropped rather than replaced so the value cannot look like an option at all.container_json,containers_json,dd_titleanddd_bodyare exempt and keep their spaces and quotes, because the JSON has to stay pipeable intojqand the digest title and body are message text; the docs now say to double-quote every expansion and the examples do. The lifecycle hook sanitizer, which its own comment says matches this one, moved with it — hook values are all scalars, so nothing there legitimately carries a space. One visible consequence: add.display.namewith spaces now reaches a command script with underscores in their place. - An agent could still change how the controller pruned that agent's own containers, by naming an id it doesn't own. The ownership gate added for the bulk ingestion paths lives in
processAuthoritativeContainer, and all four callers prune before they ingest, so a foreign id in a sync frame still reachedpruneOldContainersunchecked. It could not delete or save a record on its own, because the prune only ever considers rows already owned by the reporting agent. What it could do is the #496 replacement match, which is keyed onagent::watcher::namewith the agent forced to the reporting agent: naming a container owned by another agent or by the controller was enough to turn the removal of one of the reporting agent's own rows into a replacement, which retains the update policy on the incoming record and skips the Home Assistant discovery cleanup for the one going away. The prune now filters its input through the same rule the ingest gate uses, silently, since the ingest pass logs each rejection a moment later.
Fixed
- A self-update could restart the controller while an unrelated container update was between removing the old container and starting its replacement. Self-update bypassed the optional global concurrency cap and shared no lock with a container in another Compose project, so both lifecycles could start together. The new controller then recovered the unrelated operation against its deleted container id, got a 404, and left the replacement in
Created. Self-update now takes a fair process-wide exclusive lifecycle gate: it waits for active updates to finish, later updates queue behind it, and a successful helper handoff keeps the gate closed until the process restarts. A failed or dry-run handoff releases the gate, and infrastructure-mode updates keep their existing global-cap bypass without becoming permanently exclusive. Reported with the operation records that exposed the race by @tarzan77cz in #930, fixed in #942. - A caller-supplied update operation id was inserted without checking whether it already existed.
POST /api/v1/triggers/:type/:nameand its per-agent variant accept anoperationIdin the body so the controller can thread its own id through to an agent-local route. Nothing checked it, and the store's id index is not unique, so reusing an id left two rows sharing it. Lookups return the newest of the two, so every later status write landed on the duplicate and the original stayedqueued, holding its container's active-operation gate for the full 30 minute TTL or until a restart reconciled it, and when the orphaned entry's turn came round it resolved to the other container's row and overwrote it with its own fields. The active-operation gate did not catch this because it only looks up operations for the container being targeted, so an id belonging to a different container passed straight through. Both routes now return 409 for an id that already exists, active or terminal, and nothing is inserted. - Self-update stranded itself on hosts that need the root break-glass pair, leaving nothing running under the container's real name. The transition helper is the drydock image with only its command overridden, so it still runs the entrypoint as uid 0, but its environment was built from scratch and carried only the
DD_SELF_UPDATE_*values. On a host where the Docker socket is owned by GID 0 — Docker Desktop, OrbStack, a root-owned socket, the population the FAQ tells to setDD_RUN_AS_ROOTandDD_ALLOW_INSECURE_ROOT— the entrypoint refused implicit root mode and exited 1, andAutoRemovedeleted the evidence. Nothing noticed: dockerode'sstart()resolves when the container execs, not when it exits, so the code logged "Helper container started" and reported success while the old container sat renamed to-old-<timestamp>, the new one had never been started, and the next attempt failed on the rollback cascade guard. The helper now inherits both variables from the running container's own inspected environment when both aretrue(both, because either one alone still gets refused), and a watchdog inspects the helper a few poll intervals after starting it: if it has already exited, or auto-remove has already reaped it, the rename is rolled back and the failure is reported with the exit code instead of being swallowed. Reading the pair from the container spec rather thanprocess.envkeeps it working for an agent-side spawn. - An update that had already passed its health gate could still be rolled back, and with
AutoRemoveset that could leave nothing running at all. The Docker update strategy renames the old container out of the way, creates and starts the new one, gates on health, and only then removes the old one. That last removal rethrew anything that was not a "no such container", which sent an already-finished update into the rollback path: the health-verified new container was stopped and force-removed, and the old one renamed back and restarted. The errors that reach it are ordinary Docker hiccups, not failures of the update — the 10 second timeout on waiting for anAutoRemovecontainer to disappear, a 409removal of container <id> is already in progress, a 500 from a slow overlay unmount or an anonymous-volume delete under IO pressure. When the old container hadAutoRemoveset the outcome was worse than a rollback, because Docker may already have deleted it by the time the rename back ran, so the rollback failed too and the user was left with no container at all. Cleanup after the health gate can no longer roll anything back: the new container stays running, the operation is markedsucceeded, and the leftover<name>-old-<timestamp>container is logged as a warning and recorded on the operation so it can be pruned. The two tests covering this pinned the old behaviour — they were written for branch coverage rather than as an assertion about what should happen — and now assert the update survives. - Every sanitized log line kept the visible half of an ANSI escape.
sanitizeLogParamstripped control characters first, which deletes the escape byte the ANSI pattern then looks for, so the pattern could never match andhello\x1b[31m worldcame out ashello[31m worldinstead ofhello world. Not an injection hole, since the escape byte itself was always removed and a terminal can't act on the remainder, but the module's own contract said it removed ANSI sequences and it didn't, the residue is in every log line, audit detail and close reason that carries user- or registry-supplied text, and the test asserting the behaviour had the residue written into its expected value. ANSI now goes first, and the pattern covers both ESC-prefixed and C1 CSI sequences across the whole CSI grammar rather than just color sequences ending inm, so a cursor or clear-screen sequence doesn't leave[2Jbehind either; the remaining C1 control range is stripped with the other controls. The scanner-asset error path had grown its own local copy of the ANSI regex to work around this, with a comment explaining the ordering problem; that copy is gone (#938). - Any repository with more than 1000 tags was silently truncated at the first page, and on AWS ECR Public it failed the watch outright. Tag listing captured the
Linkheader the registry returned and then discarded it, rebuilding the next-page cursor by hand aslast=<the previous page's last tag>. Pagination cursors are opaque under the OCI distribution spec, so that only works on registries that happen to accept a literal tag name there. ECR Public does not: it answers the rebuilt cursor with405 Method Not AllowedandInvalid parameter at 'NextToken', which is not in the retryable set and so surfaced asError when processing (Request failed with status code 405)for that container, every cycle. Found by running the published rc.6 image against a real Docker host:public.ecr.aws/supabase/postgreshas 1502 tags, page one returns 1000, and the installed tag existed only on the page that never loaded. Sibling images on the same registry were unaffected because none of them cross 1000 tags, which is why this reads as a per-image fault rather than a registry one. The cursor is now followed as given. A cursor pointing at a different origin is refused rather than followed, because the request carries registry credentials, and tag listing stops after 50 pages so a registry that always reports another page cannot loop forever. Coverage was 100% on this code the whole time: the existing tests drove the pagination loop withlink: 'next'andlink: 'rel="next"', neither of which is aLinkheader, so the loop condition saw a truthy value and the cursor-building path was never given anything real to parse (#927). - AWS ECR Public was rate-limited at the generic default and drew 429s from ordinary use. The per-host token bucket has tuned entries for GHCR, Docker Hub and the GitHub API, but ECR Public fell through to the more permissive 5 requests per second default despite throttling anonymous reads harder than any of them. A compose stack pulled from a single ECR namespace, Supabase self-host being the common case, puts a dozen containers on that host in one cycle and produced widespread
429warnings. It now uses the same 2 per second and burst of 10 the other strict registries get.
Performance
- Turning security scanning on could disconnect every open browser tab once per scan cycle. The
dd:container-addedanddd:container-updatedserver-sent events carried the whole container record, which includes the per-CVE arrays for both the running image and the update candidate (capped at 1000 entries each, around 340 bytes per entry), the SBOM documents and the cosign verification blocks. That is roughly 340 KB for a single container against the 256 KB a client is allowed to have pending while its socket is backed up, so a scan cycle touching two or three replicas of a vulnerable image dropped every listener, and the reconnect replayed the same oversized events out of the ring buffer and dropped them again. The container list endpoint has stripped those fields since it was written; the event path never did, and there was no projection between the store and the wire. Both lifecycle events, and the removal event, now go through that same projection, on the wire and in the replay ring, so they carry exactly what the list carries: scan status, severity summary, blocking count, block severities and scan time. No display loses anything, because the UI never read the arrays off the event — vulnerabilities, SBOMs and signatures are fetched from their own endpoints, and the security detail is refetched ondd:scan-completedregardless.
Documentation
- The public feature comparison was dated March 2026 and measured drydock against a field that no longer reflects what people are choosing. One table ran against WUD, Diun and two archived projects, so the three tools that draw the direct comparisons today, Arcane, Komodo and Dockhand, appeared nowhere in it. It is now two tables, update managers and management platforms, so neither gets wide enough to stop being readable, and every cell traces to the 2026-08-29 competitive audit. Corrections that came out of that audit: WUD ships threshold filtering and has 17 notification providers and 12 registries, not 16 and 13; Diun's semver and Home Assistant support are partial rather than full, and it does have Prometheus metrics; Watchtower's Shoutrrr count is about 20. Drydock's own rows now include the two it loses, RBAC and a real pending-approval queue, rather than only the ones it wins. A note under the first table records Watchtower's December 2025 archival, its v1.7.1 (November 2023) last release, and the unofficial community fork still shipping releases. The same table is mirrored into all six translated READMEs.
- Seventeen rows across the comparison surfaces on the site disagreed with the audit, and two of them undersold drydock or a competitor. Komodo ships maintenance windows, and Dockhand ships Prometheus metrics and Home Assistant MQTT, all three of which were listed as absent. Komodo and Dockhand both have a limited dry-run rather than none, and Dockhand has no rollback on failure where the
/compareindex called it partial. Diun's semver and MQTT rows claimed parity where the audit found partial support. Dockge's stable releases have been stalled since March 2025, so it is no longer listed as plainly maintained, and it ships 29 UI languages, not "30+". Portainer does have a distributed agent architecture, which the homepage teaser denied. And the Dozzle page still called drydock's resource monitoring planned when it shipped in v1.5.0. The WUD and Watchtower provider counts now agree with the README, and Drydock's 23 registries are 11 more than WUD's 12, not 10 (#938). - The README roadmap table listed scoped rotatable API keys under v2.0+. They are v1.8.0 work now, where the SQLite store migration provides the persisted key table they depend on, and the per-update approval queue is listed with them.
Chore
- A compose rollback that itself fails was mapped to a terminal state no test ever drove.
getComposeRollbackTerminalPatchturns arollback-failedcompose outcome into{status: failed, phase: rollback-failed}, but the whole branch sat under av8 ignoreclaiming it was integration-covered through compose recovery, and nothing drove that status throughrunContainerUpdateLifecycle: the one lifecycle test usedrolled-back, and the compose-side test stops at the throw. A regression mapping it torolled-back, or letting it fall through to the duplicate-updateexpiredreclassification, would have shipped at 100% coverage. The second ignore in that path, over the rollback-state persistence, was stale the other way: the rolled-back lifecycle test has been exercising it all along. Seven new lifecycle cases now cover both statuses plus the blank-reason, absent-lastError, unrecognized-status, no-container-id, no-persistence-dependency and watcher-only-identity paths, which retires seven of the file's thirteenv8 ignoreblocks. The terminal patch'slastErroris also typed as always present, which it always was, so its dead fallback is gone. The six ignores left each name a specific unreachable input instead of claiming coverage that lives somewhere else.