<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Todd Matens</title>
    <description>The latest articles on DEV Community by Todd Matens (@tmatens).</description>
    <link>https://dev.to/tmatens</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3946817%2F30ab95dc-17d7-4d40-9619-d451224e9093.png</url>
      <title>DEV Community: Todd Matens</title>
      <link>https://dev.to/tmatens</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tmatens"/>
    <language>en</language>
    <item>
      <title>Docker Hub's access token vulnerability: how signing let me verify my images</title>
      <dc:creator>Todd Matens</dc:creator>
      <pubDate>Mon, 12 Oct 2026 23:48:20 +0000</pubDate>
      <link>https://dev.to/tmatens/docker-hubs-access-token-vulnerability-how-signing-let-me-verify-my-images-58i3</link>
      <guid>https://dev.to/tmatens/docker-hubs-access-token-vulnerability-how-signing-let-me-verify-my-images-58i3</guid>
      <description>&lt;p&gt;A critical bug in Docker Hub's access controls meant some personal access tokens, including the ones I use to publish &lt;a href="https://tmatens.github.io/compose-lint/" rel="noopener noreferrer"&gt;compose-lint&lt;/a&gt;, a security linter for Docker Compose files, could have been used by someone else. Not the email you want on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;I actually got two of them on Friday, October 2, about an hour apart. The one I happened to open first was addressed to me as the organization owner. It was pretty vague and said there was no action for me to take, so I skimmed it and moved on. The other one, sent to my user account, was titled "Action Requested." When I had a chance to read both more closely, I realized my user was the one flagged, and Docker was planning to force-deactivate my two CI tokens that Monday, October 5.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5912eqrm9ajzy8noyk1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5912eqrm9ajzy8noyk1.jpg" alt="Docker's security notice: a critical vulnerability in Docker Hub's IAM could let one user impersonate another via a personal access token; fixed within four hours, no evidence of exploitation" width="800" height="921"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On September 30, an outside researcher had reported a flaw in Docker Hub's identity and access management. Under certain conditions around converting a personal account to an organization, one user could take on another user's capabilities through a personal access token, which means pushing or pulling images as them. Docker said they fixed it within about four hours and hadn't seen any sign of it being exploited.&lt;/p&gt;

&lt;p&gt;At that point I knew I needed to rotate my tokens and check that my published images hadn't been touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I could check
&lt;/h2&gt;

&lt;p&gt;I started compose-lint because I couldn't find a Compose linter that met my needs or that I was happy with, and I'd been looking for a chance to build something open source. I also wanted a project where I could build with complete freedom and put everything I know about security engineering into it, including the release pipeline.&lt;/p&gt;

&lt;p&gt;So every compose-lint image is signed with cosign, keyless, using GitHub Actions' OIDC identity. The signing certificate records the workflow and tag that produced the image, something like &lt;code&gt;publish.yml@refs/tags/v0.34.0&lt;/code&gt;. Someone with Docker Hub push access could push an image, but they couldn't produce that signature without running my workflow in my repo on a release tag.&lt;/p&gt;

&lt;p&gt;That meant I didn't have to take Docker's word for it. I could go check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked
&lt;/h2&gt;

&lt;p&gt;I verified every tag against my workflow identity. Checking that an image has a valid signature isn't enough on its own, since anyone can sign an image. You have to pin who signed it.&lt;/p&gt;

&lt;p&gt;This is where I hit something I hadn't thought about. Most of my images were signed by &lt;code&gt;publish.yml&lt;/code&gt;, but one older release, 0.3.4, was signed by a different workflow I'd used to republish it. Different workflow, different identity in the certificate. I could still verify everything, but it meant using a regexp that allowed both workflows instead of one exact identity. It works, it's just not as clean as I'd like. If you're setting this up, it's worth keeping signing in one workflow, or at least knowing every identity you've ever signed with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;tag &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://hub.docker.com/v2/repositories/composelint/compose-lint/tags?page_size=100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.results[].name'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;cosign verify &lt;span class="s2"&gt;"docker.io/composelint/compose-lint:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--certificate-identity-regexp&lt;/span&gt; &lt;span class="s1"&gt;'^https://github\.com/tmatens/compose-lint/\.github/workflows/(publish|publish-channel)\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--certificate-oidc-issuer&lt;/span&gt; https://token.actions.githubusercontent.com &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok   &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 42 images verified, and every one traced back to the CI run that built it.&lt;/p&gt;

&lt;p&gt;A signature check only tells you what a tag points to today, though. Someone could point a tag at a bad image, wait for people to pull it, then point it back, and everything would still verify afterward. So I also pulled each tag's &lt;code&gt;last_pushed&lt;/code&gt; time from the Docker Hub API and matched it against my release runs. Every push lined up with a real run. I also compared the full list of tags against my releases to make sure nothing extra had shown up and nothing was missing.&lt;/p&gt;

&lt;p&gt;Everything came back clean. I rotated the tokens and split them up so each job only gets the one it needs: read-only for the nightly scans, push for releases, and a separate one for updating the Docker Hub description.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed afterward
&lt;/h2&gt;

&lt;p&gt;Going back through the pipeline, I found a couple of things I wanted to fix.&lt;/p&gt;

&lt;p&gt;The release workflow signed whatever the tag pointed to right after pushing it. In the few seconds between push and sign, someone with write access to the registry could have moved the tag and gotten their image signed by my workflow. The window is small, but it's there. Now the workflow checks that the tag points to exactly what the build pushed, and stops before signing if it doesn't.&lt;/p&gt;

&lt;p&gt;The verify step in CI was also using a loose regexp. It now checks the exact workflow with &lt;code&gt;--certificate-identity "https://github.com/${GITHUB_WORKFLOW_REF}"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And signing doesn't help much if nobody verifies, so I added the command to the docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.34.0
cosign verify &lt;span class="s2"&gt;"docker.io/composelint/compose-lint:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-identity&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/tmatens/compose-lint/.github/workflows/publish.yml@refs/tags/v&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-oidc-issuer&lt;/span&gt; https://token.actions.githubusercontent.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs cover &lt;a href="https://tmatens.github.io/compose-lint/verifying/" rel="noopener noreferrer"&gt;how to verify compose-lint images&lt;/a&gt; in more detail, including the SBOM and VEX attestations.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you maintain images and aren't signing yet
&lt;/h2&gt;

&lt;p&gt;On GitHub Actions it's about twenty lines. The job needs &lt;code&gt;id-token: write&lt;/code&gt; so cosign can get a short-lived certificate tied to your workflow, and you sign by digest, not by tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sigstore/cosign-installer@&amp;lt;pinned-sha&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;# build and push, capturing the digest&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cosign sign --yes "docker.io/your-org/your-image@${DIGEST}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see &lt;a href="https://github.com/tmatens/compose-lint/blob/d9006ac2cc0bb2c17024eeab6af180e4c033e08e/.github/workflows/publish.yml#L720-L739" rel="noopener noreferrer"&gt;the sign and verify steps in compose-lint's release workflow&lt;/a&gt;, and the rest of the project is on &lt;a href="https://github.com/tmatens/compose-lint" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Twenty lines is the open source on GitHub version. At a bigger org, signing is the easy part. Managing keys, keeping private repo names out of public transparency logs, deciding which builds to trust, and actually enforcing verification at deploy time is where it gets hard.&lt;/p&gt;

&lt;p&gt;For a small project, though, it's cheap. And the next time a vendor tells you there's no evidence of exploitation, you'll be able to check for yourself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Personal project, personal views.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>githubactions</category>
      <category>opensource</category>
      <category>docker</category>
    </item>
    <item>
      <title>9 in 10 Docker Compose files skip the basic security flags</title>
      <dc:creator>Todd Matens</dc:creator>
      <pubDate>Fri, 22 May 2026 22:48:21 +0000</pubDate>
      <link>https://dev.to/tmatens/9-in-10-docker-compose-files-skip-the-basic-security-flags-2dpf</link>
      <guid>https://dev.to/tmatens/9-in-10-docker-compose-files-skip-the-basic-security-flags-2dpf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, October 2026:&lt;/strong&gt; this post covers the &lt;a href="https://github.com/tmatens/compose-lint/blob/082db9f5c63c5a5637f2e9e2b29d00e345023b00/docs/state-of-compose.md" rel="noopener noreferrer"&gt;first edition&lt;/a&gt; of the study (6,444 files). The current edition covers 11,111 files with a tighter methodology, and some headline numbers have changed. Most notably, 99.2% of the files that actually lint now carry at least one finding. Read the current edition: &lt;strong&gt;&lt;a href="https://tmatens.github.io/compose-lint/state-of-compose/" rel="noopener noreferrer"&gt;State of Docker Compose Security&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I created &lt;a href="https://github.com/tmatens/compose-lint" rel="noopener noreferrer"&gt;compose-lint&lt;/a&gt;, a security linter for Docker Compose files, and pointed it at &lt;strong&gt;6,444 public &lt;code&gt;docker-compose.yml&lt;/code&gt; and &lt;code&gt;compose.yaml&lt;/code&gt; files from GitHub&lt;/strong&gt;. (More on why below.)&lt;/p&gt;

&lt;p&gt;Three things stood out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;91%&lt;/strong&gt; of the files that parse have at least one security finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;68%&lt;/strong&gt; have at least one &lt;strong&gt;HIGH or CRITICAL&lt;/strong&gt; finding.&lt;/li&gt;
&lt;li&gt;The same three issues top &lt;em&gt;every&lt;/em&gt; category I looked at, including the official vendor examples people are told to copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't read this as engineers being careless. It's about defaults. Docker Compose ships with the hardening switched off, almost nobody turns it on, and the examples people learn from don't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;By day I lead a team of security engineers at a large financial institution, where Compose barely comes up. Production runs on Kubernetes and ECS, both with mature security tooling around them. At home in my lab, though, Compose is the right tool: quick, low-ceremony, enough to stand up a stack on a Saturday.&lt;/p&gt;

&lt;p&gt;What bugged me was the asymmetry. Kubernetes and Terraform have a deep bench of scanners: Checkov, Trivy, kube-bench, Kubescape. Compose is an afterthought in most of them. The Compose-specific tools I found solved adjacent problems instead. &lt;a href="https://github.com/hadolint/hadolint" rel="noopener noreferrer"&gt;Hadolint&lt;/a&gt; lints Dockerfiles, not Compose files. &lt;a href="https://github.com/zavoloklom/docker-compose-linter" rel="noopener noreferrer"&gt;dclint&lt;/a&gt; checks Compose structure and style, not security.&lt;/p&gt;

&lt;p&gt;What I wanted was simple: a zero-config, OWASP/CIS-grounded linter I could drop into CI and run against my own stacks. So I wrote one. Then I got curious whether the stuff I kept fixing in my own files showed up everywhere else.&lt;/p&gt;

&lt;p&gt;It does. That's what this writeup is about, and I'm putting the tool out there in case it's useful to anyone who builds the way I do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Personal project; the views here are my own, not my employer's.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the corpus is built
&lt;/h2&gt;

&lt;p&gt;I split the files into four tiers. A single "X% of Compose files do Y" number is misleading when it averages a polished Bitnami example against someone's half-finished homelab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;canonical&lt;/code&gt;&lt;/strong&gt; (327 files) — official upstream examples: awesome-compose, Bitnami, Grafana, Vaultwarden. &lt;em&gt;The stuff READMEs tell you to copy-paste.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;popular&lt;/code&gt;&lt;/strong&gt; (3,977) — repos with ≥50 stars and a recent Compose file. &lt;em&gt;Production-adjacent code.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selfhosted&lt;/code&gt;&lt;/strong&gt; (588) — app-store / template registries: CasaOS, runtipi, Dockge. &lt;em&gt;Home-LAN threat model.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;longtail&lt;/code&gt;&lt;/strong&gt; (1,552) — a stratified sweep of GitHub code search. &lt;em&gt;The median file in the wild.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every file goes through the same rule set (compose-lint 0.7.0), and every rule is grounded in the &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Docker Security Cheat Sheet&lt;/a&gt; or the CIS Docker Benchmark. The full methodology, including what the study deliberately doesn't claim, is in the &lt;a href="https://tmatens.github.io/compose-lint/state-of-compose/" rel="noopener noreferrer"&gt;canonical report&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: nobody flips the hardening flags
&lt;/h2&gt;

&lt;p&gt;Three findings fire on roughly &lt;strong&gt;90% of every file in the corpus&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3n5a5a5flb1ugvkx2cqg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3n5a5a5flb1ugvkx2cqg.png" alt="Most common findings across the corpus, by share of parsed files affected, coloured by severity" width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filesystem not read-only&lt;/strong&gt; (&lt;code&gt;read_only: true&lt;/code&gt; missing) — 91%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No capability restrictions&lt;/strong&gt; (&lt;code&gt;cap_drop: [ALL]&lt;/code&gt; missing) — 91%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privilege escalation not blocked&lt;/strong&gt; (&lt;code&gt;no-new-privileges&lt;/code&gt; missing) — 90%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They're rated MEDIUM, not CRITICAL, because each one is a missing control rather than active misuse. That's also what makes them interesting. The Compose hardening triple is almost never set.&lt;/p&gt;

&lt;p&gt;The fix takes about 30 seconds per service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27@sha256:...&lt;/span&gt;   &lt;span class="c1"&gt;# pin a digest, not just a tag&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                &lt;span class="c1"&gt;# CL-0007&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ALL&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;                &lt;span class="c1"&gt;# CL-0006&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;     &lt;span class="c1"&gt;# CL-0003&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a &lt;code&gt;tmpfs:&lt;/code&gt; for whatever paths your app writes to and you've cleared the three most common findings in the corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Aren't these just optional config choices, not real vulnerabilities?"&lt;/strong&gt; Mostly fair, and it's worth being exact about what a "finding" is here. The linter isn't claiming anything was exploited. It's flagging that a file leaves a recommended hardening control unset. Whether that matters is a judgment call that belongs to the org or the engineer, based on their context and risk tolerance. What the linter takes off your plate is the "did I even know this control existed?" part. &lt;code&gt;read_only&lt;/code&gt;, &lt;code&gt;cap_drop: [ALL]&lt;/code&gt;, and &lt;code&gt;no-new-privileges&lt;/code&gt; aren't my preferences about tidy YAML; they're named controls in the &lt;a href="https://www.cisecurity.org/benchmark/docker" rel="noopener noreferrer"&gt;CIS Docker Benchmark&lt;/a&gt; and the OWASP Docker Security Cheat Sheet. A finding means the config diverges from that published baseline. Closing the gap or accepting it is your call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: even copy-paste vendor examples aren't clean
&lt;/h2&gt;

&lt;p&gt;You'd expect the official examples to be the hardened ones. Being copied is their entire job. They're the cleanest tier in the corpus, and they still come in at &lt;strong&gt;83%&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjxhr2xkzx15qdcqn110k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjxhr2xkzx15qdcqn110k.png" alt="Share of files with at least one finding by tier: canonical 82.8%, popular 95.2%, selfhosted 100%, longtail 78.3%" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things jump out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted app-store templates: 100%.&lt;/strong&gt; Every single one trips at least one rule. They're built for "works on your LAN in one click," which in practice means exposed ports, root users, and big host mounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Popular repos aren't better than the long tail.&lt;/strong&gt; Stars don't buy hardening discipline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonical examples are config demos, not hardening exemplars.&lt;/strong&gt; People copy them into production anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is most of the story. The examples teach the unhardened shape, and the shape propagates.&lt;/p&gt;

&lt;p&gt;A fair caveat here, especially if you're running a homelab: threat model matters. A single-user box behind a firewall and Tailscale is a different risk calculus than something exposed to the internet, and a finding is usually something to decide about rather than an emergency. Start with what actually bites. A mounted Docker socket is full host takeover whether or not you meant to expose it, so fix those first and treat the MEDIUM pile as gradual cleanup. It's why the CI gate defaults to &lt;code&gt;fail-on: high&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: ~10% of ordinary files don't even parse
&lt;/h2&gt;

&lt;p&gt;This is the one I didn't expect. In the long-tail tier, &lt;strong&gt;9.6%&lt;/strong&gt; of files don't parse as a valid Compose file at all, against well under 1% everywhere else:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3fdv06g8drxsqyehsc0o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3fdv06g8drxsqyehsc0o.png" alt="Parse-error rate by tier: canonical 0.6%, popular 0.7%, selfhosted 0.0%, longtail 9.6%" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And it's almost never broken YAML. It's shape errors: people writing &lt;code&gt;services&lt;/code&gt; as a dictionary of strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What a lot of people write (does not parse):&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;

&lt;span class="c1"&gt;# What Compose actually wants:&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file that doesn't parse with a real Compose engine was never deployed by one. So these are docs snippets, tutorial follow-alongs, half-finished first drafts. None of them are getting linted before they ship, and the parse-error rate is really a map of where unreviewed config piles up.&lt;/p&gt;

&lt;p&gt;(If you're wondering: "long tail" here means the low-visibility mass of ordinary repos, not a statistical distribution tail.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's all MEDIUM-heavy
&lt;/h2&gt;

&lt;p&gt;One more chart, because the severity mix surprises people:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ys4w8tcnda2imboe12e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ys4w8tcnda2imboe12e.png" alt="Findings by severity: MEDIUM 78.5%, HIGH 20.3%, CRITICAL 1.1%, LOW 0.0%" width="799" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nearly four out of five findings are MEDIUM. That's the hardening-triple misses from Finding 1, which hit almost every file and are MEDIUM by design. CRITICAL findings are rarer but real: a mounted Docker socket, &lt;code&gt;cap_add: ALL&lt;/code&gt;, a bind-mounted &lt;code&gt;/&lt;/code&gt;. The Docker socket one alone, which is full host takeover, shows up on &lt;strong&gt;6.4%&lt;/strong&gt; of parsed files and &lt;strong&gt;8%&lt;/strong&gt; in the popular tier.&lt;/p&gt;

&lt;p&gt;LOW is almost empty, and that's by construction. Only one of compose-lint's 21 rules is LOW (a healthcheck someone explicitly turned off), because the tool's whole scope is security misconfiguration, where the floor is MEDIUM. So read "0.0% LOW" as a fact about the tool, not as the small stuff being fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why are all the flags off by default?
&lt;/h2&gt;

&lt;p&gt;Because Docker optimizes for "it runs the first time." A writable filesystem, the full capability set, privilege escalation left on: that's the path of least surprise. Your container starts, your app works. Hardening is opt-in, and opting in means knowing the control exists, confirming your app still works without that capability or that write access, and adding a few lines per service.&lt;/p&gt;

&lt;p&gt;And "knowing it exists" is the hard part. There's no single secure-Compose baseline to copy from. The controls are scattered across the Compose spec, the Docker run reference, the &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Docker Security Cheat Sheet&lt;/a&gt;, and the CIS benchmark. You'd have to already know that &lt;code&gt;no-new-privileges&lt;/code&gt; is a thing, that &lt;code&gt;cap_drop: [ALL]&lt;/code&gt; goes before a targeted &lt;code&gt;cap_add&lt;/code&gt;, that &lt;code&gt;read_only: true&lt;/code&gt; usually needs a &lt;code&gt;tmpfs&lt;/code&gt; for whatever your app writes. Most people writing a Compose file aren't container-security specialists. They want their stack up. Expecting everyone to carry that whole surface in their head is how you end up with a 91% finding rate. A linter flips it around: you don't memorize anything, you fix the line it points at and read why.&lt;/p&gt;

&lt;p&gt;And it compounds. The examples never opt in, so the next person copies the unhardened shape, ships it, and becomes the next example someone copies. The corpus is that loop at scale. None of it is exotic. It's the accumulated weight of a sensible default that nobody goes back to revisit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is &lt;em&gt;not&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A few things I'm explicitly not claiming, because it's easy to over-read this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It measures how common misconfigurations are, not whether they were exploited. A finding is divergence from hardening guidance, nothing more.&lt;/li&gt;
&lt;li&gt;It's descriptive sampling, not statistical inference. No p-values, no population estimates.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;GitHub-only and public-only.&lt;/strong&gt; Private and enterprise Compose may look different.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://tmatens.github.io/compose-lint/state-of-compose/#what-this-study-does-not-claim" rel="noopener noreferrer"&gt;full "what this study does not claim"&lt;/a&gt; section in the report lays out every boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own files
&lt;/h2&gt;

&lt;p&gt;compose-lint is MIT-licensed, zero-config, and depends only on PyYAML. A few ways to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# one-off, locally&lt;/span&gt;
pipx &lt;span class="nb"&gt;install &lt;/span&gt;compose-lint &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; compose-lint docker-compose.yml

&lt;span class="c"&gt;# or the published image (distroless, nonroot)&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:/src"&lt;/span&gt; composelint/compose-lint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI, there's a GitHub Action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/compose-lint.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose-lint&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tmatens/compose-lint@v0.7.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*compose*.y*ml"&lt;/span&gt;   &lt;span class="c1"&gt;# docker-compose.yml, compose.yaml, …&lt;/span&gt;
          &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fail-on: high&lt;/code&gt; (the default) fails only on HIGH/CRITICAL, so you can adopt it without drowning in the MEDIUM backlog on day one, then tighten later. There's also a pre-commit hook, JSON and SARIF output (SARIF feeds GitHub Code Scanning), and &lt;code&gt;compose-lint --explain CL-0007&lt;/code&gt; to print any rule's rationale and fix.&lt;/p&gt;

&lt;p&gt;For what it's worth on a tool you'd wire into CI: every rule cites OWASP, CIS, or Docker docs, the image is distroless and nonroot, and releases ship SLSA provenance and Sigstore attestations. Details are in the &lt;a href="https://github.com/tmatens/compose-lint" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The full report&lt;/strong&gt; has every table, the complete methodology, the per-rule breakdowns, and steps to reproduce it: &lt;strong&gt;&lt;a href="https://tmatens.github.io/compose-lint/state-of-compose/" rel="noopener noreferrer"&gt;State of Docker Compose Security&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you maintain a popular Compose example, I'd genuinely love a PR or an issue. Hardening the examples people copy is the highest-leverage fix there is.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>containers</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
