<?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: Patience Mpofu</title>
    <description>The latest articles on DEV Community by Patience Mpofu (@pgmpofu).</description>
    <link>https://dev.to/pgmpofu</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%2F3805080%2F73f107c0-c84d-4ef3-aa44-8c4d2dc40b03.jpeg</url>
      <title>DEV Community: Patience Mpofu</title>
      <link>https://dev.to/pgmpofu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pgmpofu"/>
    <language>en</language>
    <item>
      <title>Modernising a 6-Year-Old Spring Boot Project Without Breaking Everything</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Mon, 18 May 2026 14:27:18 +0000</pubDate>
      <link>https://dev.to/pgmpofu/modernising-a-6-year-old-spring-boot-project-without-breaking-everything-2cjj</link>
      <guid>https://dev.to/pgmpofu/modernising-a-6-year-old-spring-boot-project-without-breaking-everything-2cjj</guid>
      <description>&lt;p&gt;Before I could meaningfully remediate the 188 vulnerabilities Snyk found in MFlix, I had to confront something uncomfortable.&lt;/p&gt;

&lt;p&gt;The project structure itself was the problem.&lt;/p&gt;

&lt;p&gt;Not the code — the code was fine for what it was. But the way it was organised, configured, and built reflected 2018 Spring Boot conventions that created friction for every subsequent change. Trying to apply modern security fixes to an unrenovated codebase is like trying to rewire a house without updating the fuse box. You can do it, but every step is harder than it needs to be.&lt;/p&gt;

&lt;p&gt;This article is about the modernisation work I did before touching a single CVE — what the 2019 structure looked like, what I changed, why I changed it, and what I deliberately kept.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a 2019 Spring Boot Project Looks Like
&lt;/h2&gt;

&lt;p&gt;When MFlix was built, Spring Boot 2.0.x was the current major version. Java 8 was the standard enterprise runtime. The project structure followed conventions of that era:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mflix/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── mflix/
│   │   │       ├── api/
│   │   │       │   ├── MoviesController.java
│   │   │       │   └── UsersController.java
│   │   │       ├── config/
│   │   │       │   └── MongoDBConfiguration.java
│   │   │       └── daos/
│   │   │           ├── MovieDao.java
│   │   │           └── UserDao.java
│   │   └── resources/
│   │       └── application.properties
│   └── test/
│       └── java/
│           └── mflix/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functional. Reasonable for its time. But several things stood out immediately when I looked at it with fresh eyes in 2025:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java 8 compiler target.&lt;/strong&gt; The &lt;code&gt;pom.xml&lt;/code&gt; declared &lt;code&gt;&amp;lt;source&amp;gt;1.8&amp;lt;/source&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;target&amp;gt;1.8&amp;lt;/target&amp;gt;&lt;/code&gt;. Java 8 reached end-of-life for free Oracle support in January 2019 — the same month this project was likely being committed. Six years of security patches, language improvements, and performance gains left on the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixed Spring Boot versions.&lt;/strong&gt; The &lt;code&gt;pom.xml&lt;/code&gt; declared &lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt; and &lt;code&gt;spring-boot-starter-security@2.0.4&lt;/code&gt; separately, with explicit version pinning on individual Spring Framework components (&lt;code&gt;spring-context&lt;/code&gt;, &lt;code&gt;spring-core&lt;/code&gt;, &lt;code&gt;spring-web&lt;/code&gt; all at &lt;code&gt;5.0.7&lt;/code&gt;). Modern Spring Boot projects use a parent BOM (Bill of Materials) that manages version alignment across the entire Spring ecosystem. Manually pinning individual Spring component versions is how you end up with the kind of version drift that generates 188 CVEs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No dependency management section.&lt;/strong&gt; Without a &lt;code&gt;&amp;lt;dependencyManagement&amp;gt;&lt;/code&gt; block or a parent BOM, transitive dependency versions are determined entirely by whatever the top-level dependencies pull in — with no explicit control or visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;application.properties&lt;/code&gt; with a hardcoded MongoDB URI.&lt;/strong&gt; The connection string for the MongoDB Atlas cluster was in the properties file rather than being externalised to environment variables. That's not a Snyk finding, but it's a security hygiene issue that should be addressed before anything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Modernisation Goals
&lt;/h2&gt;

&lt;p&gt;I set three goals before writing a line of changed code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goal 1: Move to a Spring Boot parent BOM.&lt;/strong&gt; This single change would bring version alignment across the entire Spring ecosystem under centralised control. Every Spring component version becomes managed by the BOM rather than individually pinned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goal 2: Upgrade the Java target to 17.&lt;/strong&gt; Java 17 is the current LTS release and the minimum target for Spring Boot 3.x. Moving from Java 8 to Java 17 closes nine years of language evolution and gives access to Spring Boot 3.x's security improvements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goal 3: Externalise secrets.&lt;/strong&gt; The MongoDB connection URI, JWT signing key, and any other credentials needed to move out of &lt;code&gt;application.properties&lt;/code&gt; and into environment variables before any other change.&lt;/p&gt;

&lt;p&gt;Goal 3 was intentionally first. Before running any security tooling or making any dependency changes, the sensitive configuration needed to be out of the code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Externalising Secrets
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;application.properties&lt;/code&gt; contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.data.mongodb.uri&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mongodb+srv://admin:password@cluster.mongodb.net/mflix&lt;/span&gt;
&lt;span class="py"&gt;jwt.secret&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mflix-jwt-secret-key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both values needed to go. The replacement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties — safe to commit
&lt;/span&gt;&lt;span class="py"&gt;spring.data.mongodb.uri&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${MONGODB_URI}&lt;/span&gt;
&lt;span class="py"&gt;jwt.secret&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env — never committed, in .gitignore&lt;/span&gt;
&lt;span class="nv"&gt;MONGODB_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mongodb+srv://admin:password@cluster.mongodb.net/mflix
&lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mflix-jwt-secret-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;.gitignore&lt;/code&gt; updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;.&lt;span class="n"&gt;env&lt;/span&gt;
*.&lt;span class="n"&gt;env&lt;/span&gt;
&lt;span class="n"&gt;application&lt;/span&gt;-&lt;span class="n"&gt;local&lt;/span&gt;.&lt;span class="n"&gt;properties&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Ten minutes. Should have been done in 2019. The secrets detector I wrote would have caught both of these had it been running as a pre-commit hook — which is a satisfying bit of cross-project validation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Introducing the Spring Boot Parent BOM
&lt;/h2&gt;

&lt;p&gt;The single most impactful structural change in the modernisation was adding the Spring Boot parent BOM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;project&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;modelVersion&amp;gt;&lt;/span&gt;4.0.0&lt;span class="nt"&gt;&amp;lt;/modelVersion&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;mongodb.university&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;mflix&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.0-SNAPSHOT&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.0.3.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-context&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;5.0.7.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- etc — every version pinned manually --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;project&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;modelVersion&amp;gt;&lt;/span&gt;4.0.0&lt;span class="nt"&gt;&amp;lt;/modelVersion&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;mongodb.university&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;mflix&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.0-SNAPSHOT&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;parent&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-parent&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;3.2.5&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;relativePath/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/parent&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;properties&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;java.version&amp;gt;&lt;/span&gt;17&lt;span class="nt"&gt;&amp;lt;/java.version&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/properties&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
            &lt;span class="c"&gt;&amp;lt;!-- No version — managed by parent BOM --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-security&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
            &lt;span class="c"&gt;&amp;lt;!-- No version — managed by parent BOM --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- Individual spring-context, spring-core, spring-web removed --&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- BOM pulls in correct aligned versions automatically --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this change does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parent BOM declares tested, compatible versions for the entire Spring ecosystem&lt;/li&gt;
&lt;li&gt;Individual Spring Framework components (&lt;code&gt;spring-context&lt;/code&gt;, &lt;code&gt;spring-core&lt;/code&gt;, &lt;code&gt;spring-web&lt;/code&gt;) no longer need to be declared separately — they're pulled in as transitive dependencies of the starters at the correct version&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;java.version&lt;/code&gt; property drives compiler configuration through the parent's plugin management&lt;/li&gt;
&lt;li&gt;All Spring component versions move in lockstep, eliminating the version drift that contributed to the CVE accumulation
The version jump from 2.0.3 to 3.2.5 is a major version upgrade. Spring Boot 3.x dropped support for Java 8, requires Jakarta EE 10 namespace (&lt;code&gt;jakarta.*&lt;/code&gt; instead of &lt;code&gt;javax.*&lt;/code&gt;), and brought a range of breaking API changes. Those breaking changes are what make this step the most work-intensive part of the modernisation.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 3: The Jakarta Namespace Migration
&lt;/h2&gt;

&lt;p&gt;Spring Boot 3.x moved from the &lt;code&gt;javax.*&lt;/code&gt; namespace (Java EE) to &lt;code&gt;jakarta.*&lt;/code&gt; (Jakarta EE). Every import in the codebase that referenced &lt;code&gt;javax.servlet&lt;/code&gt;, &lt;code&gt;javax.persistence&lt;/code&gt;, or similar needed updating.&lt;/p&gt;

&lt;p&gt;In MFlix, the affected imports were primarily in the security configuration and controller layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;javax.servlet.http.HttpServletRequest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;javax.servlet.http.HttpServletResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;javax.validation.Valid&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.servlet.http.HttpServletRequest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.servlet.http.HttpServletResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.validation.Valid&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is mechanical work rather than architectural work — find and replace across the codebase. Modern IDEs handle it automatically with a refactoring tool. The risk is missing an occurrence, which produces a compile error rather than a runtime bug, so it's catchable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Spring Security Configuration Modernisation
&lt;/h2&gt;

&lt;p&gt;The biggest code change in the modernisation was the Spring Security configuration. Spring Boot 3.x deprecated and then removed the &lt;code&gt;WebSecurityConfigurerAdapter&lt;/code&gt; pattern that was standard in Spring Boot 2.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2019 pattern (deprecated, removed in Spring Boot 3.x):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableWebSecurity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecurityConfig&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WebSecurityConfigurerAdapter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpSecurity&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;csrf&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authorizeRequests&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;antMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/movies/**"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;antMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/users/login"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;anyRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionManagement&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STATELESS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationManagerBuilder&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userDetailsService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userDetailsService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;passwordEncoder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passwordEncoder&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The modern pattern (Spring Boot 3.x):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableWebSecurity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecurityConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SecurityFilterChain&lt;/span&gt; &lt;span class="nf"&gt;securityFilterChain&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpSecurity&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;csrf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;csrf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authorizeHttpRequests&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/movies/**"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/users/login"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;anyRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionManagement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STATELESS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationManager&lt;/span&gt; &lt;span class="nf"&gt;authenticationManager&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;AuthenticationConfiguration&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthenticationManager&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The functional change is minimal — the same security rules apply. The structural change is significant: instead of extending an abstract class and overriding methods, the configuration is composed through beans with a fluent lambda-based API. The new pattern is cleaner, more testable, and aligns with Spring's component model more naturally.&lt;/p&gt;

&lt;p&gt;Two specific API changes worth noting:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;antMatchers()&lt;/code&gt; → &lt;code&gt;requestMatchers()&lt;/code&gt; — The method rename is straightforward but easy to miss because both compile without error in certain configurations; only the runtime behaviour differs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;authorizeRequests()&lt;/code&gt; → &lt;code&gt;authorizeHttpRequests()&lt;/code&gt; — This change has security implications beyond naming. &lt;code&gt;authorizeHttpRequests()&lt;/code&gt; uses the newer &lt;code&gt;AuthorizationManager&lt;/code&gt; API which short-circuits earlier in the request processing chain and is more consistent in its behaviour across different dispatcher types.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: JWT Library Migration
&lt;/h2&gt;

&lt;p&gt;The jjwt library had its own breaking change to address. &lt;code&gt;io.jsonwebtoken:jjwt@0.9.1&lt;/code&gt; — which Snyk gave a priority score of 889 and found 58 fixable issues in — underwent a major API restructuring between 0.9.x and 0.12.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (jjwt 0.9.1 API):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSubject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setIssuedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiration&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SignatureAlgorithm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compact&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Claims&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSigningKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseClaimsJws&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (jjwt 0.12.0 API):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issuedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiration&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SIG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compact&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Claims&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verifyWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseSignedClaims&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key change — beyond the fluent API restructuring — is how the signing key is handled. jjwt 0.9.1 accepted a raw String as a signing key, which is a known security weakness. A short or low-entropy string could be brute-forced if an attacker obtained a token. jjwt 0.12.0 requires a proper &lt;code&gt;SecretKey&lt;/code&gt; object, which enforces minimum key length requirements at the API level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 0.9.1 — accepts any string, no validation&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SignatureAlgorithm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"weak"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 0.12.0 — requires proper SecretKey, enforces minimum length&lt;/span&gt;
&lt;span class="nc"&gt;SecretKey&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hmacShaKeyFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Decoders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BASE64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base64EncodedSecret&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Jwts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SIG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an example of a library upgrade that isn't just a security patch — it's a security design improvement. The new API makes it harder to write insecure code, not just patching a specific vulnerability.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Deliberately Kept
&lt;/h2&gt;

&lt;p&gt;Not everything needed to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DAO layer.&lt;/strong&gt; The MongoDB operations in &lt;code&gt;MovieDao&lt;/code&gt; and &lt;code&gt;UserDao&lt;/code&gt; use the Java driver directly with proper parameterised queries. The code is correct, readable, and doesn't need to be rewritten just because the framework version changed. Unnecessary refactoring introduces risk without benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API structure.&lt;/strong&gt; The REST endpoint design in &lt;code&gt;MoviesController&lt;/code&gt; and &lt;code&gt;UsersController&lt;/code&gt; is sound. RESTful conventions, appropriate HTTP status codes, clear URL structure. These don't need to change to fix security issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test suite.&lt;/strong&gt; The JUnit 5 tests — updated to &lt;code&gt;junit-jupiter-api@5.10.x&lt;/code&gt; from &lt;code&gt;5.1.0&lt;/code&gt; — largely passed after the namespace migration. Keeping the tests as close to their original form as possible gave me confidence that the modernisation hadn't changed the application's behaviour.&lt;/p&gt;

&lt;p&gt;The guiding principle: change what needs to change for security and maintainability. Don't rewrite what doesn't need to be rewritten.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Modernisation Diff — What Actually Changed
&lt;/h2&gt;

&lt;p&gt;Summarising the changes as a before/after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Java version&lt;/td&gt;
&lt;td&gt;1.8 (Java 8)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Boot&lt;/td&gt;
&lt;td&gt;2.0.3–2.0.4 (manually pinned)&lt;/td&gt;
&lt;td&gt;3.2.5 (BOM managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Framework&lt;/td&gt;
&lt;td&gt;5.0.7 (manually pinned)&lt;/td&gt;
&lt;td&gt;6.1.x (BOM managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jjwt&lt;/td&gt;
&lt;td&gt;0.9.1&lt;/td&gt;
&lt;td&gt;0.12.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WebSecurityConfigurerAdapter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SecurityFilterChain&lt;/code&gt; bean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Namespace&lt;/td&gt;
&lt;td&gt;&lt;code&gt;javax.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jakarta.*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;Hardcoded in properties file&lt;/td&gt;
&lt;td&gt;Environment variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency versions&lt;/td&gt;
&lt;td&gt;8 manually pinned&lt;/td&gt;
&lt;td&gt;BOM managed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What the Modernisation Did to the Snyk Results
&lt;/h2&gt;

&lt;p&gt;Running Snyk after the modernisation — before doing any targeted vulnerability remediation — already moved the numbers significantly.&lt;/p&gt;

&lt;p&gt;The BOM upgrade to Spring Boot 3.2.5 automatically resolved a large portion of the Spring-related CVEs because the BOM pulled in patched versions of Spring Framework, Tomcat, and Jackson. The jjwt upgrade to 0.12.0 cleared all 58 of its fixable issues in a single version change.&lt;/p&gt;

&lt;p&gt;I'll show the full before/after numbers in article 6. For now, the preview: the modernisation alone — without any targeted CVE remediation — reduced the total finding count substantially. This illustrates an important point about legacy Java dependency management: often the most effective security intervention isn't patching individual CVEs, it's getting the project onto a supported version of its primary framework and letting the framework's dependency management do the heavy lifting.&lt;/p&gt;




&lt;p&gt;The modernised repository is at &lt;a href="https://github.com/pgmpofu/mflix" rel="noopener noreferrer"&gt;github.com/pgmpofu/mflix&lt;/a&gt; on the &lt;code&gt;modernised&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;Next up: the full unfiltered Snyk results — a deeper look at the most significant findings, what each vulnerability actually enables an attacker to do, and why the RCE in spring-beans deserved the attention it got.&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>security</category>
      <category>appsec</category>
    </item>
    <item>
      <title>I Dusted Off a 6-Year-Old Java Project and Ran Snyk Against It — Here's What I Found</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Mon, 18 May 2026 14:24:28 +0000</pubDate>
      <link>https://dev.to/pgmpofu/i-dusted-off-a-6-year-old-java-project-and-ran-snyk-against-it-heres-what-i-found-3go3</link>
      <guid>https://dev.to/pgmpofu/i-dusted-off-a-6-year-old-java-project-and-ran-snyk-against-it-heres-what-i-found-3go3</guid>
      <description>&lt;p&gt;The README said "implementing security best practices."&lt;/p&gt;

&lt;p&gt;That line has been sitting in the &lt;code&gt;pgmpofu/mflix&lt;/code&gt; repository since 2019. A MongoDB-backed movie browsing application with user registration, authentication, JWT-based sessions, and full CRUD operations. Built as part of a MongoDB University course. Described, in my own words, as implementing security best practices.&lt;/p&gt;

&lt;p&gt;I ran Snyk against it last week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;188 vulnerabilities. 10 Critical. 99 High. 59 Medium. 20 Low.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every single one fixable. None of them there when I wrote that README.&lt;/p&gt;

&lt;p&gt;This is the first article in a series about what happens when you apply modern software composition analysis to a Java project that hasn't been touched in six years — what Snyk found, what I fixed, what I chose not to fix, and what the before-and-after security posture actually looks like in measurable terms.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Project Is
&lt;/h2&gt;

&lt;p&gt;MFlix is a Spring Boot Java application backed by MongoDB. The core functionality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Movie search — basic and complex queries against a MongoDB collection&lt;/li&gt;
&lt;li&gt;User registration and authentication&lt;/li&gt;
&lt;li&gt;JWT-based session management using &lt;code&gt;io.jsonwebtoken:jjwt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Comment posting on movie entries&lt;/li&gt;
&lt;li&gt;Analytical reporting against the movie dataset&lt;/li&gt;
&lt;li&gt;Spring Security for access control
It's not a toy. It has real authentication flows, real database operations, and real dependency complexity. The &lt;code&gt;pom.xml&lt;/code&gt; has eight direct dependencies spanning Spring Boot, Spring Security, the MongoDB Java driver, and the JWT library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That dependency tree is where the story starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Dependency Snapshot — What Was In the pom.xml
&lt;/h2&gt;

&lt;p&gt;Before running anything, here's exactly what the project declared as of the last commit in 2019:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.0.3.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-security&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.0.4.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.0.4.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-context&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;5.0.7.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-core&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;5.0.7.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;5.0.7.RELEASE&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.jsonwebtoken&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;jjwt&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.9.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.mongodb&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;mongodb-driver-sync&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;3.9.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight direct dependencies. All declared at versions that were current in mid-2018 to mid-2019. All untouched since.&lt;/p&gt;

&lt;p&gt;The Java compiler target is &lt;code&gt;1.8&lt;/code&gt; — Java 8, which reached end of life for free Oracle support in January 2019. The project has been running on a deprecated runtime configuration since approximately the month it was committed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the Scan
&lt;/h2&gt;

&lt;p&gt;Setup is straightforward. With Snyk installed and authenticated:&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="nb"&gt;cd &lt;/span&gt;mflix
snyk &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--all-projects&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snyk reads the &lt;code&gt;pom.xml&lt;/code&gt;, resolves the full dependency tree including transitive dependencies, and cross-references every package version against its vulnerability database.&lt;/p&gt;

&lt;p&gt;The result appeared in seconds. The dashboard view told the story immediately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10 Critical · 99 High · 59 Medium · 20 Low&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Total: 188 fixable vulnerabilities. Zero with no supported fix.&lt;/p&gt;

&lt;p&gt;That last number — zero unfixable — is actually significant. Every single vulnerability Snyk found has a known fix available. I'll come back to what that means for the remediation strategy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Finding That Stopped Me Cold
&lt;/h2&gt;

&lt;p&gt;Before getting into the full breakdown, one finding deserves immediate attention.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;org.springframework:spring-context@5.0.7.RELEASE&lt;/code&gt; — &lt;strong&gt;Remote Code Execution. CVSS 9.8. Priority Score 919.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The vulnerability is in &lt;code&gt;spring-beans@5.0.7.RELEASE&lt;/code&gt; via CWE-94 — improper control of code generation. An attacker who can reach the application can, under certain conditions, execute arbitrary code on the server.&lt;/p&gt;

&lt;p&gt;CVSS 9.8. That's not a theoretical risk category. That's one point below the maximum possible score. That's the kind of finding that, in a production system, triggers an emergency change control process at 11pm on a Friday.&lt;/p&gt;

&lt;p&gt;MFlix was never deployed to production. The attack surface was always zero. But the finding is real — the vulnerability exists in the version of &lt;code&gt;spring-beans&lt;/code&gt; that ships with &lt;code&gt;spring-context@5.0.7&lt;/code&gt;, and it would be exploitable if the application were running and accessible.&lt;/p&gt;

&lt;p&gt;This is the finding that "security best practices" in the README was supposed to prevent. It didn't — not because of negligence, but because security best practices in 2019 didn't include the CVE that was disclosed after the code was written.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Breakdown by Dependency
&lt;/h2&gt;

&lt;p&gt;Here's what Snyk found grouped by the dependency it originated from, with priority scores and headline vulnerability types:&lt;/p&gt;

&lt;h3&gt;
  
  
  Critical Severity (Priority Score 919)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;org.springframework:spring-web@5.0.7.RELEASE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
11 direct issues, 8 transitive issues. The highest-priority dependency in the scan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remote Code Execution — CWE-94, CVSS 9.8 (via &lt;code&gt;spring-beans&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Privilege Escalation — CWE-264, CVSS 4.4&lt;/li&gt;
&lt;li&gt;Improper Input Validation — CWE-20, CVSS 8.6&lt;/li&gt;
&lt;li&gt;Reflected File Download — CWE-494, CVSS 8.0&lt;/li&gt;
&lt;li&gt;Denial of Service — CWE-400, CVSS 3.7
&lt;strong&gt;&lt;code&gt;org.springframework:spring-context@5.0.7.RELEASE&lt;/code&gt;&lt;/strong&gt;
2 direct issues, 11 transitive issues.&lt;/li&gt;
&lt;li&gt;Remote Code Execution — CWE-94, CVSS 9.8 (via &lt;code&gt;spring-beans&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Relative Path Traversal — CWE-23, CVSS 8.2&lt;/li&gt;
&lt;li&gt;Incorrect Authorization — CWE-863, CVSS 8.7
&lt;strong&gt;&lt;code&gt;org.springframework.boot:spring-boot-starter-security@2.0.4.RELEASE&lt;/code&gt;&lt;/strong&gt;
37 transitive issues.&lt;/li&gt;
&lt;li&gt;Authorization Bypass — CWE-285, CVSS 8.2&lt;/li&gt;
&lt;li&gt;Reflected File Download — CWE-494, CVSS 8.0&lt;/li&gt;
&lt;li&gt;Improper Input Validation — CWE-20, CVSS 8.6
&lt;strong&gt;&lt;code&gt;org.springframework.boot:spring-boot-starter-web@2.0.3.RELEASE&lt;/code&gt;&lt;/strong&gt;
206 transitive issues — the single dependency pulling in the most downstream vulnerabilities.&lt;/li&gt;
&lt;li&gt;Insecure Defaults via &lt;code&gt;tomcat-embed-core@8.5.31&lt;/code&gt; — CWE-453, CVSS 9.8&lt;/li&gt;
&lt;li&gt;Deserialization of Untrusted Data via &lt;code&gt;jackson-databind@2.9.6&lt;/code&gt; — CWE-502, CVSS 9.2&lt;/li&gt;
&lt;li&gt;Session Fixation via &lt;code&gt;tomcat-embed-core&lt;/code&gt; — CWE-384, CVSS 3.1&lt;/li&gt;
&lt;li&gt;Cross-Site Scripting via &lt;code&gt;tomcat-embed-core&lt;/code&gt; — CWE-79, CVSS 3.5
&lt;strong&gt;&lt;code&gt;io.jsonwebtoken:jjwt@0.9.1&lt;/code&gt;&lt;/strong&gt; — Priority Score 889
63 transitive issues, 58 fixable. All fixed in a single upgrade to version 0.12.0.&lt;/li&gt;
&lt;li&gt;Deserialization of Untrusted Data via &lt;code&gt;jackson-databind@2.9.6&lt;/code&gt; — CWE-502, CVSS 9.2&lt;/li&gt;
&lt;li&gt;Allocation of Resources Without Limits via &lt;code&gt;jackson-core&lt;/code&gt; — CWE-770, CVSS 8.x
### Critical — Certificate Validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;org.springframework.boot:spring-boot-autoconfigure@2.0.3.RELEASE&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Improper Validation of Certificate with Host Mismatch — CWE-297, CVSS 9.3
This one matters specifically because MFlix handles user authentication. A TLS certificate validation bypass in the autoconfigure layer means a connection claiming to be a trusted service could potentially be impersonated without detection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  High Severity (Priority Score 649)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;org.springframework.boot:spring-boot@2.0.4.RELEASE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
4 direct issues, 7 transitive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Insecure Temporary File — CWE-377, CVSS 7.8&lt;/li&gt;
&lt;li&gt;Incorrect Authorization — CWE-863, CVSS 8.7
&lt;strong&gt;&lt;code&gt;org.springframework:spring-core@5.0.7.RELEASE&lt;/code&gt;&lt;/strong&gt;
5 direct issues.&lt;/li&gt;
&lt;li&gt;Incorrect Authorization — CWE-863, CVSS 8.7&lt;/li&gt;
&lt;li&gt;Improper Case Sensitivity Handling — CWE-178, CVSS 2.3
### Medium Severity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;org.mongodb:mongodb-driver-sync@3.9.1&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
1 direct issue.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Man-in-the-Middle — CWE-300, CVSS 6.4
The MongoDB driver finding is particularly relevant for this application. MFlix connects to a MongoDB Atlas cluster. A MitM vulnerability in the driver layer means the connection between the application and the database could potentially be intercepted.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Transitive Dependency Problem
&lt;/h2&gt;

&lt;p&gt;The number that tells the real story of legacy Java dependency management is this one:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt; — &lt;strong&gt;206 transitive issues.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One line in the &lt;code&gt;pom.xml&lt;/code&gt;. One version declaration. 206 downstream vulnerabilities pulled in through the dependency chain, in packages the application never explicitly imported and whose version numbers never appeared in the build file.&lt;/p&gt;

&lt;p&gt;This is the fundamental challenge of Software Composition Analysis in Java projects. The &lt;code&gt;pom.xml&lt;/code&gt; has eight dependencies. The actual dependency graph has dozens of packages. Each of those packages has its own version, its own CVE history, its own patch cadence.&lt;/p&gt;

&lt;p&gt;A developer in 2018 who wrote &lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt; made a single decision. That decision pulled in a specific version of Tomcat, a specific version of Jackson, a specific version of Spring's core libraries — all of which have accumulated vulnerabilities over six years. None of those vulnerabilities were visible at the point of the original decision.&lt;/p&gt;

&lt;p&gt;This is why dependency scanning exists. The alternative — manually tracking CVE disclosures across every transitive dependency in your build graph — is not a realistic approach for any team at any scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Exploit Maturity Breakdown
&lt;/h2&gt;

&lt;p&gt;Not all 188 findings carry the same actual risk. Snyk's exploit maturity classification tells a more nuanced story:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maturity Level&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mature exploits (working exploit code exists)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof-of-concept exploits&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No known exploit&lt;/td&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight findings have working, publicly available exploit code. These are the ones that require the most urgent remediation — not because the vulnerability is necessarily more severe than others, but because the barrier to exploitation is effectively zero. Anyone with the exploit code and network access to the application could use it.&lt;/p&gt;

&lt;p&gt;The 137 findings with no known exploit are real vulnerabilities — they're in the CVE database, they have CVSS scores, Snyk flags them correctly — but the practical attack risk is lower because exploitation requires custom effort rather than running a public tool.&lt;/p&gt;

&lt;p&gt;This breakdown becomes critical for article 4, where I'll explain which findings I remediated, which I suppressed, and why the exploit maturity classification was the primary factor in that decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "100% Fixable" Actually Means
&lt;/h2&gt;

&lt;p&gt;One number that surprised me when I first saw the Snyk output: 188 fixable, 0 with no supported fix.&lt;/p&gt;

&lt;p&gt;That's unusually clean. In my experience scanning production Java codebases at work, there are almost always some findings in the "no supported fix" category — typically because a vulnerable package has no patched version available, or because the fix requires changes that would break the API the application depends on.&lt;/p&gt;

&lt;p&gt;MFlix having 100% fixable findings is partly a function of how old the dependencies are. Six years is a long time. Every major vulnerability that exists in Spring Boot 2.0.x, Spring 5.0.x, and jjwt 0.9.1 has had years to be patched in subsequent versions. The fix exists — I just have to apply it.&lt;/p&gt;

&lt;p&gt;The question is how straightforward "applying the fix" actually is. Some of these are minor version bumps. Others are major version upgrades — Snyk recommends going from &lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt; to &lt;code&gt;2.0.6&lt;/code&gt; for some fixes and all the way to &lt;code&gt;3.x&lt;/code&gt; for others. Major version upgrades in Spring Boot involve breaking API changes that require code modifications, not just version bumps.&lt;/p&gt;

&lt;p&gt;That complexity is what articles 4 and 5 are about. The 100% fixability rate is the theoretical ceiling. The practical remediation story is considerably more interesting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Irony of the README
&lt;/h2&gt;

&lt;p&gt;I want to return to that line. "Implementing security best practices."&lt;/p&gt;

&lt;p&gt;It was accurate in 2019. I used parameterised queries for MongoDB operations. I implemented JWT authentication correctly for the era. I used Spring Security for access control. I followed the MongoDB University course's security guidance.&lt;/p&gt;

&lt;p&gt;None of that is what Snyk found. What Snyk found is a different category of security problem entirely — not vulnerabilities in the code I wrote, but vulnerabilities in the dependencies I imported. The distinction matters because it reveals a gap in how most developers think about application security.&lt;/p&gt;

&lt;p&gt;When developers think about writing secure code, they think about SQL injection, authentication flows, authorisation checks, input validation. These are code-level concerns. A skilled developer can learn them, apply them consistently, and write code that is largely free of them.&lt;/p&gt;

&lt;p&gt;Software composition vulnerabilities are different. They accumulate silently. They appear in packages you didn't write and may never read. They arrive via CVE disclosures months or years after you made your dependency choices. They require an ongoing process — not a one-time skill — to manage.&lt;/p&gt;

&lt;p&gt;The "security best practices" that prevent code-level vulnerabilities are largely different from the "security best practices" that prevent composition vulnerabilities. Both matter. Until I ran this scan, I was only thinking about one of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;Over the next six articles in this series, I'll document:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 2&lt;/strong&gt; — Modernising the project structure: wrapping the legacy code in a current Spring Boot shell, what changed, and what had to change before Snyk could even give me a useful remediation path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 3&lt;/strong&gt; — The full unfiltered Snyk results: a deeper dive into the most significant findings with full CVE context, what each vulnerability actually enables an attacker to do, and which ones matter most for an application with user authentication and database access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 4&lt;/strong&gt; — Why I suppressed some findings and fixed others: the risk assessment framework, the role of exploit maturity in prioritisation, and the findings I made a documented decision to accept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 5&lt;/strong&gt; — The remediation work itself: the easy version bumps, the breaking changes that required code modification, and the one dependency upgrade that took four attempts to get right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 6&lt;/strong&gt; — Before and after metrics: what changed, how to measure security posture improvement, and what the numbers look like when you present them to an engineering team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Article 7&lt;/strong&gt; — Using AI to assist with remediation: what worked, what didn't, and the difference between AI suggestions and Snyk recommendations on the same vulnerabilities.&lt;/p&gt;

&lt;p&gt;The repository is at &lt;a href="https://github.com/pgmpofu/mflix" rel="noopener noreferrer"&gt;github.com/pgmpofu/mflix&lt;/a&gt;. The &lt;code&gt;pom.xml&lt;/code&gt; in the current state is exactly as it was in 2019. The Snyk findings are real. The remediation work is ongoing.&lt;/p&gt;

&lt;p&gt;Next article: modernising the project — what a 2019 Spring Boot structure looks like compared to what a current one should look like, and what had to change before the security work could begin in earnest.&lt;/p&gt;

</description>
      <category>java</category>
      <category>security</category>
      <category>appsec</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Ran My ML Secrets Detector Against My Own Repositories — Here's What It Found</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 16 May 2026 03:00:54 +0000</pubDate>
      <link>https://dev.to/pgmpofu/i-ran-my-ml-secrets-detector-against-my-own-repositories-heres-what-it-found-281p</link>
      <guid>https://dev.to/pgmpofu/i-ran-my-ml-secrets-detector-against-my-own-repositories-heres-what-it-found-281p</guid>
      <description>&lt;p&gt;here's a moment every security tool builder eventually faces.&lt;/p&gt;

&lt;p&gt;You've built the scanner. You've written the rules. You've validated it against synthetic test cases and contrived examples. And then you point it at your own code — the repositories you've actually written, committed, and pushed over years of real development work.&lt;/p&gt;

&lt;p&gt;That moment is humbling.&lt;/p&gt;

&lt;p&gt;I ran my ML secrets detector against every personal repository I own — 11 repositories across Python, Java, Node.js, and Kotlin projects accumulated over several years of portfolio building and side projects. I'm documenting the results honestly: what it found, what was real, what was a false positive, and what the numbers actually looked like.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Before running, I configured the scan for comprehensive coverage:&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;# Full repository scan including git history&lt;/span&gt;
python main.py scan ./repos/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include-history&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threshold&lt;/span&gt; 0.65 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; ./scan-results/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A threshold of 0.65 rather than the default 0.70 — I wanted to see more findings, including ones that would normally sit just below the reporting threshold. For an audit of your own code, more signal is better than less.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--include-history&lt;/code&gt; flag scans not just the current working tree but every commit in git history. This is the mode that makes people nervous. Whatever got committed and "fixed" later is still in the history. It's still accessible. It still needs to be addressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repositories scanned:&lt;/strong&gt; 11&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Total commits scanned:&lt;/strong&gt; 847&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Total files scanned:&lt;/strong&gt; 2,341&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Scan duration:&lt;/strong&gt; 4 minutes 23 seconds  &lt;/p&gt;


&lt;h2&gt;
  
  
  The Raw Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;th&gt;Confirmed Real&lt;/th&gt;
&lt;th&gt;False Positives&lt;/th&gt;
&lt;th&gt;False Positive Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MEDIUM&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;71%&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;57&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things to unpack here.&lt;/p&gt;

&lt;p&gt;The CRITICAL findings had a 14% false positive rate — one in seven was benign. That's roughly what I expected based on the test set results. The one false positive was a 32-character hex string in a variable named &lt;code&gt;encryption_mode&lt;/code&gt; — the word "encryption" pushed the key name score high, but the value was actually a configuration mode identifier, not a key.&lt;/p&gt;

&lt;p&gt;The HIGH findings had a 42% false positive rate. Higher than I'd like, but consistent with the nature of HIGH confidence findings — they're cases where the evidence is strong but not overwhelming. Most of the false positives in this tier were package integrity hashes in older &lt;code&gt;package-lock.json&lt;/code&gt; files that hadn't been added to the skip list yet.&lt;/p&gt;

&lt;p&gt;The MEDIUM findings had a 71% false positive rate. This is expected and by design. MEDIUM findings are prompts for human review, not automatic defects. Most were generic high-entropy strings in configuration files where the variable names were moderately suspicious but the values were benign.&lt;/p&gt;

&lt;p&gt;The overall 54% false positive rate sounds alarming until you account for the lower threshold (0.65 vs. default 0.70) and the MEDIUM tier. At the default threshold, the false positive rate drops to approximately 28% — closer to the test set results.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Real Findings: What Was Actually There
&lt;/h2&gt;

&lt;p&gt;Of the 26 confirmed real findings, here's what they were. I've anonymised the specific values but documented the pattern honestly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Finding 1–3: Test Credentials That Never Left Test Files (But Were Still Committed)
&lt;/h3&gt;

&lt;p&gt;Three findings were test database credentials in integration test configuration files:&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="c1"&gt;# tests/integration/test_database.py (2021 commit)
&lt;/span&gt;&lt;span class="n"&gt;TEST_DB_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integration_test_password_2021&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TEST_DB_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://testuser:local_test_pass@localhost/testdb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were intentionally "fake" credentials — values I created specifically for local testing. But they were committed to a public repository. The classifier flagged them at 87% and 91% confidence respectively.&lt;/p&gt;

&lt;p&gt;Are these real vulnerabilities? Technically no — a local test database password with no external access isn't a secret in the traditional sense. But they taught me something: even intentional test credentials get flagged, which means either the suppression annotation should have been there from the start, or the test configuration should have used environment variables even for local test values.&lt;/p&gt;

&lt;p&gt;The lesson isn't that the scanner was wrong. It's that "this is only for testing" is not a reason to skip secure credential handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 4: An Actual JWT Secret (History)
&lt;/h3&gt;

&lt;p&gt;This one made my stomach drop.&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="nc"&gt;CRITICAL &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;97&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;·&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;
&lt;span class="n"&gt;jwt_secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-jwt-signing-secret-change-this&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="err"&gt;↳&lt;/span&gt; &lt;span class="n"&gt;History&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;commit&lt;/span&gt; &lt;span class="n"&gt;a3f8b2c&lt;/span&gt; &lt;span class="err"&gt;·&lt;/span&gt; &lt;span class="mi"&gt;2020&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found a hardcoded JWT signing secret in a 2020 commit to a project that I had since "fixed" by moving to environment variables. The fix was in the current code. The secret was still in git history.&lt;/p&gt;

&lt;p&gt;The value itself — &lt;code&gt;"my-jwt-signing-secret-change-this"&lt;/code&gt; — is one of those values that developers write with the intention of replacing it before going anywhere near production. The comment is literally in the name. But it got committed, and committed things live in git history forever unless you rewrite it.&lt;/p&gt;

&lt;p&gt;The project was never deployed to production with this value. But it was a public repository. Anyone who cloned it at any point in 2020 has this value. The theoretical attack surface was real even if the practical exploitation probability was low.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; Rewrote the commit history using &lt;code&gt;git filter-branch&lt;/code&gt; to remove the file containing the secret, then force-pushed. I also added a &lt;code&gt;.gitignore&lt;/code&gt; entry for &lt;code&gt;config.py&lt;/code&gt; files and a pre-commit hook (obviously) to catch this pattern in future.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 5–8: API Keys in Old Test Scripts
&lt;/h3&gt;

&lt;p&gt;Four findings were API keys in utility scripts I'd written to test integrations:&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="c1"&gt;# scripts/test_sendgrid.py (2019 commit)
&lt;/span&gt;&lt;span class="n"&gt;SENDGRID_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SG.abc123...xyz789&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# key has been rotated
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were real API keys at the time of commit. I confirmed with the respective providers that all four had been rotated or the accounts had been closed — so the operational risk was zero. But they were real keys that were real secrets when committed.&lt;/p&gt;

&lt;p&gt;This is the most common pattern in real credential exposure incidents: keys that were live at the time of commit, rotated after discovery, but remain in history as evidence of the exposure. The key rotation closes the operational risk but doesn't erase the fact of the exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; Rotated anything still active (none were), documented the historical exposure, and rewrote history for the two repositories where the keys were in active-looking scripts. For older repositories where the scripts were clearly abandoned, I left the history intact and noted the exposure in the repository README.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 9–11: Internal Service URLs With Embedded Credentials
&lt;/h3&gt;

&lt;p&gt;Three findings were database and service connection strings:&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="c1"&gt;# config/database.py (2022 commit)
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://admin:password123@internal-host:5432/appdb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these were production credentials — they were development environment connection strings pointing to local or development hosts. But the pattern is exactly what you see in production credential exposures, and the scanner correctly identified them as high confidence.&lt;/p&gt;

&lt;p&gt;Two were for hosts that no longer exist. One was for a development Postgres instance that still exists but has no external network access. The operational risk was low; the pattern risk was real.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding 12: A Private Key Fragment in a README
&lt;/h3&gt;

&lt;p&gt;The most surprising finding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRITICAL (99%) · README.md:47
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A README containing an example private key that I'd generated specifically to demonstrate what a private key looks like in documentation. It was a real RSA private key — not a truncated fake — but generated purely for documentation purposes and never associated with any system.&lt;/p&gt;

&lt;p&gt;The scanner correctly flagged it. The private key has never been used for anything. But it's a valid RSA private key that anyone could theoretically use to claim they found something in my repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; Replaced the real private key in the README with a clearly truncated fake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA[EXAMPLE - NOT A REAL KEY]...
-----END RSA PRIVATE KEY-----
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're writing documentation that shows what a private key looks like, never use a real generated key. Generate a fake-looking placeholder instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Findings 13–26: Various Confirmed Vulnerabilities
&lt;/h3&gt;

&lt;p&gt;The remaining 14 confirmed findings were a mix of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hardcoded passwords in older Java projects using Spring with properties files committed directly&lt;/li&gt;
&lt;li&gt;OAuth client secrets in mobile app prototype code from 2018–2019&lt;/li&gt;
&lt;li&gt;Slack webhook URLs (which are effectively secrets — anyone with the URL can post to your channel)&lt;/li&gt;
&lt;li&gt;Internal service tokens from a project that has since been decommissioned
All were historical, all have been rotated or decommissioned. All are now either suppressed with justification or removed from history.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The False Positives: What Triggered Them
&lt;/h2&gt;

&lt;p&gt;The 31 false positives clustered into four categories:&lt;/p&gt;

&lt;h3&gt;
  
  
  Category 1: Package Lock File Hashes (12 findings)
&lt;/h3&gt;

&lt;p&gt;The most numerous false positive source. &lt;code&gt;package-lock.json&lt;/code&gt; files contain SHA-512 integrity hashes for every dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"integrity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha512-abc123def456..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are high-entropy strings in a file that often has keys named &lt;code&gt;integrity&lt;/code&gt;. The key name risk for "integrity" is 0.0 in my vocabulary, which should push these below threshold — and at the default 0.70 threshold, most don't appear. At 0.65, several edge cases squeaked through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Added &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;yarn.lock&lt;/code&gt;, and &lt;code&gt;*.lock&lt;/code&gt; to the global skip list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category 2: UUID Values With Moderately Sensitive Variable Names (8 findings)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;session_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;550e8400-e29b-41d4-a716-446655440000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;auth_correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7c9b2de1-3f4a-8b5c-2d1e-9f8a7b6c5d4e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Session token" and "auth correlation ID" score moderately high on key name risk. UUIDs have moderate entropy. The combination pushed these above 0.65.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Added &lt;code&gt;correlation_id&lt;/code&gt;, &lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;, and similar terms to the explicitly benign vocabulary with a score of 0.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category 3: Example Values in Documentation (7 findings)
&lt;/h3&gt;

&lt;p&gt;Markdown files and READMEs containing example code snippets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Set your API key:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
API_KEY = "your-api-key-here"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"your-api-key-here"&lt;/code&gt; is low entropy and obviously a placeholder. The scanner correctly passes it. But other examples used more realistic-looking values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;API_KEY = "aK9mP2xL8vR3qT7nY5wZ1bJ4cH6dF0eI"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable name is high risk, the entropy is high, and no pattern matches — 78% confidence. False positive, but an understandable one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Added &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.rst&lt;/code&gt; files to a lower-confidence mode (threshold raised to 0.90 for documentation files) rather than skipping them entirely — real secrets do appear in committed documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category 4: High-Entropy Configuration Values (4 findings)
&lt;/h3&gt;

&lt;p&gt;Configuration values that are long and random-looking but aren't secrets:&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="n"&gt;CACHE_KEY_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_v2_prod_cache_2024_r3f8b2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;CORRELATION_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Request-ID-v2-production-shard-3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are deterministic, human-readable configuration values that happen to be long and contain alphanumeric characters. Low false positive risk in most codebases but they appeared in mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; These are the hardest category to address systematically. The suppression annotation is the right tool — add &lt;code&gt;# secrets-ignore&lt;/code&gt; with a note that the value is a configuration constant.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the History Scan Revealed That the Current Scan Didn't
&lt;/h2&gt;

&lt;p&gt;Scanning history found 9 findings that don't appear in the current codebase — secrets that have been "fixed" but remain in git history. This is the most important capability of the history scanner and the most overlooked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Findings in current code: 17
Findings only in history: 9
Total unique findings: 26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 9 historical-only findings represent credentials that a developer committed, noticed (or was told about), and removed from the current code — but never removed from history. From a security perspective, these are live exposures. The credential exists in a public repository's history. Anyone who cloned the repository at any point has it.&lt;/p&gt;

&lt;p&gt;The remediation for historical findings is harder than current findings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: Rotate the credential.&lt;/strong&gt; If the credential is still active, rotate it immediately. The historical exposure is already done — rotation closes the operational risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: Rewrite git history.&lt;/strong&gt; Using &lt;code&gt;git filter-branch&lt;/code&gt; or the newer &lt;code&gt;git filter-repo&lt;/code&gt;, you can rewrite history to remove the file or commit containing the secret. This requires force-pushing, which is disruptive if other people have cloned the repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 3: Make the repository private.&lt;/strong&gt; If the repository is public and the historical exposure is significant, making it private while history is cleaned up is a reasonable interim step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 4: Document and accept.&lt;/strong&gt; For decommissioned systems and rotated credentials with no active risk, documenting the historical exposure in the repository README and marking the findings as suppressed is acceptable. Not ideal, but pragmatic for old secrets with no active attack surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Assessment
&lt;/h2&gt;

&lt;p&gt;Running the scanner against my own repositories was a genuinely useful exercise that I'd recommend to anyone building security tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CRITICAL findings were high precision — 6 out of 7 were real&lt;/li&gt;
&lt;li&gt;The history scanner found things I'd genuinely forgotten about&lt;/li&gt;
&lt;li&gt;The scan was fast enough that 11 repositories in 4 minutes felt reasonable&lt;/li&gt;
&lt;li&gt;The output was actionable — I knew exactly what to fix and where
&lt;strong&gt;What needs improvement:&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The HIGH finding false positive rate of 42% is too high for a production tool targeting real organisations. It would erode trust in a team context&lt;/li&gt;
&lt;li&gt;The package-lock.json skip list should have been in place from the start — that's a known false positive source that I didn't anticipate fully&lt;/li&gt;
&lt;li&gt;The threshold calibration needs work — 0.70 feels too conservative for CRITICAL findings and not conservative enough for HIGH findings
&lt;strong&gt;The finding that most surprised me:&lt;/strong&gt;
The JWT secret in history. Not because finding it surprised me — that's exactly what the history scanner is for. Because I had genuinely forgotten it was there. I "fixed" the issue in 2020 by moving to environment variables and closed the mental file. The history scanner reopened it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the value proposition of history scanning in one sentence: it finds the things you fixed but didn't actually fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Do If You Want to Run This Against Your Own Repos
&lt;/h2&gt;

&lt;p&gt;Start with current code only, at the default threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py scan ./your-repo &lt;span class="nt"&gt;--threshold&lt;/span&gt; 0.70 &lt;span class="nt"&gt;--format&lt;/span&gt; terminal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Triage every CRITICAL finding before looking at anything else. Then work through HIGH. Treat MEDIUM as informational unless something catches your eye.&lt;/p&gt;

&lt;p&gt;Once you've cleaned up the current state, run the history scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py scan ./your-repo &lt;span class="nt"&gt;--include-history&lt;/span&gt; &lt;span class="nt"&gt;--threshold&lt;/span&gt; 0.70
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be prepared for findings you've forgotten about. Have a decision framework ready for each one: rotate, rewrite history, or document and accept.&lt;/p&gt;

&lt;p&gt;The scan itself is the easy part. The remediation decisions are where the real work is.&lt;/p&gt;




&lt;p&gt;The full tool, including the history scanner and all configuration options, is at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run it against your own repositories and find something interesting — or find a false positive pattern I haven't handled — open an issue. The tool gets better from real-world feedback, and real-world feedback only comes from people running it on real code.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>secrets</category>
      <category>detector</category>
    </item>
    <item>
      <title>Blocking Secrets Before They Hit the Repository: Building a Pre-Commit Hook With ML</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 16 May 2026 02:54:18 +0000</pubDate>
      <link>https://dev.to/pgmpofu/blocking-secrets-before-they-hit-the-repository-building-a-pre-commit-hook-with-ml-51kj</link>
      <guid>https://dev.to/pgmpofu/blocking-secrets-before-they-hit-the-repository-building-a-pre-commit-hook-with-ml-51kj</guid>
      <description>&lt;p&gt;here are two places you can catch an exposed secret.&lt;/p&gt;

&lt;p&gt;After it's in the repository — in a CI/CD pipeline scan, a periodic audit, or a breach notification from a security researcher who found it in your public history. Or before it ever gets there — at the moment of &lt;code&gt;git commit&lt;/code&gt;, when the developer is still at their keyboard and the fix takes thirty seconds.&lt;/p&gt;

&lt;p&gt;The second option is better in every dimension. Earlier detection means lower remediation cost. A blocked commit means no credential rotation required, no incident response, no git history rewriting. The developer who gets stopped at commit understands immediately what they did and why — the context is fresh, the fix is obvious.&lt;/p&gt;

&lt;p&gt;The challenge is UX.&lt;/p&gt;

&lt;p&gt;A pre-commit hook that's too slow gets disabled. A hook that generates too many false positives gets disabled. A hook that doesn't explain itself gets disabled and complained about on Slack. A hook that developers trust — that's fast, precise, and tells them exactly what it found and why — stays enabled and actually prevents exposures.&lt;/p&gt;

&lt;p&gt;This article is about building a pre-commit hook that developers will actually leave on.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Hook Needs to Do
&lt;/h2&gt;

&lt;p&gt;Before writing a line of code, I defined what a good pre-commit secrets hook looks like from the developer's perspective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; The hook runs on every commit. If it adds more than two or three seconds, developers will notice and resent it. On a typical feature branch with a handful of changed files, the scan needs to complete in under two seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope.&lt;/strong&gt; The hook should scan staged content — only the files about to be committed — not the entire repository. Scanning everything on every commit is unnecessary and slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signal clarity.&lt;/strong&gt; When the hook blocks a commit, the developer needs to know immediately: which file, which line, what variable, why it was flagged. "Secret detected" with no context is useless. "HIGH confidence (94%): &lt;code&gt;api_key = "sk-proj-abc123..."&lt;/code&gt; in &lt;code&gt;config/settings.py&lt;/code&gt; line 47 — matches OpenAI key format" is actionable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression path.&lt;/strong&gt; Developers need a documented, low-friction way to handle false positives. The hook can't be a hard wall with no escape — that's how hooks get disabled entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-destructive.&lt;/strong&gt; The hook never modifies files. It either passes silently or blocks and explains. That's it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: Scanning Staged Content
&lt;/h2&gt;

&lt;p&gt;The first architectural decision is what to scan. There are two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Scan the working tree&lt;/strong&gt; — the files as they currently exist on disk, including unstaged changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: Scan the staged content&lt;/strong&gt; — exactly what &lt;code&gt;git diff --cached&lt;/code&gt; shows, which is what will actually be committed.&lt;/p&gt;

&lt;p&gt;Option B is correct. Scanning the working tree means flagging things the developer hasn't committed and may never intend to commit. That's noise. Scanning staged content means flagging exactly what's about to enter the repository — which is the precise intervention point.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_staged_content&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get the staged content for all modified/added files.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;staged_files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="c1"&gt;# Get list of staged files
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--cached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--name-only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--diff-filter=ACM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;filenames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filenames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="c1"&gt;# Get staged content (not working tree content)
&lt;/span&gt;        &lt;span class="n"&gt;content_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;show&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;staged_files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;staged_files&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--diff-filter=ACM&lt;/code&gt; flag limits to Added, Copied, and Modified files — not deletions. Scanning deleted file content would generate findings for secrets that are being removed, which is the wrong direction.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scan Loop: From Staged Content to Findings
&lt;/h2&gt;

&lt;p&gt;The hook extracts string literal assignments from each staged file and passes them through the ML classifier:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scan_staged_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;staged_content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;staged_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="c1"&gt;# Skip binary files, lock files, and known safe extensions
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;should_skip_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="c1"&gt;# Skip lines with suppression annotation
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;# secrets-ignore&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;# nosec&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="c1"&gt;# Extract (key_name, value) pairs from string assignments
&lt;/span&gt;            &lt;span class="n"&gt;assignments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_string_assignments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Skip very short strings
&lt;/span&gt;                    &lt;span class="k"&gt;continue&lt;/span&gt;

                &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value_preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;confidence_to_severity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;)&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;findings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few implementation details worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;should_skip_file()&lt;/code&gt;&lt;/strong&gt; excludes file types that generate systematic false positives: &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;yarn.lock&lt;/code&gt;, &lt;code&gt;*.sum&lt;/code&gt; (Go module checksums), &lt;code&gt;*.min.js&lt;/code&gt; (minified JavaScript), binary file extensions, and image files. These are maintained in a skip list rather than being hardcoded into the scan logic, so teams can extend it for their specific false positive patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value preview truncation.&lt;/strong&gt; The finding reports only the first 20 characters of the flagged value, with &lt;code&gt;...&lt;/code&gt; truncation. Showing the full value in terminal output creates a secondary exposure — if someone is screen sharing when the hook fires, the secret shouldn't appear in full in the terminal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum length of 8.&lt;/strong&gt; Strings shorter than 8 characters are almost never secrets. This eliminates a class of false positives from short configuration values and reduces scan time on files with many string literals.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Output: Making Findings Actionable
&lt;/h2&gt;

&lt;p&gt;The most important UX decision in the hook is what to show when a finding is blocked. I went through four iterations of the output format before settling on one that developers responded well to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 1 (too terse):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BLOCKED: Secret detected in config/settings.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Developers immediately asked: "What secret? Where exactly? What should I do?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 2 (better but still vague):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BLOCKED: Possible secret at config/settings.py:47
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still not enough context. Developers had to open the file and count to line 47 to understand what was flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 3 (too verbose):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SECRETS DETECTOR] 
==========================================
COMMIT BLOCKED — POTENTIAL SECRET DETECTED
==========================================
File: config/settings.py
Line: 47
Variable: api_key
Value (truncated): sk-proj-abc123...
Confidence: 94%
Severity: CRITICAL
Matched Pattern: OpenAI API key format (sk-proj-*)
Feature contributions:
  - key_name_risk: 0.90 (HIGH)
  - shannon_entropy: 5.82 (HIGH)
  - pattern_openai_key: 1.00 (MATCH)
  - repetition_ratio: 0.94 (HIGH)

To suppress this finding, add '# secrets-ignore' to line 47
To bypass this check entirely (NOT RECOMMENDED): git commit --no-verify
==========================================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is technically complete but overwhelming. Developers in flow state don't want to read a report. They want to know: what, where, what to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final version (what shipped):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 Secrets Detector — Commit Blocked

  CRITICAL (94%) · config/settings.py:47
  api_key = "sk-proj-abc123..."
  ↳ Matches OpenAI key format · High entropy · Sensitive variable name

  To suppress false positive: add  # secrets-ignore  to line 47
  To use env vars instead:    export API_KEY="your-key"
                              then  api_key = os.environ["API_KEY"]

1 finding blocked this commit. Fix the issue or suppress with justification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final format answers the three questions developers actually have in two seconds of reading: &lt;em&gt;what is it&lt;/em&gt; (OpenAI key), &lt;em&gt;where is it&lt;/em&gt; (file and line), &lt;em&gt;what do I do&lt;/em&gt; (env var example or suppression). The feature contributions are available in verbose mode (&lt;code&gt;--verbose&lt;/code&gt;) but don't appear by default.&lt;/p&gt;

&lt;p&gt;The emoji is intentional. &lt;code&gt;🔴&lt;/code&gt; provides an immediate visual signal in terminals that support it, and degrades gracefully to plain text in terminals that don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Multiple Findings
&lt;/h2&gt;

&lt;p&gt;When multiple findings exist, the output stacks them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 Secrets Detector — Commit Blocked

  CRITICAL (96%) · src/database.py:12
  DB_PASSWORD = "Tr0ub4dor&amp;amp;3"
  ↳ High-risk variable name · Matches human-chosen password pattern

  HIGH (78%) · src/config.py:34
  internal_token = "prod-service-backend-2019"
  ↳ Moderate-risk variable name · Low entropy but sensitive context

2 findings blocked this commit. Fix all issues before committing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Findings are sorted by confidence descending — the most certain findings appear first, which is where the developer's attention should go.&lt;/p&gt;

&lt;p&gt;The commit is blocked if any finding exceeds the threshold, not just the highest-confidence one. A batch of MEDIUM confidence findings is still a blocked commit. If all findings are genuine false positives, they should all be suppressed with justification — not just the top one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Suppression UX
&lt;/h2&gt;

&lt;p&gt;The suppression path needs to be low-friction but not invisible. If suppressing a false positive is too hard, developers will use &lt;code&gt;git commit --no-verify&lt;/code&gt; to bypass the hook entirely — which defeats the purpose.&lt;/p&gt;

&lt;p&gt;The designed flow:&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="c1"&gt;# Developer encounters a false positive:
# file_integrity_hash = "d8e8fca2dc0f896fd7cb4cb0031ba249"  ← flagged
&lt;/span&gt;
&lt;span class="c1"&gt;# They add the annotation with a justification:
# MD5 hash for file integrity check only — not a credential
&lt;/span&gt;&lt;span class="n"&gt;file_integrity_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# secrets-ignore
&lt;/span&gt;
&lt;span class="c1"&gt;# Commit proceeds normally on next attempt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;# secrets-ignore&lt;/code&gt; annotation is visible in code review. A reviewer can see that a suppression was added and evaluate whether the justification is reasonable. This is the governance layer — suppressions can't happen silently.&lt;/p&gt;

&lt;p&gt;The hook also respects the &lt;code&gt;SECRETS_DETECTOR_THRESHOLD&lt;/code&gt; environment variable, which allows individual developers to adjust their personal threshold without modifying shared configuration:&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;# Developer who wants to see more findings (lower threshold)&lt;/span&gt;
&lt;span class="nv"&gt;SECRETS_DETECTOR_THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.55 git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"wip"&lt;/span&gt;

&lt;span class="c"&gt;# Developer who wants fewer false positives (higher threshold)&lt;/span&gt;
&lt;span class="nv"&gt;SECRETS_DETECTOR_THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.85 git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feature: payment flow"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This flexibility matters for adoption. Some developers will want to see everything; others will want a tighter filter. Forcing everyone to the same threshold is a source of friction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installation: Making Setup Frictionless
&lt;/h2&gt;

&lt;p&gt;A hook that's hard to install never gets installed. The setup needs to be one command:&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;# Using pre-commit framework (recommended)&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pre-commit
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"repos:
- repo: https://github.com/pgmpofu/secrets-detector
  rev: v1.0.0
  hooks:
  - id: secrets-detector
    args: [--threshold, '0.7']"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .pre-commit-config.yaml
pre-commit &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or manual installation for teams not using the pre-commit framework:&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;# Copy hook to git hooks directory&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;hooks/pre-commit .git/hooks/pre-commit
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .git/hooks/pre-commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pre-commit framework approach is preferable for teams because it version-pins the hook, makes it part of the repository configuration (&lt;code&gt;.pre-commit-config.yaml&lt;/code&gt; is committed), and automatically installs on &lt;code&gt;git clone&lt;/code&gt; for new team members. The manual approach works for individual use.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens at &lt;code&gt;git commit --no-verify&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the escape hatch that can't be removed. Git's &lt;code&gt;--no-verify&lt;/code&gt; flag bypasses all hooks, and there's nothing a hook can do to prevent it.&lt;/p&gt;

&lt;p&gt;The right response to this is not technical — it's cultural and procedural.&lt;/p&gt;

&lt;p&gt;In a team setting, &lt;code&gt;git commit --no-verify&lt;/code&gt; should require a comment in the commit message explaining why the hook was bypassed. This can be enforced through CI/CD: a pipeline step that checks whether any commit in a PR used &lt;code&gt;--no-verify&lt;/code&gt; and requires a justification in the commit message if so.&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;# In GitHub Actions&lt;/span&gt;
&lt;span class="pi"&gt;-&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;Check for hook bypasses&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;git log --oneline origin/main..HEAD | while read line; do&lt;/span&gt;
      &lt;span class="s"&gt;hash=$(echo $line | cut -d' ' -f1)&lt;/span&gt;
      &lt;span class="s"&gt;msg=$(git log --format=%B -n 1 $hash)&lt;/span&gt;
      &lt;span class="s"&gt;if git log --format=%B -n 1 $hash | grep -q "no-verify bypass"; then&lt;/span&gt;
        &lt;span class="s"&gt;echo "Documented bypass found in $hash"&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is to make &lt;code&gt;--no-verify&lt;/code&gt; traceable, not to make it impossible. A developer in a genuine emergency who needs to commit right now and deal with the secret later should be able to do that — but there should be a record of the decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring Hook Effectiveness
&lt;/h2&gt;

&lt;p&gt;After the hook has been running for a few weeks, three metrics tell you whether it's working:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bypass rate.&lt;/strong&gt; What percentage of commits use &lt;code&gt;--no-verify&lt;/code&gt;? A bypass rate above 10% suggests the hook is generating too many false positives or too much friction. Investigate which developers are bypassing most frequently and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression rate.&lt;/strong&gt; What percentage of findings are suppressed rather than fixed? High suppression rates indicate either noisy rules or developers treating suppression as the default response. Review suppressions in code review and push back on suppression-without-justification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets found in CI despite the hook.&lt;/strong&gt; If your CI pipeline also runs a secrets scan and finds things the pre-commit hook didn't catch, those are false negatives worth understanding. Each one is an opportunity to improve the hook's coverage.&lt;/p&gt;

&lt;p&gt;The hook is not a complete solution — it's the first line of defence. CI scanning is the second. Periodic full history scanning is the third. Each layer catches what the previous one misses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Point: Shift Left Has a UX Requirement
&lt;/h2&gt;

&lt;p&gt;"Shift left" — catching security issues earlier in the development lifecycle — is the right strategy. Every study on the economics of security defects confirms that earlier detection means lower remediation cost.&lt;/p&gt;

&lt;p&gt;But shift left only works if the shifted controls are actually used. A pre-commit hook that developers disable after the first false positive has shifted nothing. A CI gate that gets bypassed in every release has shifted nothing.&lt;/p&gt;

&lt;p&gt;The investment in UX — the careful output format, the clear suppression path, the fast scan, the explainable findings — is not cosmetic. It's what determines whether the security control actually operates or sits dormant in the repository while credentials quietly accumulate in git history.&lt;/p&gt;

&lt;p&gt;Security controls that developers trust are security controls that get used. That's the only metric that matters.&lt;/p&gt;




&lt;p&gt;The pre-commit hook implementation is in &lt;code&gt;hooks/pre-commit&lt;/code&gt; at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Last article in the series: I ran the secrets detector against my own repositories — here's what it actually found, the false positives I encountered, and what the real-world numbers looked like.&lt;/p&gt;

</description>
      <category>security</category>
      <category>git</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Training on Synthetic Data: How to Build an ML Security Tool Without Touching Real Leaked Secrets</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 16 May 2026 02:51:27 +0000</pubDate>
      <link>https://dev.to/pgmpofu/training-on-synthetic-data-how-to-build-an-ml-security-tool-without-touching-real-leaked-secrets-33o5</link>
      <guid>https://dev.to/pgmpofu/training-on-synthetic-data-how-to-build-an-ml-security-tool-without-touching-real-leaked-secrets-33o5</guid>
      <description>&lt;p&gt;Before I wrote a single line of model training code, I made a decision that constrained everything that followed.&lt;/p&gt;

&lt;p&gt;I would not train on real leaked credentials.&lt;/p&gt;

&lt;p&gt;The alternative was straightforward. GitHub's public commit history contains millions of accidentally committed secrets — API keys, passwords, connection strings, private keys — that have been scraped, indexed, and catalogued by security researchers. Datasets of this material exist. Using them as positive training examples would produce a model trained on exactly the kind of data it needs to recognise.&lt;/p&gt;

&lt;p&gt;I chose not to do that. And the reasoning is more nuanced than "it felt wrong."&lt;/p&gt;

&lt;p&gt;This article is about why I made that choice, how I built synthetic training data that avoids the problem, what the tradeoffs are, and what the broader principle is for anyone building ML security tooling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Real Leaked Credentials Are a Problematic Training Source
&lt;/h2&gt;

&lt;p&gt;The obvious objection to using real leaked secrets is ethical: those credentials belong to real people and organisations. Even if the data is technically public — visible in a GitHub commit, indexed by search engines — using it for commercial or portfolio purposes raises questions about consent and purpose.&lt;/p&gt;

&lt;p&gt;But the ethical argument alone isn't the strongest one. The stronger arguments are practical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Legal Ambiguity
&lt;/h3&gt;

&lt;p&gt;The legal status of scraping and using publicly accessible but unintentionally published credentials is genuinely unclear across jurisdictions. In some interpretations of computer fraud and data protection law, accessing and storing leaked credentials — even for research purposes — could constitute unauthorised access to data or improper processing of personal information.&lt;/p&gt;

&lt;p&gt;The GDPR position on this is particularly murky. Credentials are often linked to personal accounts. Processing personal data, even publicly accessible personal data, requires a lawful basis. "I needed it to train my model" is not a lawful basis.&lt;/p&gt;

&lt;p&gt;I'm not a lawyer and this isn't legal advice. But I am someone building a tool I intend to put on GitHub with my name on it. "The legal status of my training data is unclear" is not a position I wanted to be in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Quality Problems
&lt;/h3&gt;

&lt;p&gt;Leaked credential datasets have severe quality problems that make them worse training data than they might appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal distribution shift.&lt;/strong&gt; Key formats change over time. GitHub PATs changed format in 2021 from a 40-character hex string to a structured format with a &lt;code&gt;ghp_&lt;/code&gt; prefix. AWS has introduced new key formats. An older leaked credentials dataset would train the model on formats that no longer exist while underrepresenting current formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Survivorship bias.&lt;/strong&gt; The credentials that get scraped and catalogued are the ones that were detected and revoked. Harder-to-detect secrets — generically named variables, low-entropy human-chosen passwords — are systematically underrepresented in public leaked credential datasets precisely because they're harder to find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Label noise.&lt;/strong&gt; Not every string in a "leaked credentials" dataset is actually a sensitive credential. Test keys, example values, documentation snippets, and deliberately fake keys appear throughout. Cleaning a scraped dataset to get reliable labels is a substantial manual effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Negative example scarcity.&lt;/strong&gt; A dataset of leaked credentials is purely positive examples. You still need high-quality negative examples — high-entropy strings that aren't secrets — to train a classifier that distinguishes secrets from benign values. These need to be generated separately anyway.&lt;/p&gt;

&lt;p&gt;Synthetic data generation, done carefully, avoids all of these problems. You control the format distribution, the label quality, and the class balance precisely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reusability and Sharing
&lt;/h3&gt;

&lt;p&gt;A tool trained on synthetic data can be shared freely. The training code can be published. The data generation methodology can be documented. Other researchers can reproduce, audit, and improve the approach.&lt;/p&gt;

&lt;p&gt;A tool trained on scraped real credentials has a provenance problem the moment someone asks "where did your training data come from?" Publishing that training data would mean republishing the leaked credentials. Not publishing it means the model can't be fully reproduced or audited.&lt;/p&gt;

&lt;p&gt;Reproducibility matters in security tooling specifically because trust matters. A secrets detector that you can't audit end-to-end is a secrets detector you're taking on faith.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Generated Synthetic Training Data
&lt;/h2&gt;

&lt;p&gt;The synthetic data generator in &lt;code&gt;trainer.py&lt;/code&gt; produces two classes of examples: secrets (label=1) and benign high-entropy strings (label=0).&lt;/p&gt;

&lt;h3&gt;
  
  
  Generating Positive Examples (Secrets)
&lt;/h3&gt;

&lt;p&gt;For known-format secrets, I generate values that match the structural properties of real secrets without being real secrets:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_aws_access_key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate synthetic AWS access key format&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_uppercase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;
    &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AKIA&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_github_pat&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate synthetic GitHub PAT (new format)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_letters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;
    &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ghp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gho&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ghu&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ghs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ghr&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_jwt&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate syntactically valid JWT structure&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567890&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1516239022&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_letters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For generic hardcoded credentials — the human-chosen passwords and internal tokens that no regex would catch — I generate values following common human password patterns:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_human_chosen_password&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate realistic human-chosen passwords&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;patterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;# Word + year + special
&lt;/span&gt;        &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2015&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="c1"&gt;#$%')&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# Capitalised word + number
&lt;/span&gt;        &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;capitalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# Two words concatenated
&lt;/span&gt;        &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# Word + special pattern
&lt;/span&gt;        &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;)()&lt;/span&gt;

&lt;span class="n"&gt;COMMON_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;winter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spring&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autumn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;master&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;develop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal&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;Each generated secret is paired with a realistic variable name drawn from the high-risk vocabulary:&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="n"&gt;SECRET_VARIABLE_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apiKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secretKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passwd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pwd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACCESS_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accessToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;database_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRIVATE_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privateKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ... 40+ more
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The (variable_name, value) pairs that go into training represent the full context the feature extractor sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generating Negative Examples (Benign High-Entropy Strings)
&lt;/h3&gt;

&lt;p&gt;The negative class is where most secrets detectors fail — they don't have enough high-quality negative examples, so the model learns "high entropy = secret" rather than "high entropy in a credential context = secret."&lt;/p&gt;

&lt;p&gt;I generate several categories of benign high-entropy strings:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_uuid&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&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;generate_sha256_hash&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;printable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;generate_md5_hash&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;printable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;generate_base64_data&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Simulate base64-encoded image or binary data fragments&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&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;generate_package_integrity_hash&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;npm/yarn integrity hash format&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;hash_val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha512-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hash_val&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_hex_color&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0123456789abcdef&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_version_string&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;minor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;major&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;minor&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each negative example is paired with a low-risk variable name:&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="n"&gt;BENIGN_VARIABLE_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checksum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;guid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;identifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;release&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;colour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hex_color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integrity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ... 30+ more
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Class Balance and Distribution
&lt;/h3&gt;

&lt;p&gt;The training set uses a 50/50 class balance — equal numbers of secrets and benign strings. This is a deliberate choice.&lt;/p&gt;

&lt;p&gt;Real codebases have far fewer secrets than benign strings — maybe 1% of high-entropy strings are actual secrets in a typical codebase. Training on a 1% positive class would produce a classifier that learns to say "not a secret" almost all the time and achieves 99% accuracy by doing so — completely useless.&lt;/p&gt;

&lt;p&gt;A 50/50 balance forces the model to actually learn to distinguish the classes. The resulting classifier has higher false positive rates on real codebases than the training accuracy suggests, which is why the confidence threshold (default 0.7) and the key name feature do so much work in production.&lt;/p&gt;

&lt;p&gt;The threshold can be adjusted to trade precision for recall:&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;# Higher threshold — fewer false positives, more false negatives&lt;/span&gt;
python main.py scan ./src &lt;span class="nt"&gt;--threshold&lt;/span&gt; 0.85

&lt;span class="c"&gt;# Lower threshold — more findings, more false positives&lt;/span&gt;
python main.py scan ./src &lt;span class="nt"&gt;--threshold&lt;/span&gt; 0.55
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Validating Synthetic Data Quality
&lt;/h2&gt;

&lt;p&gt;The risk of synthetic data is that it doesn't reflect the distribution of real data. A model trained on synthetic examples might perform well on the test set (also synthetic) and poorly on real codebases.&lt;/p&gt;

&lt;p&gt;I validated against three real-world test cases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 1: Known public secret patterns.&lt;/strong&gt; I collected public documentation examples of secret formats — the example values shown in AWS, GitHub, and OpenAI documentation. These are not real secrets; they're deliberately fake values used in documentation. The model should classify them as secrets (since they match real formats) and does so at &amp;gt;95% confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 2: Known benign high-entropy strings.&lt;/strong&gt; I collected &lt;code&gt;package-lock.json&lt;/code&gt; integrity hashes, UUID values from public test suites, and SHA-256 checksums from public software distributions. The model should classify these as benign and does so at &amp;lt;10% confidence in the vast majority of cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 3: Edge cases from my own code.&lt;/strong&gt; I scanned my own development projects — including the secrets detector itself — and manually reviewed every finding above 0.5 confidence. This is where real-world calibration happens. The findings from this scan informed several adjustments to the key name vocabulary and confidence thresholds.&lt;/p&gt;

&lt;p&gt;The synthetic approach doesn't eliminate the need for this kind of real-world validation. It just means the real-world validation is about calibration rather than about whether the model has learned anything at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Ongoing Data Problem: Concept Drift
&lt;/h2&gt;

&lt;p&gt;Secret formats change. New services launch with new key formats. Existing services rotate their key structures for security reasons. The synthetic data that was representative in 2023 may underrepresent the formats that matter in 2025.&lt;/p&gt;

&lt;p&gt;This is the secrets detection equivalent of the vulnerability scanner coverage gap problem — there will always be a lag between a new format appearing in the wild and the tool being updated to detect it.&lt;/p&gt;

&lt;p&gt;The response to this is the same as it is for signature-based detection: a clear update process. When a new cloud service launches with a distinctive key format, the update is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a generator function for the new format to &lt;code&gt;trainer.py&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add a pattern match flag to the feature extractor&lt;/li&gt;
&lt;li&gt;Retrain: &lt;code&gt;python main.py train --samples 6000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The new format is now detected
The synthetic data approach makes this update cycle fast and low-risk. Adding new training examples doesn't require finding or curating real examples of the new format — just implementing its generation logic. Retraining takes seconds. The update can ship as a minor version bump.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Synthetic Data as a Security Research Methodology
&lt;/h2&gt;

&lt;p&gt;Stepping back from this specific tool: the synthetic data approach is applicable to a much broader class of security ML problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phishing email detection&lt;/strong&gt; can be trained on algorithmically generated phishing templates rather than real phishing emails, which carry real malicious links and attachments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Malware classification&lt;/strong&gt; researchers face the same problem I faced — real malware samples are dangerous to handle and distribute. Synthetic malware features derived from known behavioral signatures can substitute for actual samples in feature-level classifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log anomaly detection&lt;/strong&gt; for security can use synthetic attack log patterns derived from published attack techniques rather than actual attack logs from production systems.&lt;/p&gt;

&lt;p&gt;The common thread: real security data is often sensitive, legally ambiguous, dangerous to handle, or has quality problems that make it worse than it appears. Carefully generated synthetic data, validated against real-world examples without incorporating them into training, is frequently the more practical path.&lt;/p&gt;

&lt;p&gt;The tradeoff is always the same: you give up the naturalness of real data distribution in exchange for control, safety, reproducibility, and shareability. For security tooling specifically — where trust and auditability matter — that tradeoff is often worth making.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were building this tool for a commercial security product rather than a portfolio project, I'd approach training data differently in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured negative mining from real codebases.&lt;/strong&gt; Rather than generating synthetic negative examples, I'd mine real open source repositories for high-entropy strings that are demonstrably not secrets — package hashes, checksums in test suites, example values in documentation. These are safe to use (no real credentials), have the right distribution (they appear in real code as developers write it), and don't require synthetic generation. The labeling work is the constraint, not the data availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small labeled set of real format examples.&lt;/strong&gt; Not real credentials — but real format examples. The example values in service provider documentation (AWS's &lt;code&gt;AKIAIOSFODNN7EXAMPLE&lt;/code&gt;, GitHub's documented PAT format examples) are designed to look like real keys without being real keys. A small set of these, clearly labeled, would improve the model's calibration on the exact formats that matter most.&lt;/p&gt;

&lt;p&gt;The synthetic approach I built is the right choice given the constraint of a solo portfolio project with no data labeling resources. A team building a production tool would have access to more options.&lt;/p&gt;




&lt;p&gt;The data generation code is in &lt;code&gt;trainer.py&lt;/code&gt; at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;. All generators are clearly documented and the entire training pipeline is reproducible from scratch with a single command.&lt;/p&gt;

&lt;p&gt;Next up: building the pre-commit hook — blocking secrets before they ever reach the repository, and the UX considerations that determine whether developers actually leave it enabled.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>security</category>
      <category>python</category>
      <category>ethics</category>
    </item>
    <item>
      <title>Why I Chose Random Forest Over Deep Learning for Secrets Detection</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 16 May 2026 02:47:49 +0000</pubDate>
      <link>https://dev.to/pgmpofu/why-i-chose-random-forest-over-deep-learning-for-secrets-detection-5gpp</link>
      <guid>https://dev.to/pgmpofu/why-i-chose-random-forest-over-deep-learning-for-secrets-detection-5gpp</guid>
      <description>&lt;p&gt;Every time I mention that my secrets detector uses a Random Forest classifier, someone asks the same question.&lt;/p&gt;

&lt;p&gt;"Why not a neural network?"&lt;/p&gt;

&lt;p&gt;It's a reasonable question. Deep learning dominates ML benchmarks. Transformers have redefined what's possible in natural language understanding. If you're building a tool that reads code — which is text — shouldn't you be using the most powerful text understanding architecture available?&lt;/p&gt;

&lt;p&gt;The answer is no. And the reasoning reveals something important about how to match ML approaches to real-world engineering constraints.&lt;/p&gt;

&lt;p&gt;This article is the full argument: why Random Forest was the right choice for this specific problem, what I give up by not going deep, and when the calculus would flip.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Five Constraints That Shaped the Decision
&lt;/h2&gt;

&lt;p&gt;Before choosing a model architecture, I defined the constraints the tool had to satisfy. These weren't aspirational — they were hard requirements that would determine whether the tool was actually useful in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraint 1: Must run locally with zero infrastructure.&lt;/strong&gt;&lt;br&gt;
The tool needs to work in a pre-commit hook, on a developer's laptop, without internet access. No API calls, no GPU, no Docker compose stack with a model server. A developer running &lt;code&gt;git commit&lt;/code&gt; should not experience meaningful latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraint 2: Must ship as a self-contained package.&lt;/strong&gt;&lt;br&gt;
The model file needs to be small enough to live in the repository alongside the code. Teams shouldn't need to download a separate model artifact or manage model versioning separately from tool versioning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraint 3: Must be retrainable by a non-ML engineer.&lt;/strong&gt;&lt;br&gt;
When a team encounters false positives specific to their codebase, they should be able to add examples and retrain without ML expertise, a GPU, or more than a few minutes of compute time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraint 4: Must explain its decisions.&lt;/strong&gt;&lt;br&gt;
When the tool flags a finding, an engineer should be able to understand why. "The model said so" is not an acceptable answer in a security context where false positives erode trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraint 5: Must generalise from a small training set.&lt;/strong&gt;&lt;br&gt;
I'm training on synthetically generated data, not millions of real examples. The architecture needs to perform well with thousands of samples, not billions.&lt;/p&gt;

&lt;p&gt;Every significant architectural decision in this tool flows from these five constraints. Let me show you how.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why Deep Learning Fails Each Constraint
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Constraint 1: Local execution without infrastructure
&lt;/h3&gt;

&lt;p&gt;A production-quality transformer model for code understanding — something like CodeBERT or GraphCodeBERT — runs to hundreds of megabytes to several gigabytes. Running inference on a CPU is possible but slow: several seconds per scan on a typical laptop. In a pre-commit hook, where the developer is waiting at the terminal, that's unacceptable friction.&lt;/p&gt;

&lt;p&gt;The Random Forest model runs inference in milliseconds on CPU. Scanning a 10,000-line codebase takes under two seconds on a five-year-old laptop. There's no perceptible delay between &lt;code&gt;git commit&lt;/code&gt; and the hook completing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Constraint 2: Self-contained package
&lt;/h3&gt;

&lt;p&gt;The trained Random Forest model serialises to approximately 1MB as a pickle file. It lives in the &lt;code&gt;model/&lt;/code&gt; directory, ships with the tool, and requires no separate download or version management.&lt;/p&gt;

&lt;p&gt;A fine-tuned transformer model would be 400MB–2GB depending on architecture. That's not viable as a repository artifact. It requires separate model hosting, download scripts, and version coordination — none of which a team setting up a pre-commit hook wants to manage.&lt;/p&gt;
&lt;h3&gt;
  
  
  Constraint 3: Retrainable by non-ML engineers
&lt;/h3&gt;

&lt;p&gt;Retraining the Random Forest on 6,000 samples takes approximately eight seconds on a standard laptop CPU. Scaling to 50,000 samples takes about ninety seconds. The entire workflow is:&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;# Edit trainer.py to add your examples&lt;/span&gt;
&lt;span class="c"&gt;# Then:&lt;/span&gt;
python main.py train &lt;span class="nt"&gt;--samples&lt;/span&gt; 10000
&lt;span class="c"&gt;# Done. New model.pkl in model/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retraining a transformer requires GPU infrastructure, hours of compute time, careful learning rate scheduling to avoid catastrophic forgetting, and validation that fine-tuning didn't degrade performance on the base cases. A team without an ML engineer cannot do this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Constraint 4: Explainable decisions
&lt;/h3&gt;

&lt;p&gt;This is where the gap between Random Forest and deep learning is most significant for a security tool.&lt;/p&gt;

&lt;p&gt;Random Forest gives you feature importances globally and, with one additional step, per-prediction explanations. When the tool flags a finding, it can tell you exactly which features drove the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finding: api_key = "sk-proj-abc123XYZ789..."
Confidence: 96%

Contributing features:
  key_name_risk:        0.90  (HIGH — 'api_key' matches sensitive vocabulary)
  shannon_entropy:      5.82  (HIGH — consistent with cryptographic secret)
  pattern_openai_key:   1.00  (MATCH — matches OpenAI key format sk-proj-*)
  repetition_ratio:     0.94  (HIGH — low character repetition, high randomness)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An engineer reading this knows immediately why the finding was generated. They can evaluate whether the reasoning is sound. They can make an informed decision about whether to fix or suppress.&lt;/p&gt;

&lt;p&gt;A neural network produces a probability: &lt;code&gt;0.96&lt;/code&gt;. No more. You can apply techniques like SHAP or LIME to approximate explanations, but these add complexity, latency, and approximation error. For a pre-commit hook that needs to explain itself to a developer in real time, "here are the features that drove this" is vastly better than "the attention mechanism focused on these tokens (approximately)."&lt;/p&gt;

&lt;h3&gt;
  
  
  Constraint 5: Generalisation from small data
&lt;/h3&gt;

&lt;p&gt;Transformer models are data-hungry. They're pre-trained on billions of tokens and fine-tuned on millions of examples. Their power comes from the scale of pre-training, which means fine-tuning on thousands of synthetic examples carries real risk of the model not generalising well to patterns it hasn't seen.&lt;/p&gt;

&lt;p&gt;Random Forest with well-engineered features generalises effectively from thousands of examples. The feature engineering does the heavy lifting — entropy, character ratios, key name scoring, pattern flags. The model only needs to learn the relationships between these pre-computed features, which is a much simpler learning problem than learning representations from raw text.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Give Up
&lt;/h2&gt;

&lt;p&gt;Intellectual honesty requires being clear about the tradeoffs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peak accuracy ceiling.&lt;/strong&gt; A well-fine-tuned code understanding model operating on token sequences would almost certainly achieve higher peak accuracy than my feature-engineered Random Forest. It would learn representations I haven't thought to engineer explicitly. It would capture multi-token context — the fact that &lt;code&gt;password&lt;/code&gt; appears three lines before &lt;code&gt;= "..."&lt;/code&gt; rather than directly adjacent, for instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Novel format generalisation.&lt;/strong&gt; When a new cloud provider launches with a distinctive key format, my tool catches it only if I add a pattern match flag. A neural network trained on diverse secret formats might generalise to novel formats by recognising that they "look like secrets" in ways the feature vector doesn't capture. My tool requires an explicit pattern update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code context understanding.&lt;/strong&gt; The feature vector sees one value at a time. A transformer scanning the whole file could understand that a value is being loaded from an environment variable rather than being hardcoded, that it's inside a test mock, or that it's in a comment rather than executable code. My tool handles some of these through pre-processing (only scanning string literals in executable code), but the context window is fundamentally narrower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-line data flow.&lt;/strong&gt; If a secret is assembled across multiple lines — partial string concatenation, format strings, bytes operations — the feature vector sees fragments rather than the complete secret. A model with broader context could potentially catch these.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Accuracy Numbers
&lt;/h2&gt;

&lt;p&gt;On my test set of 1,200 labeled samples (a held-out 20% of the 6,000 training samples), the Random Forest achieves:&lt;/p&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;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;94.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precision&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall&lt;/td&gt;
&lt;td&gt;94.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1 Score&lt;/td&gt;
&lt;td&gt;94.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False Positive Rate&lt;/td&gt;
&lt;td&gt;5.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False Negative Rate&lt;/td&gt;
&lt;td&gt;5.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For context: TruffleHog v3 (regex + entropy) reports false positive rates in the 10–15% range on typical codebases according to published evaluations. The ML approach achieves meaningfully better precision without sacrificing recall.&lt;/p&gt;

&lt;p&gt;I don't have a head-to-head comparison against a fine-tuned transformer on this specific task — that would require the transformer, the training infrastructure, and a larger labeled dataset than I have. What I can say is that the Random Forest achieves accuracy that's competitive with existing tools, meets all five operational constraints, and does so at a fraction of the complexity.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision Framework: When Would I Choose Deep Learning?
&lt;/h2&gt;

&lt;p&gt;Given all of the above, there are scenarios where I would choose a different architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If I were building a cloud-hosted scanning service&lt;/strong&gt;, the infrastructure constraint disappears. GPU inference is available. Model size doesn't matter. Latency can be managed with caching and batching. In that scenario, a transformer-based approach becomes viable and the accuracy ceiling argument gets stronger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If I had a large labeled dataset of real secrets&lt;/strong&gt;, the data constraint relaxes. Fine-tuning on tens of thousands of real examples would likely push accuracy significantly higher than what synthetic data training achieves. The question then becomes whether the accuracy gain justifies the operational complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the primary use case were batch scanning rather than pre-commit hooks&lt;/strong&gt;, the latency constraint loosens. Scanning a repository's entire history overnight can tolerate seconds or minutes per file. The pre-commit use case is what drives the millisecond inference requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If cross-file context mattered&lt;/strong&gt;, a graph neural network operating on the code's data flow graph might be more appropriate than either approach. Understanding that &lt;code&gt;secret = get_secret_from_vault()&lt;/code&gt; is safe and &lt;code&gt;secret = "hardcoded"&lt;/code&gt; is dangerous requires understanding function call semantics — something Random Forest on string features cannot do.&lt;/p&gt;

&lt;p&gt;The right architecture is always determined by the constraints of the deployment context, not by what achieves the best benchmark score.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Principle: Fit for Purpose Over State of the Art
&lt;/h2&gt;

&lt;p&gt;The machine learning community has a bias toward the most powerful available architecture. More parameters, more data, more compute — these are treated as virtues in research contexts where they often are virtues.&lt;/p&gt;

&lt;p&gt;Production engineering has different values. A tool that actually gets used — because it's fast, explainable, maintainable, and deployable without infrastructure — delivers more security value than a theoretically superior tool that sits unused because it's too slow, too opaque, or too complex to operate.&lt;/p&gt;

&lt;p&gt;This is an instance of a general principle I keep encountering in AppSec: the best security control is the one that gets implemented and maintained, not the one that provides the strongest theoretical protection.&lt;/p&gt;

&lt;p&gt;A Random Forest secrets detector running in every developer's pre-commit hook, catching 94% of secrets before they reach the repository, is more valuable than a transformer-based detector achieving 98% accuracy that nobody bothered to deploy because the setup was too complicated.&lt;/p&gt;

&lt;p&gt;The 4% accuracy difference is real. The deployment difference is everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Feature Importances Reveal About the Problem
&lt;/h2&gt;

&lt;p&gt;One thing Random Forest gives you that deep learning doesn't: a clear picture of what the problem actually is.&lt;/p&gt;

&lt;p&gt;Here are the top 10 feature importances from the trained model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Importance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;key_name_risk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;shannon_entropy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pattern_aws_access_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repetition_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hex_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pattern_github_pat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;base64_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;log_length&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pattern_private_key_header&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uppercase_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.03&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table is a map of the secrets detection problem. It tells you that variable naming context is more predictive than any statistical property of the string itself. It tells you that entropy matters but not as much as everyone assumes. It tells you that AWS and GitHub keys are important enough that their specific pattern flags appear in the top ten even though there are 16 pattern flags spread across the remaining importance budget.&lt;/p&gt;

&lt;p&gt;A neural network would learn similar underlying structure — it would attend more to variable names than to arbitrary string characters — but it wouldn't show you that structure explicitly. The interpretability of Random Forest turns model training into a research exercise as well as an engineering one.&lt;/p&gt;

&lt;p&gt;That visibility into what the problem actually is informed every design decision in this tool. It's one of the most valuable things the architecture choice gave me.&lt;/p&gt;




&lt;p&gt;The trainer code, feature importances, and model evaluation scripts are all in the repository at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: the ethical and practical challenge of training a security ML model without using real leaked credentials — why synthetic data, how I generated it, and what the tradeoffs are.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>security</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why the Variable Name Is the Most Important Feature in Secrets Detection</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Thu, 14 May 2026 02:26:43 +0000</pubDate>
      <link>https://dev.to/pgmpofu/why-the-variable-name-is-the-most-important-feature-in-secrets-detection-pb9</link>
      <guid>https://dev.to/pgmpofu/why-the-variable-name-is-the-most-important-feature-in-secrets-detection-pb9</guid>
      <description>&lt;p&gt;ere's a question that sounds trivial until you think about it carefully.&lt;/p&gt;

&lt;p&gt;Are these two lines of code equally dangerous?&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="n"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string value is identical. The entropy is identical. Every character-level feature is identical. A regex scanner treats them the same. A pure entropy scanner treats them the same. A human security engineer does not treat them the same — not even slightly.&lt;/p&gt;

&lt;p&gt;The first is almost certainly a file integrity hash. The second is almost certainly an exposed credential. The only difference is the four characters before the equals sign.&lt;/p&gt;

&lt;p&gt;When I trained my secrets detector and examined the feature importances, the variable name risk score came out at 0.28 — higher than Shannon entropy, higher than all character distribution features, higher than string length. The single most predictive signal for whether a string is a secret is not the string itself. It's what the developer named the variable holding it.&lt;/p&gt;

&lt;p&gt;This article is about what that finding reveals — about how secrets detection actually works, about how developers accidentally expose credentials, and about what it means for how we should think about this entire problem class.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Feature Importance of 0.28 Is Remarkable
&lt;/h2&gt;

&lt;p&gt;In a Random Forest model, feature importance is measured by how much each feature reduces impurity across all decision trees. An importance of 0.28 out of 1.0, across 26 features, means the variable name alone accounts for more than a quarter of the model's predictive power.&lt;/p&gt;

&lt;p&gt;To put that in context: if you removed every other feature and kept only the variable name, you'd still have a classifier that makes correct decisions on the majority of cases. If you kept every other feature and removed the variable name, you'd lose more predictive power than any other single change.&lt;/p&gt;

&lt;p&gt;That's not what I expected when I designed the feature vector. I expected entropy to dominate — it's the signal that most secrets detection literature focuses on. The finding that variable names outperform entropy forced me to rethink some assumptions about the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Variable Names Actually Encode
&lt;/h2&gt;

&lt;p&gt;Variable names in production code are not arbitrary. They're communication.&lt;/p&gt;

&lt;p&gt;When a developer writes &lt;code&gt;api_key = "..."&lt;/code&gt;, they're not just labelling a memory location. They're documenting their intent. They're telling the next engineer — and, it turns out, a machine learning classifier — that this value is an API key, that it's sensitive, that it should be treated as a secret.&lt;/p&gt;

&lt;p&gt;Developers are remarkably consistent about this. Across codebases, languages, and organisations, the same small vocabulary appears around credential storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;password, passwd, pwd
secret, secret_key, client_secret
api_key, apikey, api_token
token, access_token, auth_token, bearer_token
private_key, privkey, pem
credential, credentials, creds
database_url, db_url, connection_string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a complementary vocabulary appears around non-sensitive high-entropy strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checksum, hash, digest, fingerprint
uuid, guid, id, identifier
version, release, build
color, colour, hex
integrity, signature (in package manifest contexts)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signal isn't perfect — &lt;code&gt;id&lt;/code&gt; sometimes refers to a sensitive identifier, &lt;code&gt;token&lt;/code&gt; is sometimes used for pagination tokens or CSRF tokens that aren't secrets in the traditional sense. But the correlation between variable name semantics and actual sensitivity is strong enough to be the most predictive single feature in a 26-dimensional model.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Ways Developers Name Credential Variables
&lt;/h2&gt;

&lt;p&gt;Understanding how variable names signal secrets requires understanding the patterns developers actually use. In practice, there are three distinct naming patterns, each with different detection implications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Direct and Obvious
&lt;/h3&gt;

&lt;p&gt;The developer uses a name that directly and unambiguously identifies the value as sensitive:&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="n"&gt;STRIPE_SECRET_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_live_abc123...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tr0ub4dor&amp;amp;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;GITHUB_ACCESS_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ghp_abc123...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-super-secret-signing-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the easy cases. The variable name scores maximum risk, the classifier is highly confident, and the finding is genuine in nearly every instance. There's no ambiguity to resolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: Abbreviated and Conventional
&lt;/h3&gt;

&lt;p&gt;The developer uses a shortened or conventional form that's recognisable within the development community but might be less obvious to an outsider:&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="n"&gt;DB_PASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Winter2019!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# "PASS" → password
&lt;/span&gt;&lt;span class="n"&gt;AWS_SK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wJalrXUtn...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# "SK" → secret key
&lt;/span&gt;&lt;span class="n"&gt;OAUTH_CS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;abc123def456...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# "CS" → client secret
&lt;/span&gt;&lt;span class="n"&gt;SVC_PWD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service_password_1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# "PWD" → password
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The risk scoring function handles these through substring matching and a vocabulary of abbreviations. &lt;code&gt;PASS&lt;/code&gt;, &lt;code&gt;PWD&lt;/code&gt;, &lt;code&gt;SK&lt;/code&gt; (in certain contexts), &lt;code&gt;CS&lt;/code&gt;, &lt;code&gt;TKN&lt;/code&gt; all score high. This is where coverage gaps can appear — an unusual abbreviation in a domain-specific codebase might not be in the vocabulary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Contextually Sensitive
&lt;/h3&gt;

&lt;p&gt;The developer uses a name that doesn't obviously indicate sensitivity on its own but becomes sensitive in context:&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="c1"&gt;# In a payment processing module
&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_live_abc123...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# "value" alone scores 0.1
&lt;/span&gt;
&lt;span class="c1"&gt;# In a configuration dictionary
&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AKIAIOSFODNN7EXAMPLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# "key" alone scores 0.7
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# In a function parameter
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="c1"&gt;# "token" scores 0.9
&lt;/span&gt;    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&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;These cases are where the feature vector struggles most. The variable name score for &lt;code&gt;value&lt;/code&gt; is 0.1 — weak evidence of sensitivity. In isolation, the classifier would likely pass this. But if the string value itself has high entropy and matches a known pattern (like the Stripe key format), the pattern flags compensate.&lt;/p&gt;

&lt;p&gt;This interaction — where a weak key name score is overridden by strong pattern match flags — is exactly how the feature vector is supposed to work. No single feature dominates all cases. The combination handles cases that individual features miss.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Accidental Exposure Psychology
&lt;/h2&gt;

&lt;p&gt;The variable name finding reveals something important about how secrets end up in code in the first place.&lt;/p&gt;

&lt;p&gt;Developers don't accidentally commit secrets because they don't know secrets are sensitive. They commit secrets because the friction of not committing them is high at the moment of writing.&lt;/p&gt;

&lt;p&gt;The archetypal scenario:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developer is building a feature that needs an API key&lt;/li&gt;
&lt;li&gt;Developer adds the key directly to the code to get the feature working&lt;/li&gt;
&lt;li&gt;Developer intends to "move it to environment variables later"&lt;/li&gt;
&lt;li&gt;Developer commits the working code&lt;/li&gt;
&lt;li&gt;"Later" never comes, or the commit is already in history
The variable name in this scenario is almost always informative — the developer names it &lt;code&gt;API_KEY&lt;/code&gt; or &lt;code&gt;STRIPE_KEY&lt;/code&gt; or &lt;code&gt;DB_PASSWORD&lt;/code&gt; because they know exactly what it is. They're not hiding it from themselves. They're just deferring the cleanup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why the variable name is such a strong signal. The developer's intent is encoded in the name. The developer knew this was a secret when they wrote it. The name reflects that knowledge.&lt;/p&gt;

&lt;p&gt;The cases where variable names are &lt;em&gt;not&lt;/em&gt; informative are the cases where secrets end up in code through a different mechanism — configuration files that get accidentally committed, environment files that get accidentally tracked, secrets embedded in test data that wasn't meant to be realistic. These are harder to catch and require the entropy and pattern features to carry more weight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Variable Name Scoring Breaks Down
&lt;/h2&gt;

&lt;p&gt;Being precise about the limits of this approach matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Obfuscated Names
&lt;/h3&gt;

&lt;p&gt;An attacker who knows about this detection vector could theoretically name credential variables to avoid detection:&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="c1"&gt;# Designed to evade variable name scoring
&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AKIAIOSFODNN7EXAMPLE7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;data_1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_live_abc123def456ghi789&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-database-password-2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two would still be caught by pattern match flags — the AWS key format and Stripe key format are distinctive enough that entropy and pattern features alone classify them correctly. The third — a human-chosen password stored in a variable named &lt;code&gt;temp&lt;/code&gt; — would be at risk of being missed if the entropy is low.&lt;/p&gt;

&lt;p&gt;This is a real gap. In practice, deliberately obfuscated variable names are uncommon in the codebases secrets appear in — developers who are trying to hide secrets from their own team are a different threat model than developers who accidentally expose secrets. But the gap exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generic Framework Patterns
&lt;/h3&gt;

&lt;p&gt;Frameworks and ORMs often use generic variable names that pattern-match to high risk scores without holding sensitive values:&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="c1"&gt;# Django ORM — "password" is a field name, not a credential
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# hashed, not plaintext
&lt;/span&gt;
&lt;span class="c1"&gt;# Spring Security — "token" is a parameter name
&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="n"&gt;TokenRequest&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In these cases, the value being assigned is a class reference, a method call, or a parameter object — not a string literal containing a secret. The feature extraction pipeline only runs on string literals, so these cases are largely handled correctly at the scanning stage before feature extraction begins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Internationalised Codebases
&lt;/h3&gt;

&lt;p&gt;The variable name risk vocabulary is English-only. A codebase with variable names in German (&lt;code&gt;Passwort&lt;/code&gt;), French (&lt;code&gt;motDePasse&lt;/code&gt;), or Portuguese (&lt;code&gt;senha&lt;/code&gt;) will have those variables score the default 0.3 rather than 1.0. This is a genuine coverage gap for multinational organisations. Extending the vocabulary to cover common credential-related terms in other languages would be a meaningful improvement.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Implication for Secrets Management Culture
&lt;/h2&gt;

&lt;p&gt;The variable name finding has an implication beyond detection accuracy. It tells us something about where to focus prevention efforts.&lt;/p&gt;

&lt;p&gt;If developers consistently name their credential variables correctly — if &lt;code&gt;DB_PASSWORD&lt;/code&gt; always contains a database password and &lt;code&gt;checksum&lt;/code&gt; never does — then the signal for detection is strong. The corollary is that the naming is correct precisely because the developer knows the value is sensitive.&lt;/p&gt;

&lt;p&gt;This means secrets in code are not primarily a knowledge problem. Developers who commit secrets usually know they're committing secrets. The problem is friction — the path of least resistance at the moment of writing is to hardcode the value and deal with it later.&lt;/p&gt;

&lt;p&gt;The most effective prevention isn't education about what a secret is. It's reducing the friction of not hardcoding secrets in the first place. Pre-commit hooks that block commits before they reach the repository — rather than scanners that find secrets after the fact — address the friction problem directly.&lt;/p&gt;

&lt;p&gt;Which is exactly what the pre-commit hook in this tool is designed to do. Catch it at the moment of commit, when the developer already knows the variable is named &lt;code&gt;API_KEY&lt;/code&gt; and the value looks like a real key. That's the lowest-friction intervention point — a message at the moment of action rather than a report discovered in a CI pipeline hours later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Improving the Key Name Feature
&lt;/h2&gt;

&lt;p&gt;The current implementation is a static keyword vocabulary with manually assigned scores. It works well but has obvious limitations — it doesn't learn, it doesn't generalise to novel terms, and it requires manual updates when new credential-adjacent terminology emerges.&lt;/p&gt;

&lt;p&gt;A more sophisticated approach would train a small embedding model on variable names from open source code, clustering semantically similar names and learning the association between name semantics and credential presence. Something like word2vec trained on a corpus of variable names from public repositories would generalise to &lt;code&gt;svc_acct_passwd&lt;/code&gt; or &lt;code&gt;oauth2_bearer_tkn&lt;/code&gt; without requiring explicit vocabulary entries.&lt;/p&gt;

&lt;p&gt;That's a meaningful improvement but a substantial increase in complexity. The static vocabulary approach handles the vast majority of real-world cases well enough that the engineering investment in embeddings hasn't been justified yet.&lt;/p&gt;

&lt;p&gt;The right trigger for that investment would be systematic measurement of false negatives — cases where the classifier misses real secrets. If those misses cluster around variable naming patterns that aren't in the current vocabulary, the embeddings approach becomes worth building.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Tells Us About Security Signal in General
&lt;/h2&gt;

&lt;p&gt;The variable name finding is an instance of a broader principle that I keep encountering in application security work: the most useful signals are often not in the content itself, but in the metadata around the content.&lt;/p&gt;

&lt;p&gt;The string &lt;code&gt;"d8e8fca2dc0f896fd7cb4cb0031ba249"&lt;/code&gt; carries no information about whether it's sensitive. The variable name &lt;code&gt;password&lt;/code&gt; carries almost complete information. The content is identical; the context is everything.&lt;/p&gt;

&lt;p&gt;This same principle appears elsewhere in AppSec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP request bodies are harder to classify as malicious than request paths, because paths carry semantic intent&lt;/li&gt;
&lt;li&gt;Log anomaly detection is more effective on log &lt;em&gt;metadata&lt;/em&gt; (frequency, source, timing) than log content&lt;/li&gt;
&lt;li&gt;Phishing detection is more accurate on sender domain patterns than email body content
The implication for building security tools is consistent: don't just look at the data. Look at what surrounds the data. Look at what the developer named it, where it came from, when it appeared, what called it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Context is signal. Often it's more signal than the content itself.&lt;/p&gt;




&lt;p&gt;The full key name risk scoring implementation is in &lt;code&gt;secrets_detector/features.py&lt;/code&gt; at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: why I chose Random Forest over deep learning for this problem — the full engineering tradeoffs argument, including interpretability, model size, training speed, and what you give up by not going deep.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>security</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The 26-Dimensional Feature Vector: How a Machine Learns to Recognise a Secret</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Thu, 14 May 2026 02:23:02 +0000</pubDate>
      <link>https://dev.to/pgmpofu/the-26-dimensional-feature-vector-how-a-machine-learns-to-recognise-a-secret-5000</link>
      <guid>https://dev.to/pgmpofu/the-26-dimensional-feature-vector-how-a-machine-learns-to-recognise-a-secret-5000</guid>
      <description>&lt;p&gt;hen my secrets detector evaluates a candidate string, it doesn't see code.&lt;/p&gt;

&lt;p&gt;It sees a vector of 26 numbers.&lt;/p&gt;

&lt;p&gt;That vector is the bridge between human intuition — "this looks like a secret" — and machine classification. Every insight a security engineer uses when reading code to spot exposed credentials has been translated into a numerical feature that the Random Forest classifier can reason about.&lt;/p&gt;

&lt;p&gt;This article is a complete walkthrough of those 26 features: what each one measures, why it matters, what it catches, and what it misses. By the end, you'll understand exactly what the model sees when it evaluates any candidate value — and why the combination of features catches things that no single signal could.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Feature Extraction Works
&lt;/h2&gt;

&lt;p&gt;Before the classifier sees anything, every candidate string goes through a feature extraction pipeline in &lt;code&gt;features.py&lt;/code&gt;. The pipeline takes two inputs: the string value itself, and the name of the variable holding it.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="c1"&gt;# Entropy features
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;shannon_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;repetition_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;longest_run_normalized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Character distribution features (8 features)
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;character_ratios&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Key name context
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;key_name_risk_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Pattern match flags (16 features)
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pattern_match_flags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a fixed-length array of 26 floating point numbers. The classifier never sees the original string — only this vector. That's both a strength (the model generalises across different string formats) and a limitation (some context that a human would use is deliberately excluded).&lt;/p&gt;

&lt;p&gt;Let me walk through each group.&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 1: Entropy Features (4 features)
&lt;/h2&gt;

&lt;p&gt;These four features capture the statistical "randomness" of the string — the property that real secrets share with random data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature 1: Shannon Entropy
&lt;/h3&gt;

&lt;p&gt;Shannon entropy measures the unpredictability of the character sequence. For a string of length &lt;em&gt;n&lt;/em&gt; with character frequencies &lt;em&gt;p_i&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;H = -Σ p_i × log₂(p_i)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A perfectly random string of alphanumeric characters has entropy around 5.7–6.0 bits. Common English words have entropy around 3.5–4.5 bits. Cryptographically generated secrets cluster at the high end.&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="c1"&gt;# High entropy — likely a secret or hash
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-proj-abc123XYZ789...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;entropy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;5.82&lt;/span&gt;

&lt;span class="c1"&gt;# Low entropy — likely a password or human-chosen value  
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Winter2019!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;            &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;entropy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.21&lt;/span&gt;

&lt;span class="c1"&gt;# Very low entropy — definitely not a secret
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aaaaaaaaaaaaa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;entropy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entropy alone is a weak classifier — UUIDs, SHA-256 hashes, and base64 image data all have high entropy but are not secrets. That's why it's one of 26 features, not the only feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature 2: Log-Scaled Length
&lt;/h3&gt;

&lt;p&gt;Raw string length would give too much weight to very long strings. Log-scaling (&lt;code&gt;math.log(len(value) + 1)&lt;/code&gt;) compresses the range so that the difference between a 32-character key and a 64-character key has roughly the same weight as the difference between a 4-character and 8-character string.&lt;/p&gt;

&lt;p&gt;Secrets tend to fall in predictable length ranges: AWS access keys are 20 characters, GitHub PATs are 40, JWT tokens are variable but typically 200+. Length contributes signal, but it's a soft signal — there's no length that definitively indicates "secret."&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature 3: Repetition Ratio
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;repetition_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the proportion of unique characters to total characters. A perfectly random string of 32 characters will have close to 32 unique characters (ratio ≈ 1.0). A string like &lt;code&gt;"aababcababc"&lt;/code&gt; has low unique character count relative to its length (ratio ≈ 0.3).&lt;/p&gt;

&lt;p&gt;Low repetition ratio is a strong signal that a string is &lt;em&gt;not&lt;/em&gt; a secret — real secrets don't repeat characters predictably. High repetition ratio is a necessary but not sufficient condition for being a secret.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature 4: Longest Run (Normalised)
&lt;/h3&gt;

&lt;p&gt;The length of the longest consecutive run of the same character, divided by string length:&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="n"&gt;longest_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;longest_run_normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;longest_run&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"aaabbbccc"&lt;/code&gt; has a longest run of 3 out of 9 characters — normalised run of 0.33.&lt;br&gt;
&lt;code&gt;"sk-abc123XYZ789def456"&lt;/code&gt; has a longest run of 1 out of 21 characters — normalised run of 0.05.&lt;/p&gt;

&lt;p&gt;Long runs of repeated characters are a strong signal of non-random data. No cryptographically generated secret will have a long run. Human-readable strings often will.&lt;/p&gt;


&lt;h2&gt;
  
  
  Group 2: Character Distribution Features (8 features)
&lt;/h2&gt;

&lt;p&gt;These eight features describe the composition of the string across character classes. Together they capture the "shape" of the character set that a human eye uses to distinguish secrets from benign strings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;What It Measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uppercase_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of A–Z characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lowercase_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of a–z characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;digit_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of 0–9 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;special_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of non-alphanumeric characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hex_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of valid hexadecimal characters (0–9, a–f, A–F)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base64_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of base64-safe characters (alphanumeric + /+=)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;printable_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of printable ASCII characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whitespace_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proportion of whitespace characters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why these specific ratios matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;hex_ratio&lt;/strong&gt; is particularly useful for distinguishing hash values from secrets. A SHA-256 hash has a hex_ratio of 1.0 — every character is a valid hex digit. An AWS access key has a hex_ratio of approximately 0.6 (uppercase letters reduce it). A JWT token has a hex_ratio near 0.0 (it's base64url-encoded, using characters outside the hex alphabet).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;special_ratio&lt;/strong&gt; catches secrets that include special characters — a strong signal for human-chosen passwords (&lt;code&gt;"P@ssw0rd!"&lt;/code&gt;) versus machine-generated tokens (which typically avoid special characters for compatibility reasons).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;base64_ratio&lt;/strong&gt; is the mirror of hex_ratio for base64-encoded content. Base64-encoded image data has a base64_ratio near 1.0. An API key that uses only alphanumeric characters has a high base64_ratio too — which is where the key name and other features need to disambiguate.&lt;/p&gt;

&lt;p&gt;The classifier learns the interaction between these ratios. A string with high entropy, high hex_ratio, and a key name that scores 0.0 is almost certainly a hash. A string with high entropy, mixed character ratios, and a key name that scores 1.0 is almost certainly a secret.&lt;/p&gt;


&lt;h2&gt;
  
  
  Group 3: Key Name Risk Score (1 feature)
&lt;/h2&gt;

&lt;p&gt;This is the single most important feature in the model — feature importance 0.28, more than the entropy and character features combined.&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="n"&gt;KEY_NAME_RISK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Score 1.0 — unambiguously sensitive
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passwd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privkey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;# Score 0.9 — very likely sensitive  
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apikey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;# Score 0.85 — likely sensitive
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;# Score 0.7 — possibly sensitive
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;# Score 0.2 — unlikely sensitive
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;setting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;# Score 0.0 — not sensitive
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checksum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;key_name_risk_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;normalised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;KEY_NAME_RISK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;normalised&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;score&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;  &lt;span class="c1"&gt;# Unknown key names get a moderate default
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scoring function does substring matching, so &lt;code&gt;DB_PASSWORD&lt;/code&gt;, &lt;code&gt;database_password&lt;/code&gt;, and &lt;code&gt;user_passwd&lt;/code&gt; all score 1.0. &lt;code&gt;API_KEY_V2&lt;/code&gt; and &lt;code&gt;service_api_key&lt;/code&gt; both score 0.9.&lt;/p&gt;

&lt;p&gt;Unknown variable names — ones that don't contain any recognised keyword — get a default score of 0.3. This is deliberately moderate: an unknown variable name is mild evidence that the string might not be sensitive (if it were, it would likely have a recognisable name), but it's not strong evidence either way.&lt;/p&gt;

&lt;p&gt;The impact of this feature on classification decisions is substantial:&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="c1"&gt;# Same value, wildly different classifications
&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# → flagged at 94% confidence
&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# → passed at 8% confidence
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the key name feature, these two lines are identical to the classifier. With it, they're completely distinguishable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 4: Pattern Match Flags (16 features)
&lt;/h2&gt;

&lt;p&gt;These are binary features — 0 or 1 — indicating whether the value matches any of 16 known secret format patterns.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_aws_access_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AKIA[0-9A-Z]{16}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_github_pat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gh[pousr]_[A-Za-z0-9]{36}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_github_fine_grained&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github_pat_[A-Za-z0-9]{82}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_jwt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_openai_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sk-[A-Za-z0-9]{48}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_slack_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xox[baprs]-[A-Za-z0-9-]+&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_stripe_secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sk_live_[A-Za-z0-9]{24}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_stripe_publishable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pk_live_[A-Za-z0-9]{24}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_google_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AIza[0-9A-Za-z-_]{35}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_heroku_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_private_key_header&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;`-----BEGIN (RSA\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;pattern_db_connection&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;`(postgresql\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;pattern_basic_auth&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[A-Za-z0-9+/]{20,}={0,2}&lt;/code&gt; (base64 basic auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_bearer_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bearer [A-Za-z0-9-._~+/]+=*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_hex_key_32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[0-9a-f]{32}&lt;/code&gt; (32-char hex — common key length)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_hex_key_64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[0-9a-f]{64}&lt;/code&gt; (64-char hex — SHA-256 length)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When any of these flags fire, the classifier has strong prior evidence that the value is a known secret format. A value that matches &lt;code&gt;pattern_aws_access_key&lt;/code&gt; will be classified as a secret at very high confidence regardless of what the other features say.&lt;/p&gt;

&lt;p&gt;The last two flags — &lt;code&gt;pattern_hex_key_32&lt;/code&gt; and &lt;code&gt;pattern_hex_key_64&lt;/code&gt; — deserve special mention. These match the lengths of common cryptographic keys but also match MD5 and SHA-256 hashes, which are not secrets. This is where the key name feature does critical disambiguation work: a 32-character hex string with key name &lt;code&gt;checksum&lt;/code&gt; has &lt;code&gt;pattern_hex_key_32 = 1&lt;/code&gt; but &lt;code&gt;key_name_risk = 0.0&lt;/code&gt;, and the classifier correctly passes it. The same string with key name &lt;code&gt;encryption_key&lt;/code&gt; gets flagged.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Features Interact: Three Case Studies
&lt;/h2&gt;

&lt;p&gt;Understanding individual features is useful. Understanding how they interact is where the real insight lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study 1: The Human-Chosen Password
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SMTP_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Winter2019!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shannon_entropy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.4&lt;/td&gt;
&lt;td&gt;Weak — below threshold for "looks random"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repetition_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;Neutral&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;special_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.09&lt;/td&gt;
&lt;td&gt;Slightly elevated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key_name_risk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;Very strong — "password" scores maximum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pattern_*&lt;/code&gt; flags&lt;/td&gt;
&lt;td&gt;All 0&lt;/td&gt;
&lt;td&gt;No known format match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secret — 91% confidence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The entropy would cause a pure entropy scanner to miss this. The key name saves it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study 2: The UUID False Positive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;session_correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;550e8400-e29b-41d4-a716-446655440000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shannon_entropy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;td&gt;Moderate — looks somewhat random&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hex_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.89&lt;/td&gt;
&lt;td&gt;Very high — almost all hex characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;special_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.08&lt;/td&gt;
&lt;td&gt;Low — only hyphens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key_name_risk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;Minimal — "id" scores 0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_heroku_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Fires — Heroku API keys are UUID-format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Benign — 23% confidence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern flag fires (Heroku API keys look like UUIDs), but the key name score is 0.0 and the classifier correctly suppresses the finding. A regex scanner using only the Heroku pattern would flag this. The ML classifier does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case Study 3: The Ambiguous High-Entropy String
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;encryption_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shannon_entropy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.9&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hex_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;Maximum — pure hex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key_name_risk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;Very high — "key" scores 0.7, "encryption" modifier pushes it higher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pattern_hex_key_32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Fires — 32-char hex matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repetition_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.44&lt;/td&gt;
&lt;td&gt;Low — repeating pattern visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secret — 78% confidence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repetition ratio is low (the value has a repeating &lt;code&gt;a1b2c3...&lt;/code&gt; pattern that reduces uniqueness), which pulls the confidence down from what it would be for a truly random key. But the key name and pattern flag are strong enough to push it above the reporting threshold. The finding would be reported at MEDIUM confidence — a prompt for human review rather than a guaranteed finding.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Feature Vector Cannot See
&lt;/h2&gt;

&lt;p&gt;Intellectual honesty requires being clear about the limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-variable context.&lt;/strong&gt; The feature vector sees one value at a time. It can't see that &lt;code&gt;key = config["encryption_key"]&lt;/code&gt; is loading the key from a config object rather than hardcoding it. A human engineer would immediately see that's not a hardcoded secret; the feature vector has no way to represent that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File context.&lt;/strong&gt; The feature vector doesn't know it's in a test file, a mock object, or a README code example. &lt;code&gt;TEST_API_KEY = "fake-key-for-testing"&lt;/code&gt; might have a high key name risk score despite being explicitly for testing. The inline suppression annotation (&lt;code&gt;# secrets-ignore&lt;/code&gt;) is the escape hatch for this case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic intent.&lt;/strong&gt; &lt;code&gt;version = "1.0.0"&lt;/code&gt; will correctly score a low key name risk. But &lt;code&gt;release_token = "1.0.0-beta"&lt;/code&gt; might score higher because "token" is a high-risk keyword — even though in context this is clearly a version string. The feature vector sees the word "token" in the variable name without understanding that it's used semantically differently here.&lt;/p&gt;

&lt;p&gt;These limitations are why the classifier is a signal generator rather than an oracle. Every finding above the confidence threshold warrants a human review. The classifier reduces the review burden dramatically — from "look at every high-entropy string in the codebase" to "look at these 20 high-confidence findings" — but it doesn't eliminate the need for human judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retraining on Your Own Data
&lt;/h2&gt;

&lt;p&gt;The feature vector approach makes retraining practical in a way that deep learning approaches don't. Because the features are hand-engineered and interpretable, adding new training samples has predictable effects.&lt;/p&gt;

&lt;p&gt;If your codebase has a pattern of false positives — say, your internal logging library uses variable names like &lt;code&gt;log_token&lt;/code&gt; that consistently score high key name risk despite being benign — you can add synthetic examples of that pattern to the benign training set and retrain in seconds:&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;# Add your custom generators to trainer.py, then:&lt;/span&gt;
python main.py train &lt;span class="nt"&gt;--samples&lt;/span&gt; 5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retrained model immediately incorporates your organisation-specific context. That's a capability that's practically unavailable with regex-based tools (you'd have to modify pattern files and accept increased miss rates) and theoretically possible but operationally impractical with deep learning (retraining takes hours and requires ML expertise).&lt;/p&gt;




&lt;p&gt;The complete feature extraction code is in &lt;code&gt;secrets_detector/features.py&lt;/code&gt; at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: why the variable name is the single most important feature in secrets detection — and what that tells us about how developers accidentally expose credentials.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>appsec</category>
      <category>security</category>
    </item>
    <item>
      <title>Why I Built an ML-Powered Secrets Detector Instead of Just Using Regex</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sun, 10 May 2026 15:40:08 +0000</pubDate>
      <link>https://dev.to/pgmpofu/why-i-built-an-ml-powered-secrets-detector-instead-of-just-using-regex-4koa</link>
      <guid>https://dev.to/pgmpofu/why-i-built-an-ml-powered-secrets-detector-instead-of-just-using-regex-4koa</guid>
      <description>&lt;p&gt;ost secrets scanners work the same way.&lt;/p&gt;

&lt;p&gt;They maintain a list of regex patterns — one for AWS access keys, one for GitHub personal access tokens, one for Stripe keys, one for JWT headers — and they scan your code looking for matches. When a pattern fires, they report a finding. When it doesn't, they stay silent.&lt;/p&gt;

&lt;p&gt;This works well for secrets that have distinctive, consistent formats. An AWS access key always starts with &lt;code&gt;AKIA&lt;/code&gt; followed by 16 uppercase alphanumeric characters. A GitHub PAT has a recognisable prefix. A private key has a PEM header. Regex catches these reliably.&lt;/p&gt;

&lt;p&gt;But it's only part of the problem. And the part it misses is exactly where real breaches happen.&lt;/p&gt;

&lt;p&gt;This is the story of why I built a machine learning secrets detector — what the existing approaches get wrong, what ML adds, and what the combined system catches that neither approach catches alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Two Failure Modes of Existing Tools
&lt;/h2&gt;

&lt;p&gt;Before building anything, I spent time understanding where the leading tools fail. TruffleHog, detect-secrets, and Gitleaks are all excellent tools. They're also all vulnerable to the same two failure modes in different proportions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 1: The Regex Gap
&lt;/h3&gt;

&lt;p&gt;Regex-only scanners miss secrets that don't match a known pattern.&lt;/p&gt;

&lt;p&gt;The most dangerous class of missed secrets is the &lt;strong&gt;generic hardcoded credential&lt;/strong&gt; — a password, database URL, or internal API key that doesn't follow any publicly documented format because it was generated internally.&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="c1"&gt;# No regex pattern catches this reliably
&lt;/span&gt;&lt;span class="n"&gt;DB_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tr0ub4dor&amp;amp;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;INTERNAL_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod-backend-service-key-2019&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SMTP_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;companyname_mail_2018!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are real secrets. They're low entropy by the standards of a cryptographically random key. They don't match any known service's key format. A regex scanner walks past them silently.&lt;/p&gt;

&lt;p&gt;This is not a theoretical concern. A significant proportion of credential exposures in real breaches involve exactly this type of secret — human-chosen passwords and internal tokens that were never designed to be detected by pattern matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 2: The Entropy False Positive Flood
&lt;/h3&gt;

&lt;p&gt;Some tools compensate by flagging anything with high Shannon entropy — the reasoning being that secrets are random, and random strings have high entropy.&lt;/p&gt;

&lt;p&gt;This is directionally correct and practically unusable in many codebases.&lt;/p&gt;

&lt;p&gt;High-entropy strings that are not secrets appear constantly in normal code:&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="c1"&gt;# UUID — high entropy, not a secret
&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;550e8400-e29b-41d4-a716-446655440000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# SHA-256 hash — very high entropy, not a secret
&lt;/span&gt;&lt;span class="n"&gt;expected_checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Base64-encoded image data — extremely high entropy, not a secret
&lt;/span&gt;&lt;span class="n"&gt;avatar_placeholder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Package integrity hash — high entropy, not a secret
&lt;/span&gt;&lt;span class="n"&gt;integrity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha512-abc123def456...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pure entropy scanner flags all of these. In a Node.js project with a &lt;code&gt;package-lock.json&lt;/code&gt;, an entropy scanner generates thousands of findings from integrity hashes alone. Engineers learn to ignore it within a week.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ML Adds: Context-Aware Classification
&lt;/h2&gt;

&lt;p&gt;The insight that drove the ML approach is that whether a string is a secret depends on &lt;strong&gt;context&lt;/strong&gt;, not just the string itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/code&gt; is either a secret or a benign hash depending on what variable contains it. A human security engineer can tell these apart instantly by reading the surrounding code. A regex scanner and an entropy scanner cannot.&lt;/p&gt;

&lt;p&gt;The question I asked was: can I teach a classifier to do what a human engineer does — look at the full context of a string and make a judgment about whether it's a secret?&lt;/p&gt;

&lt;p&gt;The answer turned out to be yes, with a 26-dimensional feature vector that captures what a human eye actually processes when making that judgment.&lt;/p&gt;

&lt;p&gt;Here's the comparison that drove the design:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Catches High-Entropy Secrets&lt;/th&gt;
&lt;th&gt;Catches Low-Entropy Secrets&lt;/th&gt;
&lt;th&gt;False Positive Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex only&lt;/td&gt;
&lt;td&gt;Yes (known formats)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entropy only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ML classifier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Significantly reduced&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ML classifier doesn't replace regex — it adds a second layer. Known-format secrets (AWS keys, GitHub PATs, JWTs) are still caught by pattern flags that are part of the feature vector. Generic hardcoded credentials that no regex would catch are caught by the combination of entropy, character distribution, and — most importantly — the variable name context.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Feature That Changed Everything: Key Name Risk
&lt;/h2&gt;

&lt;p&gt;When I looked at feature importances after training the initial model, one feature stood above all others: &lt;code&gt;key_name_risk&lt;/code&gt;, with an importance score of 0.28 out of 1.0.&lt;/p&gt;

&lt;p&gt;That's the variable name. Not the value — the name of the variable holding the value.&lt;/p&gt;

&lt;p&gt;This makes intuitive sense once you see it. These two lines of code contain the same string value:&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="n"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d8e8fca2dc0f896fd7cb4cb0031ba249&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A human engineer looks at these and immediately knows: the first is almost certainly a hash, the second is almost certainly a secret. The string itself carries no information about its purpose. The variable name carries everything.&lt;/p&gt;

&lt;p&gt;I built a risk scoring function that assigns numerical scores to variable names based on their semantic association with sensitive data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;passwd&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;private_key&lt;/code&gt; → score 1.0&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;credential&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt; → score 0.9
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;access_key&lt;/code&gt;, &lt;code&gt;client_secret&lt;/code&gt;, &lt;code&gt;bearer&lt;/code&gt; → score 0.85&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;config&lt;/code&gt;, &lt;code&gt;setting&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt; → score 0.1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;checksum&lt;/code&gt;, &lt;code&gt;hash&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt; → score 0.0
The classifier learns to combine this score with the entropy and character distribution features to make decisions that mirror what a human reviewer would make.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: &lt;code&gt;password = "abc123"&lt;/code&gt; gets flagged despite low entropy. &lt;code&gt;checksum = "d8e8fca2dc0f896fd7cb4cb0031ba249"&lt;/code&gt; gets passed despite high entropy. Neither outcome is achievable with regex or entropy alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Random Forest, Not a Neural Network
&lt;/h2&gt;

&lt;p&gt;When people hear "ML classifier," they often assume deep learning. I chose Random Forest deliberately, and it's worth explaining why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interpretability.&lt;/strong&gt; A Random Forest tells you exactly why it made a decision — which features contributed how much to a particular classification. When an engineer asks "why did the scanner flag this?", I can show them the feature breakdown: high entropy (0.82), key name risk (0.95), matches JWT pattern (true). A neural network produces a probability with no explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; The trained model is approximately 1MB as a pickle file. It ships with the tool, requires no internet connection, and adds negligible overhead to a scan. A neural network of sufficient sophistication would be orders of magnitude larger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training speed.&lt;/strong&gt; The model trains on 6,000 labeled samples in seconds on a standard laptop CPU. No GPU required. This matters enormously for the retraining feature — teams can add their own training samples and retrain in their local environment without specialist infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No overfitting on small data.&lt;/strong&gt; With 6,000 training samples — which is small by deep learning standards — Random Forest generalises better than a neural network would. The structured feature engineering does the heavy lifting; the model itself doesn't need to be sophisticated.&lt;/p&gt;

&lt;p&gt;The tradeoff is ceiling accuracy. A neural network operating on raw token sequences would likely achieve higher peak accuracy given sufficient data. But for a tool that needs to be deployable, explainable, and retrainable by a team without ML expertise, Random Forest is the right choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Synthetic Training Data: The Ethical Constraint
&lt;/h2&gt;

&lt;p&gt;One early design decision shaped everything else: I would not train on real leaked secrets from public repositories.&lt;/p&gt;

&lt;p&gt;The alternative — scraping GitHub for accidentally committed credentials and using them as positive training examples — is technically straightforward and has been done. It's also legally and ethically problematic. Those credentials belong to real people and organisations. Even if the data is technically public, using it to train a commercial tool raises questions I didn't want to answer.&lt;/p&gt;

&lt;p&gt;Instead, I built a synthetic data generator that produces realistic examples of both secrets and benign high-entropy strings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets (label=1):&lt;/strong&gt; Algorithmically generated AWS access keys, GitHub PAT formats, JWT structures, OpenAI key formats, Slack tokens, database connection strings, and — critically — synthetically generated "human-chosen" passwords that follow common patterns without being anyone's real password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benign (label=0):&lt;/strong&gt; UUIDs, MD5 and SHA-256 hashes, version strings, base64-encoded image data fragments, color hex codes, package integrity hashes, lorem ipsum text fragments.&lt;/p&gt;

&lt;p&gt;The synthetic approach has one significant advantage beyond ethics: I can generate unlimited training data and precisely control the class distribution. The 6,000 sample baseline can be scaled to 50,000 samples with a single command, which meaningfully improves model accuracy on edge cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three-Layer Detection Architecture
&lt;/h2&gt;

&lt;p&gt;The final tool combines three detection mechanisms, each compensating for the others' weaknesses:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — Pattern matching flags.&lt;/strong&gt; Sixteen binary features in the feature vector correspond to known secret formats (AWS, GitHub, JWT, OpenAI, Slack, database URLs, private key headers, and so on). These fire on known formats with near-zero false positives and form the backbone of high-confidence detections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — Entropy and character analysis.&lt;/strong&gt; Shannon entropy, character class ratios, repetition ratio, longest run of repeated characters — these features capture the statistical "shape" of a secret without requiring a specific format match. High entropy combined with a high-risk key name is a strong signal even when no pattern matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — Key name risk scoring.&lt;/strong&gt; The variable name context that neither regex nor entropy captures. This is what allows the classifier to catch &lt;code&gt;password = "simple123"&lt;/code&gt; despite its low entropy and lack of a recognisable format.&lt;/p&gt;

&lt;p&gt;A finding is reported when the classifier's confidence exceeds a configurable threshold (default: 0.7). Findings include the confidence score, the matched pattern if any, and — for CI/CD integration — an exit code that can gate builds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Actually Catches
&lt;/h2&gt;

&lt;p&gt;I ran the tool against a collection of test cases designed to stress each approach. Results that illustrate the gap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caught by all approaches:&lt;/strong&gt; &lt;code&gt;AWS_KEY = "AKIAIOSFODNN7EXAMPLE"&lt;/code&gt; — known format, high entropy, high-risk key name. Every tool gets this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caught only by ML:&lt;/strong&gt; &lt;code&gt;DB_PASS = "Winter2019!"&lt;/code&gt; — low entropy, no known format, but the key name &lt;code&gt;DB_PASS&lt;/code&gt; scores 1.0 and the classifier flags it at 89% confidence. Regex misses it. Entropy misses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;False positive in entropy tools, not in ML:&lt;/strong&gt; &lt;code&gt;expected_hash = "d8e8fca2dc0f896fd7cb4cb0031ba249"&lt;/code&gt; — high entropy, but key name scores 0.0 and the ML classifier correctly passes it. A pure entropy scanner flags it; the ML classifier does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;False positive in regex tools, not in ML:&lt;/strong&gt; An internal test file with &lt;code&gt;TEST_TOKEN = "fake-token-for-testing"&lt;/code&gt; annotated with &lt;code&gt;# secrets-ignore&lt;/code&gt; — the suppression annotation is respected, and the low-entropy value combined with a test file context (another feature) keeps the confidence below threshold even without the annotation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Fits in a Security Programme
&lt;/h2&gt;

&lt;p&gt;A secrets detector — even an ML-powered one — is one layer of a defence-in-depth approach, not a complete solution.&lt;/p&gt;

&lt;p&gt;It catches secrets at the point of scanning. It doesn't prevent secrets from being created in the first place (that's developer education and code review). It doesn't rotate compromised credentials (that's incident response). It doesn't enforce secrets management policies (that's your secrets manager — Vault, AWS Secrets Manager, Azure Key Vault).&lt;/p&gt;

&lt;p&gt;What it does well: systematically surface secret exposure across a codebase and git history, prevent new secrets from reaching the repository via pre-commit hooks, and provide a measurable baseline for "how many secret exposures exist in our codebase right now."&lt;/p&gt;

&lt;p&gt;That baseline matters more than most teams realise — you can't improve what you can't measure.&lt;/p&gt;




&lt;p&gt;The full source, including the feature extractor, trainer, and pre-commit hook, is at &lt;a href="https://github.com/pgmpofu/secrets-detector" rel="noopener noreferrer"&gt;github.com/pgmpofu/secrets-detector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: a deep dive into the 26-dimensional feature vector — exactly what the model sees when it evaluates a candidate secret, and how each feature contributes to the final decision.&lt;/p&gt;

</description>
      <category>security</category>
      <category>machinelearning</category>
      <category>appsec</category>
      <category>python</category>
    </item>
    <item>
      <title>What Building a SAST Tool Taught Me About AppSec That 13 Years of Software Engineering Didn't</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 09 May 2026 23:16:23 +0000</pubDate>
      <link>https://dev.to/pgmpofu/what-building-a-sast-tool-taught-me-about-appsec-that-13-years-of-software-engineering-didnt-3n2l</link>
      <guid>https://dev.to/pgmpofu/what-building-a-sast-tool-taught-me-about-appsec-that-13-years-of-software-engineering-didnt-3n2l</guid>
      <description>&lt;p&gt;I've been writing software professionally since 2011.&lt;/p&gt;

&lt;p&gt;Java, C#, Kotlin, Node.js. Enterprise backends, microservices, APIs, data pipelines. I've shipped production code that millions of people have used without knowing it. I've led teams, reviewed architectures, mentored junior engineers, and done all the things that accumulate into what people call "senior software engineer."&lt;/p&gt;

&lt;p&gt;And yet, when I decided to transition into application security, I realised I had significant blind spots — not about how software works, but about how software &lt;em&gt;fails&lt;/em&gt;. Specifically, how it fails in ways that attackers can exploit.&lt;/p&gt;

&lt;p&gt;This is the final article in a series about building a SAST scanner from scratch, embedding it in CI/CD pipelines, writing custom detection rules, and managing false positives. But it's really about what that whole process taught me about application security as a discipline — and what I wish I'd understood earlier.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Knew How to Write Secure Code. I Didn't Know Why It Was Secure.
&lt;/h2&gt;

&lt;p&gt;Here's an embarrassing admission: I've been using parameterised queries for SQL for at least a decade. I knew you were supposed to use them. I used them every time. I would have told you confidently that they prevent SQL injection.&lt;/p&gt;

&lt;p&gt;But if you'd asked me, before I started studying AppSec seriously, to explain &lt;em&gt;why&lt;/em&gt; they prevent SQL injection — the actual mechanism — I would have given you a hand-wavy answer about "the database handling it separately."&lt;/p&gt;

&lt;p&gt;Building the SQL injection detection rule forced me to get precise. I had to understand exactly what makes &lt;code&gt;"SELECT * FROM users WHERE id = " + userId&lt;/code&gt; dangerous, what makes &lt;code&gt;SELECT * FROM users WHERE id = ?&lt;/code&gt; with a bound parameter safe, and why the difference matters at the level of how the database parses and executes the statement.&lt;/p&gt;

&lt;p&gt;The answer — that parameterised queries send the query structure and the data in separate messages, so the database never attempts to parse the data as SQL syntax — is not complicated. But I didn't actually know it at that level of precision until I had to write a rule that distinguishes between the two patterns.&lt;/p&gt;

&lt;p&gt;This was a theme throughout the project. I knew the &lt;em&gt;what&lt;/em&gt; of secure coding from years of following conventions and best practices. Building detection rules forced me to learn the &lt;em&gt;why&lt;/em&gt; — the actual attack mechanics that the conventions are defending against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Knowing the secure pattern is not the same as understanding the vulnerability. For a software engineer, the secure pattern is enough to write safe code. For an AppSec engineer, you need to understand the attack, because your job is to find it when someone else didn't write the safe pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Is an Adversarial Discipline
&lt;/h2&gt;

&lt;p&gt;Software engineering is largely a collaborative discipline. You're building something. The goal is for it to work. Your mental model of the system is oriented around the happy path — the flow where inputs are valid, networks are reliable, and users do what you expect.&lt;/p&gt;

&lt;p&gt;AppSec is adversarial. The mental shift required is genuinely disorienting at first.&lt;/p&gt;

&lt;p&gt;When I was building the JWT algorithm none rule, I had to think like someone who wants to forge authentication tokens. Not because I want to do that, but because unless I understand exactly how the attack works — what the attacker controls, what assumptions the vulnerable code makes, what the exploit chain looks like — I can't write a rule that reliably detects it.&lt;/p&gt;

&lt;p&gt;This is the skill that 13 years of software engineering didn't develop: adversarial thinking. The question isn't "does this code do what it's supposed to do?" It's "how could someone make this code do something it's not supposed to do?"&lt;/p&gt;

&lt;p&gt;The OWASP Top 10 is, at its core, a catalogue of the assumptions developers make that attackers exploit. A03 — Injection assumes that input is data, not instructions. A07 — Authentication Failures assumes that the code correctly validates identity. A02 — Cryptographic Failures assumes that encryption means the data is protected.&lt;/p&gt;

&lt;p&gt;Every category is a place where the developer's mental model of the system diverges from what an attacker can actually do to it. Understanding OWASP deeply means understanding those divergences — not as a checklist, but as a way of thinking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; You can't find vulnerabilities you can't imagine. Developing adversarial thinking — the habit of asking "how could this go wrong for someone who wants it to go wrong" — is the most important cognitive shift in the AppSec transition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools Are Amplifiers, Not Answers
&lt;/h2&gt;

&lt;p&gt;Before I built my own SAST tool, I used SAST tools. And I treated them roughly like a compiler warning: something fires, I look at it, I decide whether to fix it or ignore it.&lt;/p&gt;

&lt;p&gt;Building one changed how I think about what a SAST tool actually is.&lt;/p&gt;

&lt;p&gt;A SAST tool is a codified set of heuristics about what vulnerable code looks like. Those heuristics are written by humans, based on human understanding of vulnerability patterns, with human decisions about confidence levels and severity ratings. The tool doesn't know your codebase. It doesn't know your threat model. It doesn't know whether the finding it just generated is actually exploitable in your specific deployment context.&lt;/p&gt;

&lt;p&gt;This sounds like a criticism. It isn't. It's a description of a tool's appropriate role.&lt;/p&gt;

&lt;p&gt;When I run Snyk or Semgrep now, I engage with the results differently than I did before. I ask: what pattern is this rule trying to catch? Is that pattern present in my code for the reason the rule assumes? Does the vulnerability the rule targets actually apply in my context? What would an attacker need to control to exploit this?&lt;/p&gt;

&lt;p&gt;Those are AppSec questions, not DevOps questions. A DevOps mindset treats SAST output as a compliance gate. An AppSec mindset treats it as a starting point for analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; A SAST scanner is a signal generator, not an oracle. The value it provides is proportional to the quality of thinking applied to its output — not to the number of findings it generates or suppresses.&lt;/p&gt;




&lt;h2&gt;
  
  
  False Positives Taught Me About Risk Tolerance
&lt;/h2&gt;

&lt;p&gt;Every time I suppressed a finding in my own scanner, I had to make a decision: is this actually safe, and how confident am I?&lt;/p&gt;

&lt;p&gt;That turns out to be the central skill of AppSec: structured risk assessment under uncertainty.&lt;/p&gt;

&lt;p&gt;You almost never have complete information. You can't always trace every data flow through a complex system. You can't always know whether a finding is exploitable without building a proof of concept. You have to make a judgment call about whether the risk is acceptable given what you know.&lt;/p&gt;

&lt;p&gt;What I learned from managing false positives is that risk tolerance is not a feeling — it's a position that needs to be documented and defensible. "I suppressed this because it looked fine" is not a risk assessment. "I suppressed this because the data being processed is always from our internal configuration system and never from user input, as confirmed by tracing the call stack in lines 42–67" is a risk assessment.&lt;/p&gt;

&lt;p&gt;The difference matters when something goes wrong. And in security, things go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Risk assessment is a core AppSec competency, not a soft skill. Developing a structured, documented approach to risk decisions — even informal ones — is more valuable than any specific technical knowledge.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap Between Writing Secure Code and Finding Insecure Code
&lt;/h2&gt;

&lt;p&gt;These are related skills. They are not the same skill.&lt;/p&gt;

&lt;p&gt;Writing secure code is a constructive activity. You know what you're building. You apply secure patterns. You follow established conventions. The feedback loop is relatively tight — if you use parameterised queries, you know you're not vulnerable to SQL injection there.&lt;/p&gt;

&lt;p&gt;Finding insecure code is a forensic activity. You're examining code you didn't write, often without full context, looking for patterns that indicate vulnerability. The feedback loop is loose — you might flag something, triage it, determine it's a false positive, and never know whether your triage was correct.&lt;/p&gt;

&lt;p&gt;The cognitive skills are different. Construction requires knowing the secure pattern. Detection requires knowing the vulnerable pattern and all its variations. It requires understanding which variations are genuinely dangerous and which are contextually safe. It requires maintaining a mental model of an attacker's perspective while reading code that was written from a developer's perspective.&lt;/p&gt;

&lt;p&gt;I've spent 13 years getting good at construction. Building this scanner was the first systematic exercise I did in detection. It was harder than I expected — not technically, but cognitively. Shifting from "I'm building this thing to work" to "I'm looking for ways this thing could be exploited" is a genuine gear change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; AppSec is not "software engineering plus security knowledge." It's a different cognitive discipline that happens to use the same raw material. Senior software engineers making this transition should expect a genuine learning curve, not just a knowledge gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Tell Someone Starting This Transition
&lt;/h2&gt;

&lt;p&gt;If you're a software engineer moving into AppSec — or considering it — here's what I'd tell you based on this project and the broader transition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build something.&lt;/strong&gt; Reading about OWASP is useful. Reading CVE writeups is useful. Neither teaches you what building a detection rule teaches you. The act of translating "this is a vulnerability" into "this is what the vulnerable code looks like in text" forces a precision of understanding that passive learning doesn't produce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Study the attacks, not just the defences.&lt;/strong&gt; Most of your software engineering career was spent learning defences — secure patterns, safe APIs, frameworks that handle the dangerous parts for you. AppSec requires understanding the attacks those defences are designed against. Read exploit writeups. Understand how CVEs actually work. Build your own vulnerable applications and attack them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get comfortable with ambiguity.&lt;/strong&gt; Software engineering has right answers. Does this code compile? Does this test pass? Does this function return the correct value? AppSec often doesn't. Is this finding exploitable? Is this suppression justified? Is this risk acceptable? These questions frequently don't have clean answers, and developing comfort with that ambiguity is part of the transition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use your engineering background as a superpower, not a crutch.&lt;/strong&gt; The thing that makes engineers valuable in AppSec is the ability to read code at scale, understand system architecture, and reason about data flows — skills most pure security professionals develop slowly. Use that. But don't assume that understanding how the code is supposed to work means you understand how it can be broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write about what you're learning.&lt;/strong&gt; This series started as a way to document my own thinking. Every article forced me to be more precise about something I thought I understood. The act of explaining something to someone else reveals the gaps in your own understanding faster than almost anything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Goes Next
&lt;/h2&gt;

&lt;p&gt;Building this scanner and writing this series was one project. The transition is ongoing.&lt;/p&gt;

&lt;p&gt;The next project is taking an old Java service and doing something I haven't done yet in this series: running Snyk against a real dependency tree on real legacy code, remediating real CVEs, and measuring the before-and-after security posture with actual metrics.&lt;/p&gt;

&lt;p&gt;That's a different kind of AppSec work — Software Composition Analysis rather than static analysis, dependency vulnerabilities rather than code vulnerabilities, Snyk's recommendations rather than my own rules. But the underlying skills are the same: understand the attack, assess the risk, make a defensible decision, measure the outcome.&lt;/p&gt;

&lt;p&gt;The transition from software engineer to AppSec engineer is not a destination. It's an ongoing process of developing adversarial thinking, structured risk assessment, and the forensic discipline of finding what's broken rather than building what works.&lt;/p&gt;

&lt;p&gt;Thirteen years in, I'm still learning. That's the right state to be in.&lt;/p&gt;




&lt;p&gt;The full SAST tool that this series was built around is at &lt;a href="https://github.com/pgmpofu/sast-tool" rel="noopener noreferrer"&gt;github.com/pgmpofu/sast-tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this series was useful to you — or if you're making a similar transition and want to compare notes — I'd genuinely like to hear from you. Find me here on dev.to or connect on LinkedIn.&lt;/p&gt;

</description>
      <category>career</category>
      <category>security</category>
      <category>webdev</category>
      <category>appsec</category>
    </item>
    <item>
      <title>False Positives in SAST — How I Built Suppression Into My Scanner and Why It Matters</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 09 May 2026 05:02:58 +0000</pubDate>
      <link>https://dev.to/pgmpofu/false-positives-in-sast-how-i-built-suppression-into-my-scanner-and-why-it-matters-48lo</link>
      <guid>https://dev.to/pgmpofu/false-positives-in-sast-how-i-built-suppression-into-my-scanner-and-why-it-matters-48lo</guid>
      <description>&lt;p&gt;There's a failure mode that kills security tooling programmes quietly, without drama, and it's not a technical failure.&lt;/p&gt;

&lt;p&gt;It's a trust failure.&lt;/p&gt;

&lt;p&gt;It goes like this: a team enables a SAST scanner. The scanner fires on 200 things. Engineers triage 40 of them and discover that 25 are false positives. They fix the 15 real findings, suppress the 25 false positives, and then face another 160 findings they haven't looked at yet. Two sprints later, nobody is triaging anymore. The scanner still runs. The reports still generate. Nobody reads them. The security programme is theatre.&lt;/p&gt;

&lt;p&gt;False positives are the mechanism by which this happens. Not because developers are lazy — because time is finite and trust is fragile. If a scanner cries wolf enough times, engineers stop listening. That's rational behaviour, not negligence.&lt;/p&gt;

&lt;p&gt;This article is about how I thought about false positives when building my SAST tool, what I built to manage them, and why the suppression system design matters as much as the detection rules themselves.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a False Positive Actually Costs
&lt;/h2&gt;

&lt;p&gt;Before getting into solutions, it's worth being precise about the cost.&lt;/p&gt;

&lt;p&gt;A false positive in a SAST scanner costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Triage time&lt;/strong&gt; — an engineer has to read the finding, understand the rule, examine the code in context, and reach a conclusion. Even for an experienced engineer, that's 5–15 minutes per finding for anything non-trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust capital&lt;/strong&gt; — every false positive is a small withdrawal from the trust account between the security team and the engineering team. Trust capital is finite and slow to rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attention budget&lt;/strong&gt; — the more false positives exist, the less attention real findings receive. This is the most dangerous cost. Security is fundamentally an attention allocation problem.
A scanner with a 40% false positive rate isn't 40% less useful. It's potentially useless, because the signal-to-noise ratio has collapsed to the point where engineers can't efficiently find real findings among the noise.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Three Sources of False Positives
&lt;/h2&gt;

&lt;p&gt;Not all false positives are the same. Understanding where they come from determines how to address them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Context-Blind Pattern Matching
&lt;/h3&gt;

&lt;p&gt;This is the most common source in regex-based scanners. The pattern matches the text but doesn't understand what the code is doing.&lt;/p&gt;

&lt;p&gt;The MD5 example I've used throughout this series is the canonical case:&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="c1"&gt;# False positive — MD5 for file integrity, not passwords
&lt;/span&gt;&lt;span class="n"&gt;file_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# True positive — MD5 for password storage
&lt;/span&gt;&lt;span class="n"&gt;stored_password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_password&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines match the pattern &lt;code&gt;\bmd5\s*\(&lt;/code&gt;. Only the second is a vulnerability. A regex scanner cannot tell them apart without understanding the semantic context — what type of data is being hashed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Safe Framework Usage That Looks Dangerous
&lt;/h3&gt;

&lt;p&gt;Some frameworks make inherently dangerous operations safe through abstraction. The dangerous-looking code is actually fine because the framework handles the dangerous part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks like SQL injection — it's not&lt;/span&gt;
&lt;span class="c1"&gt;// Spring Data JPA with @Query annotation handles parameterisation&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT u FROM User u WHERE u.email = :email"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;findByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A naive injection rule that flags anything resembling a SQL query with a variable near it would fire here. The JPA annotation system makes this perfectly safe — but the scanner doesn't know that.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Test and Configuration Code
&lt;/h3&gt;

&lt;p&gt;Test files are full of patterns that would be alarming in production code:&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="c1"&gt;# test_auth.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_jwt_none_algorithm_rejected&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Testing that we correctly REJECT the none algorithm
&lt;/span&gt;    &lt;span class="n"&gt;malicious_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;malicious_token&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;  &lt;span class="c1"&gt;# Should be rejected
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test is doing exactly the right thing — verifying that the application rejects the none algorithm attack. But a scanner looking for &lt;code&gt;algorithm="none"&lt;/code&gt; will flag it as AUTHN-001 without understanding that this is a negative test case.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built: The Suppression System
&lt;/h2&gt;

&lt;p&gt;My scanner supports two suppression mechanisms, each designed for different scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inline Suppression Annotations
&lt;/h3&gt;

&lt;p&gt;The simplest mechanism: a comment on the same line as the finding tells the scanner to skip it.&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="n"&gt;file_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# sast-ignore
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I support two annotation formats — &lt;code&gt;# sast-ignore&lt;/code&gt; and &lt;code&gt;# nosec&lt;/code&gt; — because &lt;code&gt;nosec&lt;/code&gt; is the Bandit convention and teams coming from Bandit shouldn't have to change their existing annotations.&lt;/p&gt;

&lt;p&gt;The scanner checks for these annotations before reporting a finding. If either is present on the matched line, the finding is suppressed silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem with silent suppression:&lt;/strong&gt; It's invisible. If every suppression silently disappears from the report, there's no way to audit whether suppressions are legitimate or whether engineers are using them to hide real findings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suppression With Justification
&lt;/h3&gt;

&lt;p&gt;The better pattern — and what I recommend teams enforce in code review — is annotating &lt;em&gt;why&lt;/em&gt; the suppression is valid:&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="c1"&gt;# MD5 used for file integrity checking only, not credential storage
# Tracked in SEC-REVIEW-2024-041 — confirmed non-sensitive context
&lt;/span&gt;&lt;span class="n"&gt;file_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# sast-ignore
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The annotation still suppresses the finding, but the comment creates a paper trail. When a security audit happens — and it will — every suppression has a documented rationale that a reviewer can evaluate. "We reviewed this and it's fine because X" is defensible. A bare &lt;code&gt;# sast-ignore&lt;/code&gt; with no context is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Suppression Inventory in JSON Output
&lt;/h3&gt;

&lt;p&gt;Here's a design decision I'm particularly pleased with: suppressed findings don't disappear from the JSON report. They appear in a separate &lt;code&gt;suppressed_findings&lt;/code&gt; array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CRYPTO-002"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SHA-1 Usage Detected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/utils/crypto.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suppressed_findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CRYPTO-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Weak Hashing — MD5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/utils/file_integrity.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"suppression_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MD5 used for file integrity only — sast-ignore"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"suppressed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"by_severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pipeline counts only active findings when deciding whether to fail&lt;/li&gt;
&lt;li&gt;The full report shows both active and suppressed findings&lt;/li&gt;
&lt;li&gt;Security reviewers can audit suppressions without looking at individual source files&lt;/li&gt;
&lt;li&gt;Trend analysis can track suppression rates over time alongside finding rates
That last point matters for measuring programme health. If your suppression count is growing faster than your finding count, something is wrong — either your rules are too noisy, or engineers are gaming the system.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Confidence Levels as Pre-Emptive Noise Reduction
&lt;/h2&gt;

&lt;p&gt;The suppression system deals with false positives after they appear. Confidence levels deal with them before.&lt;/p&gt;

&lt;p&gt;Every pattern in my rule engine declares a confidence level:&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;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pickle\.loads?\s*\('&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HIGH&lt;/span&gt;     &lt;span class="c1"&gt;# Almost always a real finding&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unserialize\s*\('&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MEDIUM&lt;/span&gt;   &lt;span class="c1"&gt;# Real finding in PHP web context, benign in CLI context&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;request\.headers\.get\(["\'&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="s"&gt;Origin["\']\)'&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LOW&lt;/span&gt;      &lt;span class="c1"&gt;# Could be proper allowlist implementation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confidence levels serve two purposes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For engineers reading findings:&lt;/strong&gt; Confidence communicates how much manual review a finding deserves. A HIGH confidence finding deserves immediate attention. A LOW confidence finding is a prompt to look at the code and make a judgment call. Without this signal, every finding looks equally important — which means either everything gets treated as urgent (unsustainable) or everything gets triaged with the same low attention (misses real issues).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For pipeline configuration:&lt;/strong&gt; Teams can configure their build gate to fail only on findings above a confidence threshold:&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;# Fail on HIGH severity + HIGH confidence only&lt;/span&gt;
python main.py ./src &lt;span class="nt"&gt;--fail-on&lt;/span&gt; HIGH &lt;span class="nt"&gt;--min-confidence&lt;/span&gt; HIGH

&lt;span class="c"&gt;# See everything including LOW confidence findings in audit mode&lt;/span&gt;
python main.py ./src &lt;span class="nt"&gt;--fail-on&lt;/span&gt; none &lt;span class="nt"&gt;--min-confidence&lt;/span&gt; LOW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a more nuanced gate than severity alone. A MEDIUM severity finding with HIGH confidence (this is almost certainly real, and it's moderately serious) might warrant blocking. A HIGH severity finding with LOW confidence (this is probably bad, but it might be fine) might not. The two dimensions together give you much more precise control over your signal-to-noise ratio.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Suppression Review Process
&lt;/h2&gt;

&lt;p&gt;The suppression mechanism is only as good as the governance around it. A suppression system without a review process is just a way to silence the scanner faster.&lt;/p&gt;

&lt;p&gt;Here's the process I'd implement in a team setting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Developer identifies a finding they believe is a false positive.&lt;/strong&gt;&lt;br&gt;
They don't suppress it immediately. They raise it in the PR for discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — The team reviews the claim.&lt;/strong&gt;&lt;br&gt;
Is the developer's reasoning sound? Is the code actually safe in context? Does anyone have concerns? This is a two-minute conversation in most cases, not a security committee meeting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — If accepted, the suppression is added with justification.&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;# sast-ignore&lt;/code&gt; goes in with a comment explaining why. The suppression is visible in the PR diff — it can't be hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — The suppression is tracked.&lt;/strong&gt;&lt;br&gt;
In the JSON report, in a suppression registry spreadsheet, or in a dedicated Notion page — wherever works for your team. What matters is that someone periodically reviews the suppression inventory and asks: are these still valid?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — Periodic suppression review.&lt;/strong&gt;&lt;br&gt;
Suppressions rot. Code changes. The context that made a suppression valid six months ago may no longer apply. A quarterly review of active suppressions — not of the whole codebase, just the suppression inventory — keeps the list honest.&lt;/p&gt;


&lt;h2&gt;
  
  
  Tuning Rules to Reduce Systemic False Positives
&lt;/h2&gt;

&lt;p&gt;When a specific rule consistently generates false positives across the codebase, the right answer isn't to suppress every instance — it's to tune the rule.&lt;/p&gt;

&lt;p&gt;The MD5 rule is a good example. Rather than flagging every &lt;code&gt;md5(&lt;/code&gt; call at HIGH confidence, I could tighten the pattern to focus on contexts that suggest credential handling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (noisy):&lt;/strong&gt;&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;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\bmd5\s*\('&lt;/span&gt;
    &lt;span class="na"&gt;confidence&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;strong&gt;After (tighter):&lt;/strong&gt;&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;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;md5\s*\(\s*(password|passwd|pwd|secret|credential|token)'&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HIGH&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(password|passwd|pwd)\s*=\s*.*md5\s*\('&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HIGH&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\bmd5\s*\('&lt;/span&gt;
    &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LOW&lt;/span&gt;   &lt;span class="c1"&gt;# Generic usage — review context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the rule distinguishes between MD5 in credential contexts (HIGH confidence, almost certainly a problem) and generic MD5 usage (LOW confidence, warrants a look but probably fine). The total finding count might be the same, but the actionable finding count — the ones that genuinely require a fix — goes up as a proportion of the total.&lt;/p&gt;

&lt;p&gt;This is the most sustainable way to reduce false positives: better rules, not more suppressions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The False Negative Trade-off
&lt;/h2&gt;

&lt;p&gt;Every time you tune a rule to reduce false positives, you risk introducing false negatives — real vulnerabilities the scanner no longer catches.&lt;/p&gt;

&lt;p&gt;This is the fundamental tension in SAST tool design. It has no clean resolution. It only has a deliberate choice.&lt;/p&gt;

&lt;p&gt;If you tighten the MD5 rule to only flag credential contexts, you'll miss the case where a developer uses a custom variable name:&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="c1"&gt;# Now invisible to the tightened rule
&lt;/span&gt;&lt;span class="n"&gt;user_auth_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_password&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question is: which failure mode is more expensive for your specific context?&lt;/p&gt;

&lt;p&gt;If your team is diligent about triage and the cost of a false negative (missed vulnerability) is high — financial services, healthcare, anything with regulatory consequences — keep rules broader and invest in the triage process.&lt;/p&gt;

&lt;p&gt;If your team is drowning in noise and findings aren't getting triaged at all — the scanner has already effectively failed — tighten the rules to rebuild trust, accept the trade-off, and plan to layer in additional controls elsewhere.&lt;/p&gt;

&lt;p&gt;There's no universally correct answer. There's only an honest assessment of your specific situation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Healthy Suppression Profile Looks Like
&lt;/h2&gt;

&lt;p&gt;After a few months of running the scanner with a consistent process, here's what healthy metrics look like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression rate below 20%.&lt;/strong&gt; If more than 1 in 5 findings is being suppressed, your rules are too noisy for your codebase. Tune the rules rather than suppressing everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No suppressions without justification comments.&lt;/strong&gt; Bare &lt;code&gt;# sast-ignore&lt;/code&gt; annotations with no explanation are a red flag. Make justification comments a code review requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression inventory reviewed quarterly.&lt;/strong&gt; Old suppressions that are no longer valid are silent technical debt. A quarterly review catches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;False positive rate declining over time.&lt;/strong&gt; As you tune rules based on real-world results, your false positive rate should go down. If it's stable or increasing, you're not learning from your suppression data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New findings triaged within one sprint.&lt;/strong&gt; If findings from a scan are still unreviewed after two weeks, your triage process isn't keeping up. Either reduce the finding volume (tune rules) or increase triage capacity.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Point
&lt;/h2&gt;

&lt;p&gt;False positive management is not a technical problem. It's a trust and process problem that has technical levers.&lt;/p&gt;

&lt;p&gt;The suppression system in my scanner — inline annotations, justification comments, suppressed findings in the JSON output, confidence levels on patterns — these are all technical levers. But they only work in the context of a team that has agreed on how to use them.&lt;/p&gt;

&lt;p&gt;The best SAST implementation I can imagine is one where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Engineers trust the scanner because it has a low false positive rate&lt;/li&gt;
&lt;li&gt;The scanner trusts engineers because suppressions are reviewed and justified&lt;/li&gt;
&lt;li&gt;Security teams trust both because the suppression inventory is auditable and periodically reviewed
That's not a configuration. That's a culture. The configuration just makes the culture possible.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Full source and suppression documentation at &lt;a href="https://github.com/pgmpofu/sast-tool" rel="noopener noreferrer"&gt;github.com/pgmpofu/sast-tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up — the final article in this series: what building all of this taught me about application security that 13 years of software engineering didn't.&lt;/p&gt;

</description>
      <category>appsec</category>
      <category>devops</category>
      <category>testing</category>
      <category>security</category>
    </item>
    <item>
      <title>The Adoption Trap to Avoid</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Thu, 07 May 2026 18:41:12 +0000</pubDate>
      <link>https://dev.to/pgmpofu/the-adoption-trap-to-avoid-2ekd</link>
      <guid>https://dev.to/pgmpofu/the-adoption-trap-to-avoid-2ekd</guid>
      <description>&lt;p&gt;The single biggest mistake teams make with CI/CD-integrated security tooling is treating it as a one-time setup rather than an ongoing programme.&lt;/p&gt;

&lt;p&gt;The scanner is not the security programme. The scanner is a signal generator. The security programme is the process by which signals become fixes, fixes become patterns, and patterns become rules that prevent the same issue from appearing again.&lt;/p&gt;

&lt;p&gt;Configurable thresholds give you the controls to introduce that programme without breaking your team's deployment workflow. Use them gradually, communicate the reasoning at each phase, and invest as much in the suppression review process as you do in the initial setup.&lt;/p&gt;

&lt;p&gt;A scanner your team trusts and engages with is worth ten scanners that get bypassed.&lt;/p&gt;




&lt;p&gt;Full source and GitHub Actions workflow examples at &lt;a href="https://github.com/pgmpofu/sast-tool" rel="noopener noreferrer"&gt;github.com/pgmpofu/sast-tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: the one everyone's been asking about — false positives in SAST, how I built suppression into the scanner, and why managing false positives is as important as finding real vulnerabilities.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
      <category>github</category>
    </item>
  </channel>
</rss>
