<?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: Sour durian</title>
    <description>The latest articles on DEV Community by Sour durian (@duriantaco).</description>
    <link>https://dev.to/duriantaco</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2757090%2Fcf7b3e60-5d34-4a60-9a8b-1e954aed1128.png</url>
      <title>DEV Community: Sour durian</title>
      <link>https://dev.to/duriantaco</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/duriantaco"/>
    <language>en</language>
    <item>
      <title>GitHub Actions Security and GitLab CI Security: Static Analysis for CI/CD</title>
      <dc:creator>Sour durian</dc:creator>
      <pubDate>Tue, 12 May 2026 13:55:19 +0000</pubDate>
      <link>https://dev.to/duriantaco/github-actions-security-and-gitlab-ci-security-static-analysis-for-cicd-g9h</link>
      <guid>https://dev.to/duriantaco/github-actions-security-and-gitlab-ci-security-static-analysis-for-cicd-g9h</guid>
      <description>&lt;p&gt;CI/CD is production infrastructure.&lt;/p&gt;

&lt;p&gt;No sh*t captain obvious! But most teams still review &lt;code&gt;.py&lt;/code&gt;, &lt;code&gt;.ts&lt;/code&gt;, &lt;code&gt;.go&lt;/code&gt;, and &lt;code&gt;.java&lt;/code&gt; files much more than they review the YAML that builds, signs, publishes, and deploys those files.&lt;/p&gt;

&lt;p&gt;That gap is where a lot of CI/CD supply-chain security risk lives, and it is a good fit for static analysis because many of the risky patterns are visible before the pipeline runs.&lt;/p&gt;

&lt;p&gt;A risky workflow does not need to be a sophisticated zero-day. Sometimes it can be as simple as just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a GitHub Action pinned to &lt;code&gt;@v4&lt;/code&gt; instead of a commit SHA&lt;/li&gt;
&lt;li&gt;a GitLab &lt;code&gt;include:&lt;/code&gt; that follows a moving branch&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker:dind&lt;/code&gt; with TLS disabled&lt;/li&gt;
&lt;li&gt;a release job restoring cache from less trusted jobs&lt;/li&gt;
&lt;li&gt;jobs that can request OIDC-backed credentials while running repo-controlled build scripts&lt;/li&gt;
&lt;li&gt;untrusted pull request text passed into &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;bash -c&lt;/code&gt;, or a script template&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the kinds of issues we recently added to Skylos, an open-source local static analysis tool. Skylos already scanned code for security, secrets, dead code, and quality issues. It now also works as a GitHub Actions security scanner and GitLab CI security scanner when you run danger analysis.&lt;/p&gt;

&lt;p&gt;This post explains the problem, the checks worth running, and how to scan a repo locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CI/CD Security Is Different
&lt;/h2&gt;

&lt;p&gt;Application code usually runs after review, after packaging, and inside some controlled runtime.&lt;/p&gt;

&lt;p&gt;CI/CD code runs before all of that.&lt;/p&gt;

&lt;p&gt;It often has access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repository tokens&lt;/li&gt;
&lt;li&gt;package registry tokens&lt;/li&gt;
&lt;li&gt;cloud credentials&lt;/li&gt;
&lt;li&gt;deployment keys&lt;/li&gt;
&lt;li&gt;signing credentials&lt;/li&gt;
&lt;li&gt;production environment names&lt;/li&gt;
&lt;li&gt;build artifacts&lt;/li&gt;
&lt;li&gt;release permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means your workflow files are not "just config". They are privileged automation code.&lt;/p&gt;

&lt;p&gt;The security model is also unusual because CI/CD sits at the boundary between trusted maintainers and untrusted input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pull request titles and branch names&lt;/li&gt;
&lt;li&gt;commit messages&lt;/li&gt;
&lt;li&gt;issue comments&lt;/li&gt;
&lt;li&gt;external includes&lt;/li&gt;
&lt;li&gt;third-party actions&lt;/li&gt;
&lt;li&gt;mutable container images&lt;/li&gt;
&lt;li&gt;cache restored from previous jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that boundary is loose, the pipeline can become the path from "someone opened a PR" to "someone got a publish token".&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions Issues Worth Checking
&lt;/h2&gt;

&lt;p&gt;GitHub Actions has some well-known footguns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dangerous Triggers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pull_request_target&lt;/code&gt; is useful, but dangerous. It runs in the context of the base repository and can expose privileged tokens if it checks out or executes untrusted PR code.&lt;/p&gt;

&lt;p&gt;Safer default:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need &lt;code&gt;pull_request_target&lt;/code&gt;, isolate it. Avoid building or running code from the pull request in the privileged job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unpinned Actions
&lt;/h3&gt;

&lt;p&gt;This is common:&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is stable enough for many teams, but it is still a mutable reference compared with a full commit SHA.&lt;/p&gt;

&lt;p&gt;For high-trust release pipelines, prefer:&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="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@f43a0e5ff2bd294095638e18286ca9a3d1956744&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to third-party actions and reusable workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broad Token Permissions
&lt;/h3&gt;

&lt;p&gt;Avoid relying on default token permissions.&lt;/p&gt;

&lt;p&gt;Start with:&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="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grant only what each job needs:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Release jobs may need more, but they should be explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Template Injection
&lt;/h3&gt;

&lt;p&gt;This is risky:&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="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;echo "${{ github.event.pull_request.title }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull request titles are user-controlled. Move the value into an environment variable and quote it like normal shell data:&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="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;printf '%s\n' "$PR_TITLE"&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;PR_TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  OIDC Mixed With Build Scripts
&lt;/h3&gt;

&lt;p&gt;OIDC is good when it removes long-lived cloud secrets.&lt;/p&gt;

&lt;p&gt;It becomes risky when the same job also runs repo-controlled build or release scripts:&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;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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/build-and-publish.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build in one job without OIDC.&lt;/li&gt;
&lt;li&gt;Upload a strict artifact.&lt;/li&gt;
&lt;li&gt;Publish from a smaller job that has OIDC and only consumes the artifact.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  GitLab CI Issues Worth Checking
&lt;/h2&gt;

&lt;p&gt;GitLab CI has a different syntax and different assumptions, but the same core risk exists: YAML controls privileged automation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unpinned External Includes
&lt;/h3&gt;

&lt;p&gt;This is risky:&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;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;group/security/pipelines&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a pinned &lt;code&gt;ref&lt;/code&gt;, the included pipeline can change outside your repository review process.&lt;/p&gt;

&lt;p&gt;Better:&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;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;group/security/pipelines&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template.yml&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For remote includes, use integrity checks where possible:&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;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;remote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://example.com/ci.yml&lt;/span&gt;
    &lt;span class="na"&gt;integrity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha256-...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Mutable Images and Services
&lt;/h3&gt;

&lt;p&gt;This is common:&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:latest&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:dind&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For release-sensitive jobs, mutable image tags are a supply-chain risk. Use digest-pinned images for jobs that publish, deploy, or handle credentials.&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python@sha256:...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker-in-Docker deserves extra attention because the service is often privileged and connected to build or publish logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker-in-Docker With TLS Disabled
&lt;/h3&gt;

&lt;p&gt;This GitLab pattern should trigger review:&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:dind&lt;/span&gt;

&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2375&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the Docker daemon is exposed without TLS inside the CI network.&lt;/p&gt;

&lt;p&gt;If the job also builds and pushes images, a compromised build step can become a path to registry compromise when push credentials are available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets in YAML Variables
&lt;/h3&gt;

&lt;p&gt;This is a smell:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DEPLOY_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plaintext-token-here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secret-looking values should live in GitLab protected and masked CI/CD variables, not in &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The YAML file should reference controlled values, not contain them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Untrusted Metadata Passed Into Eval-Like Commands
&lt;/h3&gt;

&lt;p&gt;This is risky:&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;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;eval "$CI_MERGE_REQUEST_TITLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also review commands like:&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;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bash -c "$CI_COMMIT_MESSAGE"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node -e "$CI_COMMIT_REF_NAME"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Merge request titles, descriptions, commit messages, and branch names can be attacker-controlled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release Jobs Restoring Cache
&lt;/h3&gt;

&lt;p&gt;This is subtle:&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;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cache is useful for speed, but cache restore in privileged release jobs deserves review when the restored files can be influenced by less trusted jobs.&lt;/p&gt;

&lt;p&gt;For publish/deploy jobs, prefer clean installs, immutable artifacts, and narrow permissions over broad cache restore.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Scan Locally With Skylos
&lt;/h2&gt;

&lt;p&gt;After the release containing GitLab CI scanning is available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; skylos
skylos &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--danger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are testing from &lt;code&gt;main&lt;/code&gt; before release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"git+https://github.com/duriantaco/skylos.git"&lt;/span&gt;
skylos &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--danger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skylos automatically detects the common CI/CD static analysis entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.github/workflows/*.yml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.github/workflows/*.yaml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;action.yml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;action.yaml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.gitlab-ci.yml&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No separate flag is needed.&lt;/p&gt;

&lt;p&gt;You can also scan a single CI file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;skylos .gitlab-ci.yml &lt;span class="nt"&gt;--danger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the full local bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;skylos &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Example: Risky GitLab CI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;group/security/pipelines&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template.yml&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;python:latest&lt;/span&gt;

&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DEPLOY_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plaintext-token-123&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;

&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&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;docker:latest&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:dind&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$RUNNER_TAG"&lt;/span&gt;
  &lt;span class="na"&gt;id_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;VAULT_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aud&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vault.example.com&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./scripts/release.sh&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push registry.example.com/app:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Issues to review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unpinned project include&lt;/li&gt;
&lt;li&gt;mutable &lt;code&gt;python:latest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;literal secret-looking variable&lt;/li&gt;
&lt;li&gt;Docker-in-Docker with TLS disabled&lt;/li&gt;
&lt;li&gt;mutable &lt;code&gt;docker:dind&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;dynamic runner tag&lt;/li&gt;
&lt;li&gt;OIDC credentials in a job running local release scripts&lt;/li&gt;
&lt;li&gt;cache restore in a release-like job&lt;/li&gt;
&lt;li&gt;missing timeout on a release/OIDC job&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Example: Safer Direction
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;group/security/pipelines&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template.yml&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;de0fac2e4500dabe0009e67214ff5f5447ce83dd&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;python@sha256:...&lt;/span&gt;

&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pytest&lt;/span&gt;

&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15 minutes&lt;/span&gt;
  &lt;span class="na"&gt;id_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;VAULT_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aud&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vault.example.com&lt;/span&gt;
  &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;PROD_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;vault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production/password@ops&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$VAULT_TOKEN&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "publish prebuilt artifact"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a complete security model, but it moves in the right direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;external CI code is pinned&lt;/li&gt;
&lt;li&gt;privileged jobs have timeouts&lt;/li&gt;
&lt;li&gt;secrets are not hardcoded in YAML&lt;/li&gt;
&lt;li&gt;token selection is explicit&lt;/li&gt;
&lt;li&gt;release logic is smaller&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Static Analysis Can and Cannot Know
&lt;/h2&gt;

&lt;p&gt;Static analysis cannot see everything. And that is just the unfortunate truth.&lt;/p&gt;

&lt;p&gt;It cannot know whether your GitLab variable is actually protected. It cannot know whether your runner fleet is isolated correctly. It cannot prove that every release script is safe.&lt;/p&gt;

&lt;p&gt;But it can catch patterns that are worth reviewing before the pipeline runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dangerous triggers&lt;/li&gt;
&lt;li&gt;unpinned references&lt;/li&gt;
&lt;li&gt;broad permissions&lt;/li&gt;
&lt;li&gt;literal secrets&lt;/li&gt;
&lt;li&gt;eval-like command sinks&lt;/li&gt;
&lt;li&gt;OIDC exposure to repo-controlled scripts&lt;/li&gt;
&lt;li&gt;release jobs with cache restore&lt;/li&gt;
&lt;li&gt;missing timeouts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the right job for a CI/CD static analyzer. Find the risky edges early, keep the signal high, and avoid pretending to know runtime state it cannot inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official References
&lt;/h2&gt;

&lt;p&gt;These are useful primary docs when reviewing the patterns above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions script injection risks: &lt;a href="https://docs.github.com/en/actions/concepts/security/script-injections" rel="noopener noreferrer"&gt;https://docs.github.com/en/actions/concepts/security/script-injections&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Actions security concepts: &lt;a href="https://docs.github.com/en/actions/concepts/security" rel="noopener noreferrer"&gt;https://docs.github.com/en/actions/concepts/security&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitLab CI YAML reference for &lt;code&gt;id_tokens&lt;/code&gt; and &lt;code&gt;secrets:token&lt;/code&gt;: &lt;a href="https://docs.gitlab.com/ee/ci/yaml/" rel="noopener noreferrer"&gt;https://docs.gitlab.com/ee/ci/yaml/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitLab CI includes and remote include behavior: &lt;a href="https://docs.gitlab.com/ci/yaml/includes/" rel="noopener noreferrer"&gt;https://docs.gitlab.com/ci/yaml/includes/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitLab Docker-in-Docker guidance: &lt;a href="https://docs.gitlab.com/ci/docker/using_docker_build/" rel="noopener noreferrer"&gt;https://docs.gitlab.com/ci/docker/using_docker_build/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Checklist
&lt;/h2&gt;

&lt;p&gt;For GitHub Actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Avoid &lt;code&gt;pull_request_target&lt;/code&gt; unless isolated.&lt;/li&gt;
&lt;li&gt;Pin third-party actions and reusable workflows to full commit SHAs in release-sensitive jobs.&lt;/li&gt;
&lt;li&gt;Set top-level &lt;code&gt;permissions: {}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Avoid injecting GitHub context directly into shell scripts.&lt;/li&gt;
&lt;li&gt;Keep OIDC publish jobs small.&lt;/li&gt;
&lt;li&gt;Avoid cache-aware actions in release workflows.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;timeout-minutes&lt;/code&gt; to privileged jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For GitLab CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin &lt;code&gt;include:project&lt;/code&gt; refs to full commit SHAs.&lt;/li&gt;
&lt;li&gt;Add integrity checks to remote includes.&lt;/li&gt;
&lt;li&gt;Pin release-sensitive images by digest.&lt;/li&gt;
&lt;li&gt;Avoid disabled-TLS Docker-in-Docker.&lt;/li&gt;
&lt;li&gt;Move secret values out of YAML.&lt;/li&gt;
&lt;li&gt;Do not pass MR/ref metadata into &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;bash -c&lt;/code&gt;, or interpreter &lt;code&gt;-c&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Avoid cache restore in publish/deploy jobs.&lt;/li&gt;
&lt;li&gt;Use static runner tags for privileged jobs.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;timeout&lt;/code&gt; to release, deploy, and OIDC jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your CI/CD YAML can deploy production, publish packages, or mint cloud credentials, it deserves the same level of review as application code.&lt;/p&gt;

&lt;p&gt;Skylos now helps with that review locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;skylos &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--danger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/duriantaco/skylos" rel="noopener noreferrer"&gt;https://github.com/duriantaco/skylos&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Slopsquatting in Python: What 205,474 Hallucinated Package Names Mean for Your Supply Chain</title>
      <dc:creator>Sour durian</dc:creator>
      <pubDate>Thu, 30 Apr 2026 13:55:53 +0000</pubDate>
      <link>https://dev.to/duriantaco/slopsquatting-in-python-what-205474-hallucinated-package-names-mean-for-your-supply-chain-12oi</link>
      <guid>https://dev.to/duriantaco/slopsquatting-in-python-what-205474-hallucinated-package-names-mean-for-your-supply-chain-12oi</guid>
      <description>&lt;p&gt;Your AI coding assistant wrote this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;huggingface_cli&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks fine. It looks like something that should exist. You run &lt;code&gt;pip install huggingface-cli&lt;/code&gt;, the install succeeds, your tests pass, and you merge.&lt;/p&gt;

&lt;p&gt;In March 2024, that exact package was a proof-of-concept attack by Bar Lanyado at Lasso Security. He'd noticed GPT-based assistants repeatedly recommending &lt;code&gt;huggingface-cli&lt;/code&gt; to developers — a package that didn't exist on PyPI. He registered an empty placeholder package under that name and waited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three months later, it had been downloaded over 30,000 times.&lt;/strong&gt; An Alibaba research repository was among the adopters — it recommended the install in its README. (&lt;a href="https://www.lasso.security/blog/ai-package-hallucinations" rel="noopener noreferrer"&gt;Lasso Security, March 28 2024&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;This is slopsquatting: the class of software supply chain attack where an attacker registers a package name that AI coding assistants repeatedly hallucinate, then waits for devs to &lt;code&gt;pip install&lt;/code&gt; it into production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who named it, and why it's its own category
&lt;/h2&gt;

&lt;p&gt;The term was coined by &lt;strong&gt;Seth Larson&lt;/strong&gt;, the Python Software Foundation's Security Developer-in-Residence. "Slop" is the common pejorative for low-quality generative-AI output; "squatting" comes from typosquatting, the long-standing attack where malicious actors register names one keystroke away from real packages (&lt;code&gt;reqeusts&lt;/code&gt;, &lt;code&gt;numpi&lt;/code&gt;, &lt;code&gt;djnago&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The distinction matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Typosquatting&lt;/th&gt;
&lt;th&gt;Slopsquatting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Attacker needs&lt;/td&gt;
&lt;td&gt;A real, popular package with typo-prone spelling&lt;/td&gt;
&lt;td&gt;An LLM-hallucinated name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who "types" the bad name&lt;/td&gt;
&lt;td&gt;Human developer&lt;/td&gt;
&lt;td&gt;AI assistant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catch point&lt;/td&gt;
&lt;td&gt;Spellcheckers, eye-catching diffs&lt;/td&gt;
&lt;td&gt;Almost nothing — the name looks plausible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeatability&lt;/td&gt;
&lt;td&gt;Relies on human error&lt;/td&gt;
&lt;td&gt;Relies on model determinism&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Typosquatting has existed for decades. Slopsquatting is new because its delivery channel — the LLM — is new, and because LLMs are consistent enough that attackers can pre-compute which hallucinated names are worth registering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data: Spracklen et al., USENIX Security 2025
&lt;/h2&gt;

&lt;p&gt;The foundational empirical study is &lt;a href="https://arxiv.org/abs/2406.10279" rel="noopener noreferrer"&gt;"We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs"&lt;/a&gt; by Joseph Spracklen, Raveen Wijewickrama, A H M Nazmus Sakib, Anindya Maiti, Bimal Viswanath, and Murtuza Jadliwala. It was accepted to USENIX Security 2025.&lt;/p&gt;

&lt;p&gt;The numbers, directly from the paper's abstract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16 LLMs&lt;/strong&gt; tested, spanning commercial and open-source models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;576,000&lt;/strong&gt; Python and JavaScript code samples generated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;205,474 unique hallucinated package names&lt;/strong&gt; observed across those samples&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least 5.2%&lt;/strong&gt; hallucination rate across commercial models (the paper's stated floor)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21.7%&lt;/strong&gt; hallucination rate across open-source models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the headline. But the more interesting question is what happens when the same prompt is run more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why recurrence is the load-bearing fact
&lt;/h2&gt;

&lt;p&gt;If hallucinated names were random — if every generation produced a fresh nonexistent package — slopsquatting wouldn't be economically viable. An attacker would have to register tens of thousands of variants and hope some unlucky dev's LLM happens to emit one on some given day.&lt;/p&gt;

&lt;p&gt;The Spracklen study dismantled that defense. When the same prompt was run ten times through the same model, the researchers observed a &lt;strong&gt;bimodal distribution&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;43%&lt;/strong&gt; of hallucinated package names appeared in &lt;strong&gt;every single run&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;39%&lt;/strong&gt; never reappeared at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;58%&lt;/strong&gt; were repeated more than once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(&lt;a href="https://socket.dev/blog/slopsquatting-how-ai-hallucinations-are-fueling-a-new-class-of-supply-chain-attacks" rel="noopener noreferrer"&gt;Socket.dev, April 8 2025&lt;/a&gt;, summarizing the Spracklen paper)&lt;/p&gt;

&lt;p&gt;Almost half the hallucinations are stable. The model invents the same fake package every time you ask. That's all an attacker needs — run a popular prompt 100 times, take the top 10 hallucinated names, register them, and let the users come to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which models hallucinate most
&lt;/h2&gt;

&lt;p&gt;The Spracklen paper breaks the per-model performance down. Per Socket's reporting of the paper's findings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Hallucination Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-4 Turbo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.59%&lt;/strong&gt; (best observed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commercial average&lt;/td&gt;
&lt;td&gt;≥ 5.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-source average&lt;/td&gt;
&lt;td&gt;21.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CodeLlama 7B / 34B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Over a third of outputs&lt;/strong&gt; (worst observed)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CodeLlama matters here because Llama-family code models have historically shipped in local-first coding assistants and self-hosted pair programmers. A team that picked a privacy-preserving open-source model over a commercial API is likely accepting a &lt;strong&gt;6× to 9× higher hallucination rate&lt;/strong&gt; than a GPT-4 Turbo user — and therefore a 6× to 9× larger slopsquatting surface.&lt;/p&gt;

&lt;p&gt;One caveat worth stating up front: &lt;strong&gt;the Spracklen lineup is 2024-vintage models.&lt;/strong&gt; GPT-4 Turbo, CodeLlama, WizardCoder, DeepSeek-Coder, Mistral, and friends — not GPT-4o, Claude 3.5/4, or Llama 3.x/Qwen-Coder 2.5. Newer frontier models may hallucinate less; no peer-reviewed replication on the 2025-generation models exists yet, so treat the numbers above as an &lt;strong&gt;order-of-magnitude baseline&lt;/strong&gt;, not a live leaderboard.&lt;/p&gt;

&lt;p&gt;An interesting footnote from Socket's writeup: &lt;strong&gt;only 0.17% of the hallucinated names matched packages that had been removed from PyPI between 2020 and 2022&lt;/strong&gt;. The vast majority of hallucinations are pure invention — names the model constructed from learned patterns, not faint memories of deleted packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Python is a particularly exposed target
&lt;/h2&gt;

&lt;p&gt;Three structural reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. PyPI's namespace is flat
&lt;/h3&gt;

&lt;p&gt;Unlike npm's &lt;code&gt;@org/package&lt;/code&gt; scoped packages, PyPI is a flat, first-come-first-served namespace under &lt;a href="https://peps.python.org/pep-0541/" rel="noopener noreferrer"&gt;PEP 541&lt;/a&gt;. Any name a model hallucinates can be claimed by anyone in under a minute. There is no &lt;code&gt;@huggingface/cli&lt;/code&gt; that only Hugging Face can publish — &lt;code&gt;huggingface-cli&lt;/code&gt; is just a string, and whoever types it into &lt;code&gt;twine upload&lt;/code&gt; first owns it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The AI crowd is disproportionately Python
&lt;/h3&gt;

&lt;p&gt;The developers most likely to be prompting an LLM for code — ML engineering, data science, LLMOps, agent frameworks — are also the ones working with the churniest, least-stable corner of the Python ecosystem. &lt;code&gt;langchain&lt;/code&gt;, &lt;code&gt;llama-index&lt;/code&gt;, &lt;code&gt;transformers&lt;/code&gt;, the &lt;code&gt;autogen&lt;/code&gt;/&lt;code&gt;crewai&lt;/code&gt; neighborhood. These libraries restructure their module layout frequently, which means the LLM's training data disagrees with today's reality, which means the LLM confidently writes imports that no longer exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The install step has no friction
&lt;/h3&gt;

&lt;p&gt;Python culture is &lt;code&gt;pip install X &amp;amp;&amp;amp; python&lt;/code&gt;. Most devs don't open PyPI's web UI to vet a package an LLM suggested before installing it. Compare with Rust (&lt;code&gt;cargo add&lt;/code&gt; surfaces crates.io metadata) or Go (&lt;code&gt;go get&lt;/code&gt; shows you the full module path with the VCS host embedded). Python's frictionless install is its slopsquatting vulnerability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a hallucinated import actually looks like
&lt;/h2&gt;

&lt;p&gt;There's more than one failure mode, and they call for different fixes. Skylos's rule &lt;strong&gt;SKY-D222&lt;/strong&gt; ("hallucinated dependency imports") fires on imports that don't resolve against your declared dependencies. In AI-generated code, that typically catches three distinct patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure hallucination.&lt;/strong&gt; The package simply doesn't exist anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptoutils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secure_hash&lt;/span&gt;        &lt;span class="c1"&gt;# no such package
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;require&lt;/span&gt;      &lt;span class="c1"&gt;# no such package
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stale module path.&lt;/strong&gt; The package exists, but the model remembers an older layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.chat_models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChatAnthropic&lt;/span&gt;
&lt;span class="c1"&gt;# Pre-0.1 location. LangChain 0.1 (January 2024) split integrations out
# into separate partner packages — the modern import is:
#     from langchain_anthropic import ChatAnthropic
# The old top-level shim is deprecated and fails outright on fresh installs
# that don't carry the legacy compatibility layer.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alias confusion.&lt;/strong&gt; The package exists on PyPI but under a different distribution name than its import name — and isn't in your requirements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sklearn&lt;/span&gt;
&lt;span class="c1"&gt;# sklearn is the import name; the distribution on PyPI is scikit-learn.
# If your requirements.txt declares neither, this import fails at runtime
# no matter how obvious the name looks. The same trap catches cv2/opencv-python
# and yaml/PyYAML — three of the most misremembered import/distribution splits
# in Python.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the first pattern is a strict "package doesn't exist on PyPI" case — the one slopsquatters directly target. But all three matter, because all three are symptoms of an LLM generating code that references a world that isn't yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching it at lint time, not install time
&lt;/h2&gt;

&lt;p&gt;Existing tooling tends to be install-time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pip-audit&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.com/pypa/pip-audit" rel="noopener noreferrer"&gt;PyPA&lt;/a&gt;) checks installed packages against known vulnerabilities. Useless against a hallucinated name, because the name isn't in any advisory database — there's no CVE for "this package doesn't exist."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lockfiles&lt;/strong&gt; (&lt;code&gt;uv.lock&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, hash-pinned &lt;code&gt;requirements.txt&lt;/code&gt;) pin &lt;em&gt;what you've already installed&lt;/em&gt;. If a dev ran &lt;code&gt;pip install cryptoutils&lt;/code&gt; to make the AI-generated import work, the lockfile now enshrines that decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted publishing / Sigstore&lt;/strong&gt; (&lt;a href="https://docs.pypi.org/trusted-publishers/" rel="noopener noreferrer"&gt;PyPI docs&lt;/a&gt;) guarantees provenance for packages you know you want. It can't tell you that &lt;code&gt;cryptoutils&lt;/code&gt; shouldn't be on your want-list to begin with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these layers runs too late. By the time lockfile hashing kicks in, the slopsquatted package is already resolved as a legitimate dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cheap detection layer is static.&lt;/strong&gt; Parse every &lt;code&gt;import X&lt;/code&gt; and &lt;code&gt;from X import Y&lt;/code&gt; in the diff. Resolve against the declared dependency graph — &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;, whatever you use. If an import has no matching distribution, fail the PR.&lt;/p&gt;

&lt;p&gt;That's what Skylos's &lt;strong&gt;SKY-D222&lt;/strong&gt; does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;skylos
skylos &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every unresolved import in the codebase becomes a finding. On an AI-generated PR, that's exactly the layer that catches a hallucinated name before anyone reaches for &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A workflow you can drop in today
&lt;/h2&gt;

&lt;p&gt;A minimal GitHub Action that blocks a PR when hallucinated imports are present:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;skylos scan&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;scan&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;actions/setup-python@v5&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;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.12'&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;pip install skylos&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;skylos defend . --fail-on high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skylos defend&lt;/code&gt; runs the AI-code security checks (hallucinated imports, removed auth, hardcoded secrets, weak crypto, disabled SSL) and &lt;code&gt;--fail-on high&lt;/code&gt; fails the job if any high-severity finding is present.&lt;/p&gt;

&lt;p&gt;Pair it with a lockfile — &lt;code&gt;uv.lock&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, or &lt;code&gt;pip-tools&lt;/code&gt;-generated &lt;code&gt;requirements.txt --generate-hashes&lt;/code&gt; — so that any &lt;code&gt;pip install&lt;/code&gt; a dev might run to "fix" the failing import also has to pass code review. The lockfile catches the second-order supply chain risk; the static scan catches the first-order hallucination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;LLMs hallucinate Python imports. &lt;a href="https://arxiv.org/abs/2406.10279" rel="noopener noreferrer"&gt;Commercial models do it in ~5% of generations; open-source models in &amp;gt;20%&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roughly 43% of those hallucinations recur on every re-run of the same prompt.&lt;/strong&gt; That determinism is what makes pre-computing attack targets profitable. (&lt;a href="https://socket.dev/blog/slopsquatting-how-ai-hallucinations-are-fueling-a-new-class-of-supply-chain-attacks" rel="noopener noreferrer"&gt;Socket.dev&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The attack is not hypothetical. Bar Lanyado &lt;a href="https://www.lasso.security/blog/ai-package-hallucinations" rel="noopener noreferrer"&gt;demonstrated 30,000+ downloads&lt;/a&gt; of a single hallucinated package name in three months, including an Alibaba research repo recommending the install in its README.&lt;/li&gt;
&lt;li&gt;PyPI's flat namespace under &lt;a href="https://peps.python.org/pep-0541/" rel="noopener noreferrer"&gt;PEP 541&lt;/a&gt; makes claiming a hallucinated name trivial, and the Python ML/AI crowd is the group most exposed by habit.&lt;/li&gt;
&lt;li&gt;Lockfiles and &lt;code&gt;pip-audit&lt;/code&gt; catch known vulnerable packages after install. &lt;strong&gt;They do not catch nonexistent names at lint time.&lt;/strong&gt; Static import resolution against your declared dependencies is the cheap layer that does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run it on a repo you care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;skylos
skylos &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Skylos flags an unresolved import in an AI-generated diff, nothing was lost — you caught the exact class of bug that makes slopsquatting possible.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Disclosure: we build Skylos. The Spracklen, Lasso, and Socket findings cited above are independent third-party research.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Spracklen, J., Wijewickrama, R., Sakib, A. H. M. N., Maiti, A., Viswanath, B., Jadliwala, M. &lt;em&gt;We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs.&lt;/em&gt; USENIX Security 2025. &lt;a href="https://arxiv.org/abs/2406.10279" rel="noopener noreferrer"&gt;arXiv:2406.10279&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lanyado, B. &lt;em&gt;AI Package Hallucinations.&lt;/em&gt; Lasso Security, March 28 2024. &lt;a href="https://www.lasso.security/blog/ai-package-hallucinations" rel="noopener noreferrer"&gt;https://www.lasso.security/blog/ai-package-hallucinations&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gooding, S. &lt;em&gt;The Rise of Slopsquatting: How AI Hallucinations Are Fueling a New Class of Supply Chain Attacks.&lt;/em&gt; Socket.dev, April 8 2025. &lt;a href="https://socket.dev/blog/slopsquatting-how-ai-hallucinations-are-fueling-a-new-class-of-supply-chain-attacks" rel="noopener noreferrer"&gt;https://socket.dev/blog/slopsquatting-how-ai-hallucinations-are-fueling-a-new-class-of-supply-chain-attacks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PSF. &lt;em&gt;PEP 541 — Package Index Name Retention.&lt;/em&gt; &lt;a href="https://peps.python.org/pep-0541/" rel="noopener noreferrer"&gt;https://peps.python.org/pep-0541/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPA. &lt;em&gt;pip-audit.&lt;/em&gt; &lt;a href="https://github.com/pypa/pip-audit" rel="noopener noreferrer"&gt;https://github.com/pypa/pip-audit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI. &lt;em&gt;Trusted Publishers.&lt;/em&gt; &lt;a href="https://docs.pypi.org/trusted-publishers/" rel="noopener noreferrer"&gt;https://docs.pypi.org/trusted-publishers/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Originally published on &lt;a href="https://skylos.dev/blog/slopsquatting-python-hallucinated-imports" rel="noopener noreferrer"&gt;skylos.dev&lt;/a&gt;. Skylos is an open-source static analysis tool for Python, TypeScript, and Go that catches hallucinated imports, dead code, and security issues — &lt;a href="https://github.com/duriantaco/skylos" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.*&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>ai</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Python Dead Code: I Scanned Flask, FastAPI, and 7 Other Popular Repos — Here's What I Found</title>
      <dc:creator>Sour durian</dc:creator>
      <pubDate>Tue, 10 Mar 2026 14:06:37 +0000</pubDate>
      <link>https://dev.to/duriantaco/python-dead-code-i-scanned-flask-fastapi-and-7-other-popular-repos-heres-what-i-found-5c1c</link>
      <guid>https://dev.to/duriantaco/python-dead-code-i-scanned-flask-fastapi-and-7-other-popular-repos-heres-what-i-found-5c1c</guid>
      <description>&lt;p&gt;Dead code is the tech debt nobody talks about. Unused functions, orphaned imports, abandoned classes — they get maintained, reviewed in PRs, and tested in CI. And they do absolutely nothing.&lt;/p&gt;

&lt;p&gt;I wanted to answer two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How much dead code exists&lt;/strong&gt; in the most popular Python projects on GitHub?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can static analysis tools reliably detect it&lt;/strong&gt; without drowning you in false positives?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I ran dead code detection on &lt;strong&gt;9 of the most popular Python repositories&lt;/strong&gt; (350k+ combined stars) and &lt;strong&gt;manually verified every single finding&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 9 Python Repos I Tested
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Why it's a good stress test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/fastapi/fastapi" rel="noopener noreferrer"&gt;fastapi/fastapi&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;82k&lt;/td&gt;
&lt;td&gt;100+ Pydantic model fields for OpenAPI specs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/pallets/flask" rel="noopener noreferrer"&gt;pallets/flask&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;69k&lt;/td&gt;
&lt;td&gt;Jinja2 template globals, Werkzeug protocol methods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/psf/requests" rel="noopener noreferrer"&gt;psf/requests&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;53k&lt;/td&gt;
&lt;td&gt;Heavy &lt;code&gt;__init__.py&lt;/code&gt; re-exports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Textualize/rich" rel="noopener noreferrer"&gt;Textualize/rich&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;51k&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__rich_console__&lt;/code&gt; protocol, metaclasses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/tqdm/tqdm" rel="noopener noreferrer"&gt;tqdm/tqdm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;30k&lt;/td&gt;
&lt;td&gt;Keras/Dask callbacks, pandas monkey-patching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/pydantic/pydantic" rel="noopener noreferrer"&gt;pydantic/pydantic&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;23k&lt;/td&gt;
&lt;td&gt;Mypy plugin hooks, &lt;code&gt;__getattr__&lt;/code&gt; dynamic config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/pallets/click" rel="noopener noreferrer"&gt;pallets/click&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;17k&lt;/td&gt;
&lt;td&gt;IO protocol methods, nonlocal closures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/encode/httpx" rel="noopener noreferrer"&gt;encode/httpx&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;14k&lt;/td&gt;
&lt;td&gt;Transport/auth protocol methods — zero dead code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/encode/starlette" rel="noopener noreferrer"&gt;encode/starlette&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10k&lt;/td&gt;
&lt;td&gt;ASGI interface params, polymorphic dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every finding was &lt;strong&gt;manually verified&lt;/strong&gt; against the source code. No automated labelling. No cherry-picking.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Much Dead Code Did I Find?
&lt;/h2&gt;

&lt;p&gt;Across all 9 repos: &lt;strong&gt;52 genuinely dead items&lt;/strong&gt; — unused functions, classes, imports, and variables.&lt;/p&gt;

&lt;p&gt;But here's the interesting part: &lt;strong&gt;the false positive problem is way worse than the dead code itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I compared two Python dead code detection tools — &lt;a href="https://github.com/jendrikseipp/vulture" rel="noopener noreferrer"&gt;Vulture&lt;/a&gt; (the most popular Python dead code finder) and &lt;a href="https://github.com/duriantaco/skylos" rel="noopener noreferrer"&gt;Skylos&lt;/a&gt; (a framework-aware tool I built to reduce false positives).&lt;/p&gt;

&lt;h3&gt;
  
  
  Python Dead Code Detection: Skylos vs Vulture
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Dead Items&lt;/th&gt;
&lt;th&gt;Skylos Found&lt;/th&gt;
&lt;th&gt;Skylos FP&lt;/th&gt;
&lt;th&gt;Vulture Found&lt;/th&gt;
&lt;th&gt;Vulture FP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;psf/requests&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pallets/click&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;encode/starlette&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textualize/rich&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;encode/httpx&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pallets/flask&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;260&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pydantic/pydantic&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastapi/fastapi&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tqdm/tqdm&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;52&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;220&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;644&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Skylos&lt;/th&gt;
&lt;th&gt;Vulture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;98.1%&lt;/strong&gt; (51/52)&lt;/td&gt;
&lt;td&gt;84.6% (44/52)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;False Positives&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;220&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;644&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dead items found&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Skylos finds 7 more dead items with &lt;strong&gt;3x fewer false positives&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Python Dead Code Detection Produces So Many False Positives
&lt;/h2&gt;

&lt;p&gt;The biggest source of noise? &lt;strong&gt;Python framework magic.&lt;/strong&gt; Django, Flask, FastAPI, and pytest all use patterns that look like dead code to static analysis but are very much alive at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flask: 260 False Positives from Vulture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Vulture flags this as unused — but Jinja2 calls it at render time
&lt;/span&gt;&lt;span class="nd"&gt;@app.template_global&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;format_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vulture reported &lt;strong&gt;260 false positives&lt;/strong&gt; on Flask. Most were Jinja2 template globals and Werkzeug protocol methods that Flask calls internally. Skylos reported 12 because it recognizes Flask-specific patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  FastAPI: Pydantic Model Fields Aren't Unused Variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;  &lt;span class="c1"&gt;# Vulture: "unused variable"
&lt;/span&gt;    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# Vulture: "unused variable"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pydantic &lt;code&gt;BaseModel&lt;/code&gt; fields define your API schema. They're serialized, validated, and documented by OpenAPI — but never "called" in the traditional sense. Vulture flagged &lt;strong&gt;102&lt;/strong&gt; of these in FastAPI. Skylos flagged 30.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Skylos Still Gets It Wrong (Honestly)
&lt;/h3&gt;

&lt;p&gt;No Python dead code tool is perfect. Some patterns still fool Skylos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Skylos FP&lt;/th&gt;
&lt;th&gt;Vulture FP&lt;/th&gt;
&lt;th&gt;Why Skylos loses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;click&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;IO protocol methods on &lt;code&gt;io.RawIOBase&lt;/code&gt; subclasses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;starlette&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Instance method calls not resolved to class definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rich&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sentinel vars checked via &lt;code&gt;f_locals.get("name")&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When code uses very dynamic Python patterns like frame inspection (&lt;code&gt;f_locals&lt;/code&gt;), both tools struggle. Vulture actually does better on &lt;code&gt;rich&lt;/code&gt; because its more conservative analysis happens to avoid those specific cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Python Repo with Zero Dead Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;httpx&lt;/strong&gt; had zero dead items. Every function, class, and import is used. It's one of the cleanest Python codebases I've seen.&lt;/p&gt;

&lt;p&gt;But Vulture still reported &lt;strong&gt;59 false positives&lt;/strong&gt; on it — mostly transport and auth protocol methods that implement interfaces without explicit callers in the same codebase. Skylos reported 6.&lt;/p&gt;

&lt;p&gt;A tool that reports 59 issues when there are 0 real problems trains developers to ignore its output entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Dead Code I Actually Found in Popular Python Projects
&lt;/h2&gt;

&lt;p&gt;Some highlights from genuinely dead code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;requests&lt;/strong&gt;: 6 dead items including unused re-exports in &lt;code&gt;__init__.py&lt;/code&gt; that survived years of refactoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rich&lt;/strong&gt;: 13 dead items — unused utility functions and classes that were replaced but never removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pydantic&lt;/strong&gt;: 11 dead items including leftover mypy plugin hooks from API changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flask&lt;/strong&gt;: 7 dead items — old extension hooks that nothing calls anymore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are security vulnerabilities. But they add up: dead code gets reviewed in PRs, confuses new contributors, and creates false dependencies that make refactoring harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Find Dead Code in Your Own Python Project
&lt;/h2&gt;

&lt;p&gt;All benchmark scripts and ground truth data are open source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/duriantaco/skylos-demo
&lt;span class="nb"&gt;cd &lt;/span&gt;skylos-demo

&lt;span class="c"&gt;# Run any individual benchmark&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;real_life_examples/flask
python3 ../benchmark_flask.py

&lt;span class="c"&gt;# Or install and try on your own project&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;skylos
skylos your-project/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skylos also does security scanning (taint analysis, hardcoded secrets, SQL injection) and has an &lt;a href="https://github.com/duriantaco/skylos#remediation-agent" rel="noopener noreferrer"&gt;AI remediation agent&lt;/a&gt; that can auto-fix issues and open PRs.&lt;/p&gt;

&lt;p&gt;Full methodology, ground truth lists, and per-repo breakdowns: &lt;a href="https://github.com/duriantaco/skylos-demo" rel="noopener noreferrer"&gt;skylos-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dead code exists everywhere&lt;/strong&gt; — even in the most popular, well-maintained Python projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False positives are the real problem&lt;/strong&gt; — a tool that reports 644 issues when only 52 are real trains you to ignore static analysis entirely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework awareness matters for Python&lt;/strong&gt; — Django views, FastAPI endpoints, Pydantic fields, pytest fixtures — if your dead code tool doesn't understand Python frameworks, most of its output is noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dead code is achievable&lt;/strong&gt; — httpx proves it. Clean Python codebases exist.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;What does your project's dead code situation look like? Try running &lt;code&gt;pip install skylos &amp;amp;&amp;amp; skylos .&lt;/code&gt; and let me know in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>opensource</category>
      <category>codequality</category>
    </item>
    <item>
      <title>Ostrich algorithm python package</title>
      <dc:creator>Sour durian</dc:creator>
      <pubDate>Fri, 24 Jan 2025 08:05:01 +0000</pubDate>
      <link>https://dev.to/duriantaco/ostrich-algorithm-python-package-3f87</link>
      <guid>https://dev.to/duriantaco/ostrich-algorithm-python-package-3f87</guid>
      <description>&lt;p&gt;Was taking a break from the serious programming stuff, so I created this python package. &lt;/p&gt;

&lt;p&gt;Here goes! &lt;/p&gt;

&lt;p&gt;The Ostrich Algorithm is a term in programming where developers deliberately ignore certain problems in their code (like an ostrich "burying its head in the sand"). While it sounds like a joke, it's actually a legitimate strategy when:&lt;br&gt;
The problem is super unlikely to occur (or at least we hope so)&lt;br&gt;
Fixing it would cost more than ignoring it&lt;br&gt;
You're dealing with legacy code that works (don't touch it ever!)&lt;br&gt;
Your deadline was yesterday&lt;br&gt;
So ... I created a package that does just that! Except that mine is more of a joke. To use it,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from ostrich import ostrich, Priority

@ostrich(Priority.HIGH, "PERF-123", lines={
    8: "This query makes the DB cry",})
def calculate_user_metrics():
    query = "SELECT * FROM users WHERE..."  
    for metric in all_metrics:             
        results.append(calculate_metric(user, metric))
    return results

`# The output will look like:
# [OSTRICH HIGH][PERF-123] watching from line 3
# Marked lines in this function:
# Line 15 -&amp;gt; This query makes the DB cry
#     query = "SELECT * FROM users WHERE..."`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It will watch from whichever line has the ostrich decorator. And it will highlight that part so that you can just ignore it (or prioritise it).  &lt;/p&gt;

&lt;p&gt;Any comments/hate/feedback/criticism welcomed. &lt;/p&gt;

&lt;p&gt;Link to the github: [(&lt;a href="https://github.com/duriantaco/ostrich)" rel="noopener noreferrer"&gt;https://github.com/duriantaco/ostrich)&lt;/a&gt;]&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
