<?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>I Used AI to Help Remediate Vulnerabilities — Here's How Useful It Actually Was</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 06:28:10 +0000</pubDate>
      <link>https://dev.to/pgmpofu/i-used-ai-to-help-remediate-vulnerabilities-heres-how-useful-it-actually-was-43n9</link>
      <guid>https://dev.to/pgmpofu/i-used-ai-to-help-remediate-vulnerabilities-heres-how-useful-it-actually-was-43n9</guid>
      <description>&lt;p&gt;Everyone is talking about AI-assisted security. Fewer people are being honest about what it actually looks like in practice.&lt;/p&gt;

&lt;p&gt;I used Claude throughout the MFlix remediation project — for understanding vulnerabilities, planning upgrades, writing migration code, and reviewing my remediation decisions. This article is an honest accounting of where it helped, where it was confidently wrong, and what the experience taught me about the appropriate role of AI in security engineering work.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Used It
&lt;/h2&gt;

&lt;p&gt;Four distinct use cases across the project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vulnerability explanation&lt;/strong&gt; — understanding what a specific CVE actually enables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remediation planning&lt;/strong&gt; — figuring out the right fix approach for complex upgrades&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code generation&lt;/strong&gt; — writing the migration code for API changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision review&lt;/strong&gt; — sanity-checking suppression decisions
Each use case produced different results. Let me walk through them honestly.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Use Case 1: Vulnerability Explanation — Where AI Genuinely Shines
&lt;/h2&gt;

&lt;p&gt;When Snyk flagged &lt;code&gt;spring-beans@5.0.7&lt;/code&gt; with a Remote Code Execution at CVSS 9.8, my first question was: what does this actually enable? The CVE database entry is often terse. The Snyk description gives you the vulnerability class but not always the attack mechanics.&lt;/p&gt;

&lt;p&gt;I asked Claude to explain exactly how the spring-beans RCE worked — what an attacker needed to control, what the exploit chain looked like, and how the application's specific configuration affected exploitability.&lt;/p&gt;

&lt;p&gt;The explanation was excellent. It walked through the class loading mechanism in &lt;code&gt;CachedIntrospectionResults&lt;/code&gt;, explained why certain HTTP request parameters could trigger it, and was clear about the conditions required — HTTP endpoint exposure, specific Spring MVC configuration, no input filtering at the framework layer.&lt;/p&gt;

&lt;p&gt;More importantly, it helped me think through MFlix's specific exposure: yes, the public movie search endpoint is unauthenticated, yes it accepts HTTP parameters, yes the Spring MVC configuration in MFlix matches the vulnerable pattern.&lt;/p&gt;

&lt;p&gt;This kind of contextual vulnerability analysis — "here's the CVE, here's my application, am I actually exposed?" — is exactly where AI adds value. It accelerates the analysis that a security engineer would do manually, and it's good at it because the underlying information (CVE mechanics, Spring internals, application patterns) is well-documented and within training data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy: High.&lt;/strong&gt; I cross-referenced the explanation against the Spring Security advisories and the CVE detail page. The core mechanics were correct. The application-specific analysis was sound.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Case 2: Remediation Planning — Mixed Results
&lt;/h2&gt;

&lt;p&gt;Planning the jjwt 0.9.1 → 0.12.0 migration is where I first encountered AI's most significant failure mode: confident incorrectness.&lt;/p&gt;

&lt;p&gt;I asked for a migration guide from jjwt 0.9.1 to 0.12.0. The response was detailed, structured, and plausible-looking. It included code examples showing the old and new API patterns, explained the artifact structure change (monolith to three separate artifacts), and described the key type requirement change.&lt;/p&gt;

&lt;p&gt;Three of the code examples were wrong.&lt;/p&gt;

&lt;p&gt;Not subtly wrong — wrong in ways that would cause compile errors or runtime failures. The &lt;code&gt;Keys.hmacShaKeyFor()&lt;/code&gt; usage was correct but the import path was from a version that didn't exist. The &lt;code&gt;Jwts.SIG.HS256&lt;/code&gt; syntax was correct but the surrounding builder pattern had a method that was removed in 0.11.x, not present in 0.12.0. The claims parsing example used &lt;code&gt;.getBody()&lt;/code&gt; which is the 0.9.x API, not &lt;code&gt;.getPayload()&lt;/code&gt; which is the 0.12.x API.&lt;/p&gt;

&lt;p&gt;When I pointed out the errors, Claude corrected them — but the corrections introduced new errors. The model had detailed knowledge of jjwt 0.9.x and general knowledge of the 0.12.x direction, but its specific knowledge of the 0.12.0 API surface was unreliable.&lt;/p&gt;

&lt;p&gt;This is the pattern I encountered repeatedly: AI is excellent at explaining concepts and patterns, but unreliable on specific API signatures for library versions that changed after its training data cutoff or that were underrepresented in training data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correct workflow I developed:&lt;/strong&gt; Use AI to understand the &lt;em&gt;shape&lt;/em&gt; of what needs to change, then verify every specific API detail against the official documentation or source code. Never trust an AI-generated import path or method signature without checking it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy: Medium.&lt;/strong&gt; Conceptually correct, specifically unreliable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Case 3: Code Generation — Useful With Verification
&lt;/h2&gt;

&lt;p&gt;For the Spring Security configuration migration from &lt;code&gt;WebSecurityConfigurerAdapter&lt;/code&gt; to the &lt;code&gt;SecurityFilterChain&lt;/code&gt; bean pattern, I asked Claude to rewrite the existing configuration in the new style.&lt;/p&gt;

&lt;p&gt;The generated code was largely correct. The &lt;code&gt;SecurityFilterChain&lt;/code&gt; bean structure was right. The &lt;code&gt;authorizeHttpRequests&lt;/code&gt; lambda syntax was right. The &lt;code&gt;SessionCreationPolicy.STATELESS&lt;/code&gt; configuration was right.&lt;/p&gt;

&lt;p&gt;Two issues:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;antMatchers()&lt;/code&gt; → &lt;code&gt;requestMatchers()&lt;/code&gt; rename was handled correctly in the &lt;code&gt;authorizeHttpRequests&lt;/code&gt; block but missed in a separate method I hadn't shown in my prompt. This is a prompt engineering failure as much as a model failure — I didn't include the full configuration, so the model couldn't see everything that needed changing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AuthenticationManager&lt;/code&gt; bean configuration was slightly wrong — the generated code used a deprecated method for retrieving it from the &lt;code&gt;AuthenticationConfiguration&lt;/code&gt;. The correct approach required checking the Spring Security 6.x migration guide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workflow that worked:&lt;/strong&gt; Provide the complete existing code in the prompt, ask for the migration, then run the tests. The tests caught both issues immediately. AI-generated code that passes tests is trustworthy; AI-generated code that you haven't run is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy: High with verification.&lt;/strong&gt; Tests are not optional when using AI-generated code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Case 4: Suppression Decision Review — Surprisingly Valuable
&lt;/h2&gt;

&lt;p&gt;This was the use case I expected least from and got most from.&lt;/p&gt;

&lt;p&gt;Before finalising any suppression decision, I described the finding and my reasoning to Claude and asked it to challenge my logic. "I'm planning to suppress this jackson-databind deserialization finding because we don't use polymorphic deserialization. Here's my evidence. What am I missing?"&lt;/p&gt;

&lt;p&gt;The responses were genuinely useful — not because the model had superior security knowledge, but because articulating the reasoning to an external entity forced me to be more precise, and the model asked clarifying questions that identified gaps in my analysis.&lt;/p&gt;

&lt;p&gt;For the jackson-databind suppression, it asked: "Have you verified that no third-party library you import configures Jackson's default typing on your behalf?" I hadn't checked that. I checked. Nothing did. But the question was right — a common source of polymorphic deserialization vulnerabilities is a transitive dependency that configures Jackson in the background, not direct application code.&lt;/p&gt;

&lt;p&gt;For the MongoDB driver suppression, it pushed back on my "Atlas TLS enforcement mitigates the MitM risk" argument more effectively than I'd expected. The counter-argument: Atlas TLS enforcement prevents cleartext transmission but doesn't fully mitigate driver-level certificate validation failures, which can be exploited even over an encrypted channel under certain network conditions. The suppression was still justified, but the reasoning needed to be more precise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy: High for challenging reasoning.&lt;/strong&gt; Using AI as a devil's advocate for security decisions is one of its most effective use cases — not because it's always right, but because the process of explaining your reasoning exposes gaps.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Failure Mode That Matters Most: Confident Incorrectness
&lt;/h2&gt;

&lt;p&gt;Across all four use cases, one failure mode appeared repeatedly and is worth naming explicitly: &lt;strong&gt;confident incorrectness on version-specific details&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model would state that a method existed in a specific library version with complete confidence — no hedging, no "you should verify this" — and be wrong. Not wrong about the concept, wrong about the specific API surface at a specific version.&lt;/p&gt;

&lt;p&gt;For security work this is particularly dangerous. The difference between &lt;code&gt;setSigningKey(String)&lt;/code&gt; (vulnerable in jjwt 0.9.x, accepts weak keys) and &lt;code&gt;signWith(SecretKey, SignatureAlgorithm)&lt;/code&gt; (secure in 0.12.x, enforces key strength) is the difference between a secure and an insecure JWT implementation. If you trust the AI's method signature and it's wrong, you might implement the old vulnerable pattern thinking you've implemented the secure one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule I developed:&lt;/strong&gt; Trust AI for concepts. Verify AI for specifics. Any method name, import path, version number, or configuration value that AI provides should be cross-referenced against official documentation before use in security-sensitive code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where AI Added the Most Net Value
&lt;/h2&gt;

&lt;p&gt;Ranking the use cases by net value delivered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Vulnerability explanation&lt;/strong&gt; — highest value, highest accuracy. Understanding attack mechanics is conceptual work that AI handles well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Suppression decision review&lt;/strong&gt; — high value, unexpected. Using AI as a challenger rather than an oracle is an underrated pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Code generation&lt;/strong&gt; — medium value, requires verification. Fastest when used as a starting point with tests as the quality gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Remediation planning&lt;/strong&gt; — lowest net value due to reliability issues. Better to read the official migration guide directly and use AI to clarify specific points you don't understand.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means for the NerdWallet Role
&lt;/h2&gt;

&lt;p&gt;The job description explicitly mentions building AI-powered security systems including RAG pipelines and automated code review. Having done this project gives me a concrete, honest perspective on AI in security work that I think is more valuable than enthusiasm.&lt;/p&gt;

&lt;p&gt;The engineers who will build effective AI security tools aren't the ones who think AI is magic. They're the ones who understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where AI is reliably accurate (concepts, pattern recognition, reasoning challenges)&lt;/li&gt;
&lt;li&gt;Where AI is unreliably accurate (specific APIs, version details, novel configurations)&lt;/li&gt;
&lt;li&gt;How to design systems that route the right tasks to AI and keep humans in the loop on the wrong ones&lt;/li&gt;
&lt;li&gt;How to test and validate AI outputs in security contexts where confident incorrectness is dangerous
An automated code review system that uses AI to identify suspicious patterns is valuable. The same system that uses AI to generate specific remediation code without human verification is dangerous — because the AI might generate code that looks like a fix but implements the vulnerable pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The appropriate role of AI in security engineering is augmentation, not replacement. It compresses the time to understand a vulnerability, challenges your reasoning on risk decisions, generates starting points for remediation code. It does not replace reading the documentation, running the tests, or applying security judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Bottom Line
&lt;/h2&gt;

&lt;p&gt;AI made me faster on this project. It did not make me more accurate on its own — accuracy came from verifying AI outputs against authoritative sources and running tests.&lt;/p&gt;

&lt;p&gt;The productivity gain was real: vulnerability analysis that might have taken an hour of reading took fifteen minutes with AI assistance. The jjwt migration planning took an afternoon partly because I trusted the first AI-generated migration guide more than I should have. The security configuration migration was straightforward partly because the AI-generated starting point was close enough that tests caught the gaps quickly.&lt;/p&gt;

&lt;p&gt;Net verdict: meaningful productivity improvement with a significant failure mode that requires active management. The failure mode — confident incorrectness on specifics — is dangerous enough in security contexts that using AI without verification is worse than not using it at all.&lt;/p&gt;

&lt;p&gt;That's not an argument against AI in security work. It's an argument for understanding what you're working with.&lt;/p&gt;




&lt;p&gt;The completed MFlix project — remediated &lt;code&gt;pom.xml&lt;/code&gt;, &lt;code&gt;.snyk&lt;/code&gt; suppression file, security configuration, and full test suite — 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;This concludes the MFlix/Snyk series. The full portfolio now spans three projects and twenty-one articles covering SAST tool design, ML-powered secrets detection, and real-world SCA remediation. If you found this series useful, the best thing you can do is star the repositories and share the articles with someone who's thinking about the AppSec transition.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>appsec</category>
      <category>java</category>
    </item>
    <item>
      <title>Before and After: Measuring Security Posture Improvement With Real Metrics</title>
      <dc:creator>Patience Mpofu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 06:17:01 +0000</pubDate>
      <link>https://dev.to/pgmpofu/before-and-after-measuring-security-posture-improvement-with-real-metrics-pp1</link>
      <guid>https://dev.to/pgmpofu/before-and-after-measuring-security-posture-improvement-with-real-metrics-pp1</guid>
      <description>&lt;p&gt;188 vulnerabilities. That's where we started.&lt;/p&gt;

&lt;p&gt;After the modernisation, the jjwt migration, the targeted remediations, and the documented suppressions, here's where we ended up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6 open findings. All suppressed with documented reasons. 0 unaddressed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But "188 to 6" is a headline, not a measurement. This article is about what meaningful security posture measurement actually looks like — the metrics that tell a real story versus the ones that just make a dashboard look good.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Raw Finding Count Is a Weak Metric
&lt;/h2&gt;

&lt;p&gt;The most common way teams measure SCA progress is finding count. Before: 188. After: 6. Improvement: 182 findings resolved. 97% reduction.&lt;/p&gt;

&lt;p&gt;That number is real but it's also misleading in isolation. Here's why.&lt;/p&gt;

&lt;p&gt;If I had suppressed all 188 findings without fixing anything, my finding count would also be 6. The dashboard would look identical. The actual security posture would be unchanged.&lt;/p&gt;

&lt;p&gt;Finding count measures activity, not outcomes. What you need to measure is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What risk was actually reduced&lt;/strong&gt; — not just what got closed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What risk remains and why&lt;/strong&gt; — the documented residual risk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How the remediation was achieved&lt;/strong&gt; — fix vs. suppress breakdown&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the exploit exposure looks like&lt;/strong&gt; — before and after exploit maturity
These four dimensions together tell a story that a single number can't.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Metric 1: Risk Reduction by Severity
&lt;/h2&gt;

&lt;p&gt;The most important before/after comparison is severity distribution, not total count.&lt;/p&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;Before&lt;/th&gt;
&lt;th&gt;After Fixed&lt;/th&gt;
&lt;th&gt;After Suppressed&lt;/th&gt;
&lt;th&gt;Remaining&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;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;0 (7 in 4.x backlog)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&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;132&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 unaddressed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every Critical finding was fixed — not suppressed, fixed. That's the number that matters most. No Critical vulnerability was accepted as residual risk.&lt;/p&gt;

&lt;p&gt;The 6 suppressed High findings are all in the "no known exploit" category with documented unreachability justifications. The 23 suppressed Medium findings are split between test-scope dependencies and unreachable code paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to say when presenting this:&lt;/strong&gt; "We eliminated all Critical exposure and resolved 94% of High findings through active remediation. The remaining 6 High findings are suppressed with documented justifications — unreachable code paths verified by codebase analysis — and have review dates set for Q2."&lt;/p&gt;




&lt;h2&gt;
  
  
  Metric 2: Fix vs. Suppress Breakdown
&lt;/h2&gt;

&lt;p&gt;This metric distinguishes genuine security improvement from metric manipulation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution Type&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 — 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 — 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;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;70% of findings were actively fixed. 30% were suppressed with documented reasons. Zero were closed without a reason.&lt;/p&gt;

&lt;p&gt;The 47% resolved by BOM upgrade is worth highlighting specifically — it demonstrates the leverage of keeping framework versions current. Nearly half the entire vulnerability backlog was resolved by a single architectural change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this tells a hiring manager or security team:&lt;/strong&gt; You didn't just run a scanner and close tickets. You made architectural decisions, did targeted remediations, and applied security judgment to suppression decisions. The breakdown shows the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metric 3: Exploit Exposure Before and After
&lt;/h2&gt;

&lt;p&gt;Raw CVSS scores don't tell you how much real-world attack risk exists. Exploit maturity does.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exploit Maturity&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What It Means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mature (working exploit exists)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Anyone with the tool can exploit this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof of concept&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;Requires effort but exploit path is known&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;td&gt;Theoretical vulnerability, no public exploit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exploit Maturity&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mature&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;All fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof of concept&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;All fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No known exploit&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Suppressed with documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every finding with working exploit code was fixed. Every finding with a published proof of concept was fixed. The 6 remaining findings are all in the "no known exploit" category — the lowest practical risk tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the metric that matters most for communicating real risk reduction.&lt;/strong&gt; Going from 8 mature exploits to 0 means an attacker with a public tool can no longer trivially compromise this application. That's a concrete, meaningful security improvement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metric 4: Priority Score Reduction
&lt;/h2&gt;

&lt;p&gt;Snyk's priority score (0-1000) combines CVSS, exploit maturity, reachability, and other factors into a single number per finding. It's more nuanced than CVSS alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; Highest priority score was 919 (spring-web RCE, spring-context RCE, spring-boot-starter-security auth bypass — multiple findings at maximum score)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; Highest priority score among remaining findings is 329 — the Improper Handling of Case Sensitivity finding in spring-core that requires a major version upgrade to resolve and is suppressed with justification.&lt;/p&gt;

&lt;p&gt;The maximum priority score dropped from 919 to 329. The most critical attack vectors have been eliminated.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metric 5: Dependency Health Score
&lt;/h2&gt;

&lt;p&gt;Beyond individual vulnerabilities, the modernisation improved the overall health of the dependency tree in ways that reduce future vulnerability accumulation.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Spring Boot: 2.0.3/2.0.4 — released May 2018, EOL&lt;/li&gt;
&lt;li&gt;Spring Framework: 5.0.7 — released June 2018, EOL&lt;/li&gt;
&lt;li&gt;jjwt: 0.9.1 — released November 2018, unmaintained&lt;/li&gt;
&lt;li&gt;Java target: 1.8 — EOL for free Oracle support&lt;/li&gt;
&lt;li&gt;Dependencies: 8 manually pinned, no BOM
&lt;strong&gt;After:&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Spring Boot: 3.2.5 — current LTS, actively maintained&lt;/li&gt;
&lt;li&gt;Spring Framework: 6.1.x — current, actively maintained&lt;/li&gt;
&lt;li&gt;jjwt: 0.12.0 — current, actively maintained&lt;/li&gt;
&lt;li&gt;Java target: 17 — current LTS&lt;/li&gt;
&lt;li&gt;Dependencies: BOM managed, version alignment enforced
This isn't just about current CVEs. A project on EOL dependencies accumulates new CVEs constantly as researchers continue to find vulnerabilities in old versions that never receive patches. Moving to actively maintained versions means future CVE disclosures will have patches available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The dependency health score is a leading indicator&lt;/strong&gt; — it predicts future vulnerability accumulation, not just current state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metric 6: Mean Time to Remediate (Simulated)
&lt;/h2&gt;

&lt;p&gt;In a production environment, MTTR (Mean Time to Remediate) by severity is a standard AppSec programme metric. For this project I can simulate what it would have looked like:&lt;/p&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;Discovery Date&lt;/th&gt;
&lt;th&gt;Remediation Date&lt;/th&gt;
&lt;th&gt;MTTR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Critical (RCE)&lt;/td&gt;
&lt;td&gt;Day 1 — scan&lt;/td&gt;
&lt;td&gt;Day 3 — BOM upgrade&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Critical (Auth bypass)&lt;/td&gt;
&lt;td&gt;Day 1 — scan&lt;/td&gt;
&lt;td&gt;Day 3 — BOM upgrade&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (jjwt)&lt;/td&gt;
&lt;td&gt;Day 1 — scan&lt;/td&gt;
&lt;td&gt;Day 5 — jjwt migration&lt;/td&gt;
&lt;td&gt;4 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (Tomcat)&lt;/td&gt;
&lt;td&gt;Day 1 — scan&lt;/td&gt;
&lt;td&gt;Day 3 — BOM upgrade&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (MongoDB MitM)&lt;/td&gt;
&lt;td&gt;Day 1 — scan&lt;/td&gt;
&lt;td&gt;Day 7 — driver patch&lt;/td&gt;
&lt;td&gt;6 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Average MTTR for Critical: 2 days&lt;br&gt;
Average MTTR for High: 3 days&lt;br&gt;
Average MTTR for Medium: 6 days&lt;/p&gt;

&lt;p&gt;Industry benchmarks for production systems typically target Critical remediation within 24-72 hours and High within 7-14 days. This project would have met those benchmarks.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Dashboard View
&lt;/h2&gt;

&lt;p&gt;If you were presenting this to an engineering team or security leadership, here's how the story looks in dashboard format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SECURITY POSTURE SUMMARY — pgmpofu/mflix
Scan date: [date]
Previous scan: [date — before remediation]

FINDING SUMMARY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                Before    After    Change
Critical          10        0      -100%
High              99        0*     -100%
Medium            59        0*      -100%
Low               20        0*     -100%
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total            188        6*      -97%

*6 findings suppressed with documented justification
 All suppressions have expiry dates and review schedule

EXPLOIT EXPOSURE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Mature exploits:   8 → 0    (-100%)
Proof of concept: 43 → 0    (-100%)
No known exploit: 137 → 6   (-96%)

RESOLUTION BREAKDOWN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Fixed (BOM upgrade):          89  (47%)
Fixed (targeted):             43  (23%)
Suppressed (documented):      56  (30%)
Unaddressed:                   0   (0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What These Metrics Don't Measure
&lt;/h2&gt;

&lt;p&gt;Honest measurement includes acknowledging the gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-level vulnerabilities.&lt;/strong&gt; Snyk SCA scans dependencies, not your code. The metrics above say nothing about whether the application code itself has injection vulnerabilities, authentication bypasses, or insecure cryptography. That requires SAST — which is exactly what the SAST tool series was about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime behaviour.&lt;/strong&gt; A dependency can be vulnerable without being exploitable in a specific application's runtime configuration. The metrics above are conservative — they count vulnerabilities as present even when compensating controls exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New vulnerabilities.&lt;/strong&gt; The scan represents a point in time. New CVEs are disclosed daily. Without a recurring scan schedule and a process to act on new findings, the posture degrades over time. The metrics are only meaningful if they're refreshed regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MongoDB 4.x backlog.&lt;/strong&gt; The 6 tracked residual risk items from the MongoDB driver partial upgrade aren't reflected in these metrics. They're tracked separately and represent the honest incomplete work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Number That Actually Matters
&lt;/h2&gt;

&lt;p&gt;Of all the metrics in this article, one number is most important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0 Critical findings. 0 mature exploits.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything else — the 97% reduction, the fix/suppress breakdown, the priority score drop — supports that headline. But that's the number a CISO, a hiring manager, or a security audit cares about.&lt;/p&gt;

&lt;p&gt;You started with 10 Critical vulnerabilities including a Remote Code Execution at CVSS 9.8. You ended with zero. Every vulnerability with working public exploit code was remediated. The application went from a state where a knowledgeable attacker with public tools could trivially compromise it, to a state where exploitation requires novel research against theoretical vulnerabilities in unreachable code paths.&lt;/p&gt;

&lt;p&gt;That's what security posture improvement looks like in measurable terms.&lt;/p&gt;




&lt;p&gt;The remediated repository with full &lt;code&gt;.snyk&lt;/code&gt; suppression documentation 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;Final article: I used AI to help remediate vulnerabilities — here's how useful it actually was, where it added value, and where it confidently gave me wrong answers.&lt;/p&gt;

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