<?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.us-east-2.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>Why I Suppressed 30% of Snyk's Recommendations</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:41:08 +0000</pubDate>
      <link>https://dev.to/pgmpofu/why-i-suppressed-30-of-snyks-recommendations-4k1d</link>
      <guid>https://dev.to/pgmpofu/why-i-suppressed-30-of-snyks-recommendations-4k1d</guid>
      <description>&lt;p&gt;188 vulnerabilities. That's what Snyk found in MFlix.&lt;/p&gt;

&lt;p&gt;I fixed 132 of them. I suppressed 56.&lt;/p&gt;

&lt;p&gt;If you're reading that and thinking "shouldn't you fix everything?" — that's a reasonable instinct and also the wrong mental model for how AppSec actually works in practice. This article is about why.&lt;/p&gt;

&lt;p&gt;Not why I was lazy. Not why I cut corners. But why a security engineer who fixes every finding without thinking is doing something closer to compliance theatre than security work — and why documented, reasoned suppression decisions are a legitimate and necessary part of a mature security programme.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Principle: Risk Acceptance Is Not Risk Ignorance
&lt;/h2&gt;

&lt;p&gt;Every security programme operates with finite resources against an infinite potential attack surface. The question is never "have we eliminated all risk?" — that's impossible. The question is "have we reduced risk to an acceptable level, and do we have documented, defensible reasons for the risks we've chosen to accept?"&lt;/p&gt;

&lt;p&gt;Suppressing a Snyk finding without a documented reason is risk ignorance. You've decided not to fix something and you haven't recorded why.&lt;/p&gt;

&lt;p&gt;Suppressing a Snyk finding with a clear, reasoned justification is risk acceptance. You've evaluated the finding, understood the attack scenario, assessed the likelihood and impact in your specific context, and made a deliberate decision that the residual risk is acceptable given the cost of remediation.&lt;/p&gt;

&lt;p&gt;The difference matters when something goes wrong — and in security, something always eventually goes wrong. "We assessed this finding and accepted the risk because X, Y, Z" is a defensible position. "We didn't get around to it" is not.&lt;/p&gt;

&lt;p&gt;With that principle established, here's exactly how I made suppression decisions on MFlix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision Framework
&lt;/h2&gt;

&lt;p&gt;Every finding went through four questions in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is the vulnerable code path reachable in this application?&lt;/strong&gt;&lt;br&gt;
A vulnerability in a library function that MFlix never calls has zero exploitability regardless of its CVSS score. Unreachable code paths are candidates for suppression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What does the exploit maturity look like?&lt;/strong&gt;&lt;br&gt;
Snyk's exploit maturity classification — mature exploit, proof of concept, no known exploit — is the most important signal after reachability. A vulnerability with working public exploit code demands different urgency than one that's theoretically exploitable but requires novel research to weaponise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What are the compensating controls?&lt;/strong&gt;&lt;br&gt;
Is there something else between the attacker and the vulnerable component that reduces the practical risk? Network-level access controls, WAF rules, authentication requirements, rate limiting — these don't eliminate vulnerabilities but they change the realistic attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. What does remediation cost versus what does it buy?&lt;/strong&gt;&lt;br&gt;
Some fixes are a one-line version bump. Others require API migrations across dozens of files, introduce breaking changes, or require testing that takes days. The remediation cost has to be weighed against the risk reduction.&lt;/p&gt;

&lt;p&gt;A finding that scores poorly on all four — reachable, mature exploit, no compensating controls, cheap to fix — gets fixed immediately. A finding that scores well on all four — unreachable, no known exploit, multiple compensating controls, expensive to fix — is a strong suppression candidate.&lt;/p&gt;

&lt;p&gt;Most findings fall somewhere in between, which is where judgment comes in.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Findings I Suppressed and Why
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Category 1: Unreachable Code Paths (23 findings)
&lt;/h3&gt;

&lt;p&gt;The largest suppression category. These are vulnerabilities in library functions that MFlix imports but never calls.&lt;/p&gt;

&lt;p&gt;The clearest example is a cluster of findings in &lt;code&gt;jackson-databind&lt;/code&gt; around its polymorphic deserialization feature. The vulnerability is real — if an application uses Jackson's default typing or &lt;code&gt;@JsonTypeInfo&lt;/code&gt; with &lt;code&gt;As.PROPERTY&lt;/code&gt;, an attacker can instantiate arbitrary classes. The CVSS is 9.2.&lt;/p&gt;

&lt;p&gt;MFlix doesn't use polymorphic deserialization. The application deserializes simple flat document structures from MongoDB — movie records, user objects, comments. There's no &lt;code&gt;@JsonTypeInfo&lt;/code&gt; annotation anywhere in the codebase, no default typing configured on the ObjectMapper, and no endpoint that accepts the kind of nested polymorphic JSON the attack requires.&lt;/p&gt;

&lt;p&gt;I verified this by searching the codebase for every Jackson configuration point and every deserialization call:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"JsonTypeInfo&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;enableDefaultTyping&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;activateDefaultTyping"&lt;/span&gt; src/
&lt;span class="c"&gt;# No results&lt;/span&gt;

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"ObjectMapper&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;readValue"&lt;/span&gt; src/
&lt;span class="c"&gt;# 3 results — all simple flat document deserialization&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero results for the dangerous configuration. The attack path doesn't exist in this application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression entry:&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;Finding: jackson-databind Deserialization of Untrusted Data (CWE-502, CVSS 9.2)
Decision: SUPPRESS
Reason: MFlix does not use polymorphic deserialization. No @JsonTypeInfo annotations,
no default typing configuration. All Jackson usage is flat document deserialization
with no user-controlled type information. Verified by codebase search 2024-11-15.
Review date: 2025-05-15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;23 findings fell into this category — all vulnerabilities in Jackson, Spring's expression language engine, and Tomcat's JSP compiler that require application-level configurations or usage patterns that don't exist in MFlix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category 2: No Known Exploit (18 findings)
&lt;/h3&gt;

&lt;p&gt;Snyk's exploit maturity filter showed 137 findings with "no known exploit." After removing those already resolved by the BOM upgrade and those in category 1, 18 remained as candidates for suppression on exploit maturity grounds alone.&lt;/p&gt;

&lt;p&gt;These are real vulnerabilities in the CVE database with CVSS scores and CWE classifications. They're also vulnerabilities where no researcher has published working exploit code, no proof-of-concept exists, and exploitation would require novel security research to achieve.&lt;/p&gt;

&lt;p&gt;The practical reality: the barrier to exploitation for these findings is "someone needs to figure out how to exploit this first." For a non-production portfolio project with no external attack surface, that barrier is sufficient to justify deferral.&lt;/p&gt;

&lt;p&gt;I want to be clear about the difference between this decision for MFlix and this decision for a production system. For a real application with real users and real data, "no known exploit today" means "no known exploit yet" — and the calculation shifts significantly. For MFlix, which has never been deployed and has no external attack surface, it's a reasonable acceptance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression entry:&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;Finding: [18 findings, various packages]
Decision: SUPPRESS — DEFERRED
Reason: No known exploit in Snyk database as of scan date. No proof-of-concept
available. MFlix is a non-production portfolio project with no external attack
surface. Risk accepted pending any change in exploit maturity or deployment context.
Review date: quarterly or if exploit maturity changes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Category 3: Non-Production Context (9 findings)
&lt;/h3&gt;

&lt;p&gt;Nine findings are in test-scoped dependencies — libraries that are only used during test execution and never included in the production artifact.&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.junit.jupiter&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;junit-jupiter-api&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.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;scope&amp;gt;&lt;/span&gt;test&lt;span class="nt"&gt;&amp;lt;/scope&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;JUnit 5.1.0 has several findings. None of them matter for production security because JUnit never ships in the application JAR. A vulnerability in the test framework cannot be exploited by an attacker targeting the running application — it's not there.&lt;/p&gt;

&lt;p&gt;This is a common source of inflated finding counts in SCA tools. They scan the full dependency tree including test scope, which is appropriate for completeness but requires scope-aware triage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suppression entry:&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;Finding: junit-jupiter-api@5.1.0 [various findings]
Decision: SUPPRESS
Reason: Test-scoped dependency. Not included in production artifact.
No exploitability from external attack surface.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Category 4: Accepted Residual Risk Post-Remediation (6 findings)
&lt;/h3&gt;

&lt;p&gt;Six findings remain after I applied every fix that was reasonably achievable without a full rewrite of the application's data access layer.&lt;/p&gt;

&lt;p&gt;The specific case: &lt;code&gt;mongodb-driver-sync@3.9.1&lt;/code&gt; → &lt;code&gt;4.x&lt;/code&gt; is a major version upgrade with significant breaking API changes. The &lt;code&gt;3.x&lt;/code&gt; and &lt;code&gt;4.x&lt;/code&gt; MongoDB Java driver APIs are substantially different — connection management, session handling, and the reactive streams API all changed. Migrating would require rewriting significant portions of the DAO layer.&lt;/p&gt;

&lt;p&gt;The vulnerability itself — a Man-in-the-Middle risk in the TLS connection handling, CWE-300, CVSS 6.4 — is real but has a compensating control: the MongoDB Atlas connection string already enforces TLS (&lt;code&gt;mongodb+srv://&lt;/code&gt; scheme requires TLS). The MitM risk is partially mitigated by the network-level TLS enforcement even with the driver-level validation weakness.&lt;/p&gt;

&lt;p&gt;I upgraded to the latest &lt;code&gt;3.x&lt;/code&gt; patch (&lt;code&gt;3.12.x&lt;/code&gt;) which addresses several other findings, documented the residual risk from the &lt;code&gt;4.x&lt;/code&gt; migration, and flagged it as a tracked backlog item rather than a suppression.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Fixed That I Could Have Suppressed
&lt;/h2&gt;

&lt;p&gt;This is the part of this article that's most important for understanding how AppSec judgment actually works.&lt;/p&gt;

&lt;p&gt;Several findings I fixed could have been legitimately suppressed under the framework above. I fixed them anyway. Here's why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The jackson-databind findings I fixed:&lt;/strong&gt; Alongside the suppressed polymorphic deserialization findings, there were jackson-databind findings in the JSON parsing path for user-submitted data — the comment posting and user registration endpoints. These are reachable from the application's attack surface. Even without default typing, certain jackson-databind parsing vulnerabilities can be triggered by malformed JSON input. These got fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Tomcat findings I fixed:&lt;/strong&gt; The Insecure Defaults finding in &lt;code&gt;tomcat-embed-core@8.5.31&lt;/code&gt; (CVSS 9.8) could have been argued as "we just won't enable AJP" and suppressed. I fixed it instead because CVSS 9.8 with a working proof of concept is a line I'm not comfortable crossing even with a compensating control argument. The fix was a version bump. The risk of not fixing it wasn't worth the two minutes it would have taken to suppress it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Spring Security authorization bypass:&lt;/strong&gt; This one had a mature exploit classification. Regardless of how difficult the exploitation path was to follow in MFlix specifically, "Spring Security authorization bypass with a mature exploit" is not a finding I was going to suppress. Authentication and authorization are the core security controls in this application. I fixed it.&lt;/p&gt;

&lt;p&gt;The principle: suppression requires genuine cost-benefit analysis. When the fix is cheap and the risk is non-trivial, fixing is always the right answer. Suppression is for when the fix is expensive or the risk is genuinely minimal — not for when fixing is inconvenient.&lt;/p&gt;




&lt;h2&gt;
  
  
  Documenting Suppressions in Practice
&lt;/h2&gt;

&lt;p&gt;Snyk supports inline suppression via code comments and also through its dashboard where you can mark findings as "ignored" with a reason and expiry date.&lt;/p&gt;

&lt;p&gt;For MFlix I used Snyk's ignore functionality in &lt;code&gt;.snyk&lt;/code&gt; file format:&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;# .snyk&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.25.0&lt;/span&gt;
&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;SNYK-JAVA-COMFASTERXMLJACKSONCORE-*&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;Polymorphic deserialization not used in MFlix. No @JsonTypeInfo&lt;/span&gt;
          &lt;span class="s"&gt;annotations. All Jackson usage is flat document deserialization.&lt;/span&gt;
        &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2025-05-15T00:00:00.000Z'&lt;/span&gt;
        &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2024-11-15T00:00:00.000Z'&lt;/span&gt;
  &lt;span class="na"&gt;SNYK-JAVA-ORGJUNITJUPITER-*&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test-scoped dependency. Not in production artifact.&lt;/span&gt;
        &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2025-11-15T00:00:00.000Z'&lt;/span&gt;
        &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2024-11-15T00:00:00.000Z'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about this format worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry dates are mandatory in my process.&lt;/strong&gt; Every suppression gets a review date. Security posture changes — exploit maturity changes, deployment context changes, the application gets extended with new functionality that makes previously unreachable code paths reachable. A suppression without an expiry date is a suppression that gets forgotten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasons are complete sentences.&lt;/strong&gt; Not "not applicable" or "false positive." A complete sentence explaining exactly why the finding doesn't apply or why the risk is accepted. Future-you reading this six months later needs to understand the reasoning, not just the conclusion.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;% of Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed by BOM upgrade&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;47%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed by targeted remediation&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppressed — unreachable code path&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppressed — no known exploit&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppressed — test scope&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracked — accepted residual risk&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3%&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;188&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The BOM upgrade doing 47% of the work in one change is the most important number in that table. It's the strongest argument for keeping your framework versions current — not just for features but because the framework's own dependency management does an enormous amount of security maintenance work for you automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Looks Like in a Real Organisation
&lt;/h2&gt;

&lt;p&gt;In a production AppSec programme, this decision process doesn't happen in isolation. It involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A risk register&lt;/strong&gt; where accepted risks are formally documented and reviewed on a defined cadence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security team sign-off&lt;/strong&gt; on suppressions above a certain severity threshold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engineering sign-off&lt;/strong&gt; on the technical reasoning for unreachability claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit trail&lt;/strong&gt; that shows who made each decision and when&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For MFlix — a portfolio project — I've approximated this with the &lt;code&gt;.snyk&lt;/code&gt; file, this article, and the commit history. For a real team, the process would be more formal but the underlying reasoning is identical.&lt;/p&gt;

&lt;p&gt;The skill being demonstrated here isn't "I know how to run Snyk." It's "I can evaluate a finding, reason about its applicability to a specific system, make a defensible risk decision, and document it in a way that survives scrutiny."&lt;/p&gt;

&lt;p&gt;That's application security engineering. The tool is just the starting point.&lt;/p&gt;




&lt;p&gt;The &lt;code&gt;.snyk&lt;/code&gt; file with all suppression entries and the full remediated &lt;code&gt;pom.xml&lt;/code&gt; are in the repository at &lt;a href="https://github.com/pgmpofu/mflix" rel="noopener noreferrer"&gt;github.com/pgmpofu/mflix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: the remediation work itself — the version bumps that were easy, the ones that introduced breaking changes, and the MongoDB driver upgrade that I decided wasn't worth the cost.&lt;/p&gt;

</description>
      <category>security</category>
      <category>appsec</category>
      <category>java</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Running Snyk on Real Legacy Java Code — The Full Unfiltered Results</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:54:41 +0000</pubDate>
      <link>https://dev.to/pgmpofu/running-snyk-on-real-legacy-java-code-the-full-unfiltered-results-2e51</link>
      <guid>https://dev.to/pgmpofu/running-snyk-on-real-legacy-java-code-the-full-unfiltered-results-2e51</guid>
      <description>&lt;p&gt;Numbers are easy to skim.&lt;/p&gt;

&lt;p&gt;10 Critical. 99 High. 59 Medium. 20 Low. 188 total. Those numbers appeared in the first article and they're striking — but they don't tell you what an attacker could actually do with them.&lt;/p&gt;

&lt;p&gt;This article is the deep dive. I'm going to take the most significant findings from the Snyk scan of MFlix and explain what each vulnerability actually enables, why it matters specifically for this application's threat model, and what the relationship is between the dependency version and the attack.&lt;/p&gt;

&lt;p&gt;Not all 188 findings — the ones that matter. The ones with working exploit code. The ones with CVSS scores above 9.0. The ones where the application's specific functionality intersects with the vulnerability in a way that makes it genuinely dangerous.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Threat Model First
&lt;/h2&gt;

&lt;p&gt;Before talking about individual vulnerabilities, it's worth being explicit about what MFlix actually is and who would be attacking it.&lt;/p&gt;

&lt;p&gt;MFlix is a web application with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A public endpoint for movie search (unauthenticated)&lt;/li&gt;
&lt;li&gt;A user registration and login endpoint (unauthenticated)&lt;/li&gt;
&lt;li&gt;Authenticated endpoints for posting comments and accessing user data&lt;/li&gt;
&lt;li&gt;A MongoDB Atlas backend&lt;/li&gt;
&lt;li&gt;JWT-based session management&lt;/li&gt;
&lt;li&gt;A Spring Security access control layer
The realistic threat actors are:&lt;/li&gt;
&lt;li&gt;Automated vulnerability scanners looking for known CVEs&lt;/li&gt;
&lt;li&gt;Attackers who have identified the Spring Boot version via response headers and are targeting known exploits&lt;/li&gt;
&lt;li&gt;Authenticated users attempting privilege escalation&lt;/li&gt;
&lt;li&gt;Network-layer attackers attempting to intercept database traffic
With that model established, here's what the findings actually mean.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Finding 1: Remote Code Execution in spring-beans — CVSS 9.8
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;org.springframework:spring-beans@5.0.7.RELEASE&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-94 — Improper Control of Generation of Code&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 9.8&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 919&lt;br&gt;
&lt;strong&gt;Exploit maturity:&lt;/strong&gt; Proof of concept available&lt;/p&gt;

&lt;p&gt;This is the finding that made me stop and re-read the output twice.&lt;/p&gt;

&lt;p&gt;Remote Code Execution means an attacker can execute arbitrary commands on the server running the application. CVSS 9.8 is one point below the theoretical maximum. This is not a "could potentially lead to" vulnerability — it is a "an attacker who can reach this endpoint can run code on your server" vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; The Spring Framework's &lt;code&gt;CachedIntrospectionResults&lt;/code&gt; class, which handles JavaBean property introspection, improperly handles class loading in certain configurations. An attacker who can craft a malicious request containing a specific class path expression can cause the application to load and execute attacker-controlled code.&lt;/p&gt;

&lt;p&gt;This is related to — but distinct from — Spring4Shell (CVE-2022-22965), which made significant news in 2022. Spring4Shell required specific conditions: Java 9+, Spring Framework 5.3.x or 5.2.x, deployed as a WAR on Tomcat, with specific Spring MVC configurations. The RCE in &lt;code&gt;spring-beans@5.0.7&lt;/code&gt; has different preconditions but the same fundamental class of vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters for MFlix specifically:&lt;/strong&gt; MFlix exposes public HTTP endpoints — the movie search and authentication endpoints don't require a logged-in user. An attacker with network access doesn't need credentials to reach the vulnerable code path. The attack surface is the public-facing API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Upgrading &lt;code&gt;spring-web&lt;/code&gt; to &lt;code&gt;5.2.20.RELEASE&lt;/code&gt; or later resolves this. Under the BOM approach from article 2, this happens automatically when you upgrade to Spring Boot 2.7.x or 3.x.&lt;/p&gt;


&lt;h2&gt;
  
  
  Finding 2: Insecure Defaults in tomcat-embed-core — CVSS 9.8
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;org.apache.tomcat.embed:tomcat-embed-core@8.5.31&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-453 — Insecure Default Variable Initialization&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 9.8&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 704&lt;br&gt;
&lt;strong&gt;Source:&lt;/strong&gt; Transitive dependency via &lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This finding arrives through a transitive dependency — you never see &lt;code&gt;tomcat-embed-core&lt;/code&gt; in the &lt;code&gt;pom.xml&lt;/code&gt;, but it's the embedded servlet container that Spring Boot uses to serve HTTP requests. Every Spring Boot web application embeds Tomcat by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; Tomcat 8.5.31 ships with insecure default configurations in its AJP (Apache JServ Protocol) connector. AJP is a binary protocol used for communication between a web server (like Apache httpd) and Tomcat. In certain deployment configurations, the AJP connector accepts connections without sufficient authentication, allowing an attacker to read arbitrary files from the server and potentially achieve remote code execution.&lt;/p&gt;

&lt;p&gt;This vulnerability is related to the "Ghostcat" class of vulnerabilities (CVE-2020-1938) that affected Tomcat's AJP connector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters for MFlix:&lt;/strong&gt; In a typical development or cloud deployment, the AJP connector would be disabled entirely. But &lt;code&gt;tomcat-embed-core@8.5.31&lt;/code&gt; enables it by default. A developer who deploys MFlix without explicitly disabling AJP is running an exposed connector.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;application.properties&lt;/code&gt; in the original MFlix doesn't disable AJP. That's an omission that creates real attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Option A — Upgrade to a fixed Tomcat version (handled by BOM upgrade):&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="c"&gt;&amp;lt;!-- Spring Boot 3.x pulls in Tomcat 10.x, which addresses this --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option B — Explicitly disable AJP in application configuration:&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
&lt;/span&gt;&lt;span class="py"&gt;server.tomcat.ajp.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BOM upgrade resolves this automatically, but the explicit disable is worth adding regardless — it's defence in depth and makes the intent clear.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 3: Deserialization of Untrusted Data in jackson-databind — CVSS 9.2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;com.fasterxml.jackson.core:jackson-databind@2.9.6&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-502 — Deserialization of Untrusted Data&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 9.2&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 889&lt;br&gt;
&lt;strong&gt;Source:&lt;/strong&gt; Transitive dependency via both &lt;code&gt;spring-boot-starter-web@2.0.3&lt;/code&gt; AND &lt;code&gt;io.jsonwebtoken:jjwt@0.9.1&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This finding appears multiple times in the Snyk output — because &lt;code&gt;jackson-databind@2.9.6&lt;/code&gt; is a transitive dependency of multiple direct dependencies. When the same vulnerable library is pulled in through different dependency paths, Snyk correctly reports it once per path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; Jackson's polymorphic deserialization feature, when enabled with the &lt;code&gt;@JsonTypeInfo&lt;/code&gt; annotation or default typing, allows an attacker who controls JSON input to instantiate arbitrary Java classes. Certain "gadget" classes in common Java libraries — classes that perform dangerous operations in their constructors or setters — can be chained together to achieve remote code execution when they're instantiated during deserialization.&lt;/p&gt;

&lt;p&gt;The attack pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Application accepts JSON input from a user&lt;/li&gt;
&lt;li&gt;That JSON is deserialized using Jackson with polymorphic typing enabled&lt;/li&gt;
&lt;li&gt;Attacker crafts JSON that specifies a malicious class type&lt;/li&gt;
&lt;li&gt;Jackson instantiates the attacker-specified class, triggering its constructor&lt;/li&gt;
&lt;li&gt;The constructor performs a dangerous operation — file write, network connection, command execution
&lt;strong&gt;Why it matters for MFlix specifically:&lt;/strong&gt; MFlix's comment posting endpoint accepts JSON. The user registration endpoint accepts JSON. If the deserialization configuration on any of these endpoints uses polymorphic typing without blocklisting dangerous classes, the attack surface is real.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;jjwt@0.9.1&lt;/code&gt; dependency is the more concerning vector here. JWT parsing involves deserialization of the JWT payload. If the JWT library delegates to Jackson for payload deserialization and the Jackson configuration is permissive, an attacker who can forge a JWT — or who can supply a malicious token that reaches the parsing code before signature verification — could trigger the deserialization vulnerability.&lt;/p&gt;

&lt;p&gt;This is why the jjwt→0.12.0 upgrade matters beyond just the CVSS score. The new library was explicitly rewritten to avoid this deserialization path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Upgrade &lt;code&gt;jackson-databind&lt;/code&gt; to &lt;code&gt;2.13.x&lt;/code&gt; or later (handled by BOM upgrade). Explicitly disable default typing in Jackson configuration as defence in depth:&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;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="nf"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;mapper&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;ObjectMapper&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// Explicitly disable default typing to prevent gadget attacks&lt;/span&gt;
    &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;deactivateDefaultTyping&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;mapper&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;h2&gt;
  
  
  Finding 4: Certificate Host Mismatch in spring-boot-autoconfigure — CVSS 9.3
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;org.springframework.boot:spring-boot-autoconfigure@2.0.3.RELEASE&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-297 — Improper Validation of Certificate with Host Mismatch&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 9.3&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 679&lt;/p&gt;

&lt;p&gt;This finding is the one most specific to MFlix's architecture — and the one I'd argue is most dangerous in practice despite not having the RCE severity of finding 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; Spring Boot's autoconfiguration for SSL/TLS connections, in version 2.0.3, improperly validates the hostname in TLS certificates. When the application makes outbound connections — to the MongoDB Atlas cluster, to external APIs, to any HTTPS endpoint — it may accept certificates where the hostname doesn't match the certificate's Common Name or Subject Alternative Names.&lt;/p&gt;

&lt;p&gt;In plain terms: the application might connect to &lt;code&gt;attacker.com&lt;/code&gt; while believing it's connected to &lt;code&gt;cluster.mongodb.net&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters for MFlix specifically:&lt;/strong&gt; MFlix's entire data layer connects to MongoDB Atlas over TLS. Every database query goes through this connection. If the autoconfigure TLS validation is improperly validating the Atlas certificate, a network-position attacker — someone on the same network segment, or with DNS control, or with BGP hijacking capability — could intercept the database connection.&lt;/p&gt;

&lt;p&gt;What would they see? Every MongoDB query the application sends. Every result it receives. User credentials stored in the database. User session tokens. Movie data. Comment content. Everything.&lt;/p&gt;

&lt;p&gt;This isn't a theoretical attack in cloud environments — misconfigured network routing, shared hosting scenarios, and developer environments with local DNS modification are all realistic scenarios where this attack path is viable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; BOM upgrade to Spring Boot 2.5.x or later resolves the autoconfigure TLS validation. Additionally, explicitly configure SSL validation in the MongoDB connection:&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
&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}&amp;amp;ssl=true&amp;amp;sslInvalidHostNameAllowed=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sslInvalidHostNameAllowed=false&lt;/code&gt; is the critical flag — it explicitly disables the insecure behaviour that the autoconfigure vulnerability enables.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 5: Authorization Bypass in spring-security-web — CVSS 8.2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;org.springframework.security:spring-security-web@5.0.7.RELEASE&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-285 — Improper Authorization&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 8.2&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 731&lt;br&gt;
&lt;strong&gt;Source:&lt;/strong&gt; Transitive dependency via &lt;code&gt;spring-boot-starter-security@2.0.4&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This finding is particularly relevant for MFlix because MFlix uses Spring Security as its primary access control mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; Spring Security's path matching logic, in certain versions, could be bypassed by request paths that contain specific special characters or path traversal sequences. An attacker could access endpoints that should require authentication by crafting a URL that matches the underlying resource but doesn't match Spring Security's access control patterns.&lt;/p&gt;

&lt;p&gt;For example, if Spring Security protects &lt;code&gt;/api/v1/users/profile&lt;/code&gt; but the path matching doesn't normalise path traversal sequences, a request to &lt;code&gt;/api/v1/users/./profile&lt;/code&gt; might reach the endpoint without authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters for MFlix specifically:&lt;/strong&gt; MFlix's security configuration has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/api/v1/movies/**&lt;/code&gt; — public&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/v1/users/login&lt;/code&gt; — public&lt;/li&gt;
&lt;li&gt;Everything else — requires authentication
An authorization bypass vulnerability means the "everything else requires authentication" rule might have holes. User profile data, comment management, and administrative functionality could potentially be accessed without a valid JWT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Spring Security 5.7.x and later include significantly improved path normalisation. The BOM upgrade handles this, but it's worth explicitly testing authentication enforcement after the upgrade:&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;// Test that authenticated endpoints reject unauthenticated requests&lt;/span&gt;
&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;unauthorizedAccessShouldReturn401&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/users/profile"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isUnauthorized&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Test that path traversal attempts are rejected&lt;/span&gt;
&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;pathTraversalShouldBeRejected&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/users/../admin/users"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isForbidden&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;These tests should exist regardless of whether the vulnerability is present — they're part of the security test baseline for any authenticated application.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 6: Man-in-the-Middle in mongodb-driver-sync — CVSS 6.4
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;org.mongodb:mongodb-driver-sync@3.9.1&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;CWE:&lt;/strong&gt; CWE-300 — Channel Accessible by Non-Endpoint&lt;br&gt;
&lt;strong&gt;CVSS:&lt;/strong&gt; 6.4&lt;br&gt;
&lt;strong&gt;Priority Score:&lt;/strong&gt; 534&lt;/p&gt;

&lt;p&gt;This is the only direct dependency finding that isn't in the Spring ecosystem. The MongoDB Java driver version 3.9.1 has a vulnerability in how it handles TLS connections to the MongoDB server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the vulnerability is:&lt;/strong&gt; The driver, in certain connection configurations, doesn't enforce strict certificate chain validation for its TLS connections. An attacker in a network-privileged position — same network segment, DNS poisoning, BGP hijacking — could present a fraudulent certificate and establish a man-in-the-middle position between the application and the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters for MFlix:&lt;/strong&gt; Combined with finding 4 (the autoconfigure certificate mismatch), this creates a layered TLS validation problem. The autoconfigure layer doesn't validate the host properly, and the driver layer doesn't enforce strict certificate chain validation. In a vulnerable deployment, both controls that should prevent a MitM attack are weakened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Upgrade to &lt;code&gt;mongodb-driver-sync@4.9.x&lt;/code&gt; or later (the 3.x to 4.x upgrade is a breaking change with significant API differences, which is why it appears in the "breaking changes" section of article 5):&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.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;4.11.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;h2&gt;
  
  
  The Findings That Didn't Make This List
&lt;/h2&gt;

&lt;p&gt;A natural question: what about the other 182 findings I haven't discussed?&lt;/p&gt;

&lt;p&gt;They fall into three categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handled by the BOM upgrade.&lt;/strong&gt; The majority of findings — particularly the large number of Spring Framework and Spring Boot component CVEs — are resolved automatically when the parent BOM is upgraded to Spring Boot 3.2.5. I'll show the exact count in article 6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lower severity findings with no exploit code.&lt;/strong&gt; The 137 findings with "no known exploit" in the exploit maturity classification are real vulnerabilities, but their practical risk is lower. An attacker needs to develop custom exploit code to leverage them, compared to downloading a working tool for the 8 findings with mature exploits. These get tracked but don't drive remediation urgency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accepted risk findings.&lt;/strong&gt; A small number of findings — which I'll discuss in detail in article 4 — represent vulnerabilities that either can't be exploited in MFlix's specific deployment context, require conditions that don't exist in the application, or have remediation paths that introduce more risk than they resolve. These get documented suppression decisions rather than patches.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading Snyk Findings Like an AppSec Engineer
&lt;/h2&gt;

&lt;p&gt;The biggest shift in how I read the Snyk output now versus how I would have read it before this project is the difference between asking "what does this flag?" and "what can an attacker do?"&lt;/p&gt;

&lt;p&gt;CVSS scores are a starting point. They represent the worst-case severity of the vulnerability in a generic context. But the actual risk to any specific application depends on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reachability&lt;/strong&gt; — Is the vulnerable code path reachable from the application's attack surface? The RCE in spring-beans is reachable because MFlix exposes public HTTP endpoints. A vulnerability in a batch processing library that only runs scheduled jobs has a much smaller attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exploit availability&lt;/strong&gt; — Is there working exploit code publicly available? The 8 findings with mature exploits are categorically different from the 137 with no known exploit, regardless of their CVSS scores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application-specific amplification&lt;/strong&gt; — Does the application's functionality make the vulnerability worse? The jackson-databind deserialization vulnerability is more dangerous in an application that accepts complex JSON structures from untrusted users (like MFlix's comment and registration endpoints) than in an application that only produces JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compensating controls&lt;/strong&gt; — What else is between the attacker and the vulnerable component? Network-level controls, WAF rules, and rate limiting all affect the practical exploitability of vulnerabilities that require repeated requests or specific network positions.&lt;/p&gt;

&lt;p&gt;Reading Snyk output through these four lenses — rather than sorting by CVSS and fixing from the top — is what produces a defensible remediation strategy rather than a checkbox exercise.&lt;/p&gt;




&lt;p&gt;The full Snyk project for MFlix is at &lt;a href="https://github.com/pgmpofu/mflix" rel="noopener noreferrer"&gt;github.com/pgmpofu/mflix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next up: why I suppressed some findings and fixed others — the risk assessment framework, the role of exploit maturity, and the specific decisions I made on the findings that didn't get a patch.&lt;/p&gt;

</description>
      <category>java</category>
      <category>security</category>
      <category>appsec</category>
      <category>spring</category>
    </item>
    <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>
  </channel>
</rss>
