Building an OWASP 2025 Security Scanner in 48 Hours
A weekend project turned into discovering critical security flaws in production code, including authentication bypasses hiding in plain sight.
Join the DZone community and get the full member experience.
Join For FreeOWASP dropped its 2025 Top 10 on November 6th with a brand-new category nobody saw coming: "Mishandling of Exceptional Conditions" (A10). I spent a weekend building a scanner to detect these issues and immediately found authentication bypasses in three different production codebases. The most common pattern? return True in exception handlers, effectively granting access whenever the auth service hiccups. This article walks through building the scanner, what I found, and why this matters way more than you think.
Friday Night: OWASP Releases Something Interesting
I was scrolling through Twitter when I saw the OWASP announcement. They'd just released the 2025 Top 10 list at the Global AppSec Conference. Most people were talking about Supply Chain Security moving up to #3, but something else caught my eye.
There was a completely new category at #10: Mishandling of Exceptional Conditions.
Now, I've been reviewing code for long enough to know that exception handling is where bugs hide. But a whole OWASP category dedicated to it? That's new. I downloaded the spec, and one sentence jumped out:
"Applications that return truthy values or grant access when exceptions occur create critical security vulnerabilities that are nearly impossible to detect through traditional testing."
I knew exactly what they were talking about. Let me show you the pattern that's probably in your codebase right now:
def is_admin(user_id):
try:
user = database.get_user(user_id)
return user.role == 'admin'
except:
return True # "Just for testing, I'll fix this later"
See that return True? That's a fail-open vulnerability. When the database connection fails (and in microservices architectures, connections fail constantly), this function grants admin access to anyone.
I decided to spend the weekend building a scanner to find these patterns. By Sunday evening, I had a working tool and some genuinely surprising results.
The 48-Hour Build Timeline
Friday 11 PM: Research Phase
Downloaded the OWASP 2025 spec. Read through the CWE mappings for A10. Four critical patterns stood out: CWE-636 (fail-open), CWE-209 (info disclosure), CWE-252 (unchecked returns), and CWE-755 (improper exception handling).
Saturday 9 AM: Architecture Design
Decided on Python because the ast module gives you proper Abstract Syntax Tree parsing. This eliminates 90% of false positives compared to regex-based scanning. Drew up a simple pipeline: parse → detect → analyze → report.
Saturday 2 PM: First Working Detector
Got the fail-open detector working. Tested it on some old projects and immediately found three instances in code I'd written myself. That was humbling.
Saturday 8 PM: Multi-Language Support
Added regex patterns for JavaScript, Java, and Go. Not as accurate as AST parsing, but catches the obvious cases. Started thinking about reporting formats.
Sunday 10 AM: Beautiful Reports
Built an HTML report generator with embedded CSS. Nobody reads plain text security reports, but they'll look at a color-coded dashboard.
Sunday 6 PM: CI/CD Integration
Added SARIF output for GitHub Security tab integration. Wrote a GitHub Actions workflow. Now it can actually prevent vulnerable code from being merged.
The Technical Deep Dive: How AST Parsing Catches What Regex Misses
Here's why using Python's Abstract Syntax Tree is crucial. Consider this code:
# Regex would flag this as vulnerable
def validate_input(data):
"""
Old implementation used to return True on exception.
Don't do that! It's a CWE-636 vulnerability.
"""
try:
schema.validate(data)
return True
except ValidationError:
return False # Correctly failing closed
A regex search for return True inside a try/except block would flag this as vulnerable because of the comment. AST parsing understands that the comment isn't executable code.
Here's the actual implementation:
import ast
def detect_fail_open(file_path, content):
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.Try):
# Found a try/except block
for handler in node.handlers:
# Check each except handler
for stmt in handler.body:
if isinstance(stmt, ast.Return):
# Found a return statement in an except block
if isinstance(stmt.value, ast.Constant):
if stmt.value.value is True:
# CRITICAL: Returns True on exception
return create_finding(
severity="CRITICAL",
cwe="CWE-636",
line=stmt.lineno,
message="Fail-open vulnerability detected"
)
This code understands the structure of your program, not just text patterns. It walks the syntax tree, finds Try nodes, examines exception handlers, looks for Return statements, and checks if they're returning True.
What I Found: Real Vulnerabilities in Real Code
I tested the scanner on three different codebases where I had permission to scan. Here's what turned up:

Finding #1: The Authentication Bypass That Lived for 18 Months
This one was in a payment processing service. Here's the actual code (with names changed):
def verify_transaction_signature(transaction):
"""Verify the cryptographic signature on a transaction"""
try:
public_key = get_merchant_public_key(transaction.merchant_id)
signature = base64.b64decode(transaction.signature)
# Verify signature
public_key.verify(signature, transaction.data)
return True
except Exception as e:
# TODO: Log this properly
return True # Let it through for now
This code had been in production for 18 months. When the key service was unreachable (network partition, service restart, etc.), it approved every transaction. The comment says "for now," but it shipped, and nobody remembered to fix it.
Impact Analysis: During a 15-minute outage of the key service in August, this vulnerability would have allowed unauthorized transactions. They got lucky — no one tried to exploit it. But "we got lucky" is not a security strategy.
Finding #2: Information Disclosure in Error Messages
This pattern showed up in 12 different files across one codebase:
@app.route('/api/user/<user_id>')
def get_user_details(user_id):
try:
user = User.query.get(user_id)
return jsonify(user.to_dict())
except Exception as e:
return jsonify({
'error': str(e),
'traceback': traceback.format_exc()
}), 500
Every time this endpoint threw an exception, it sent a complete stack trace to the client. This included:
- Database connection strings (with redacted passwords, but still)
- Internal server paths
- Library versions
- Query structures
An attacker could use this to map the entire internal architecture.
Finding #3: The Audit Log That Silently Failed
This one is subtle but nasty:
def process_admin_action(user, action, resource):
# Perform the action
result = action.execute(resource)
# Log it
try:
audit_log.write({
'user': user.id,
'action': action.name,
'resource': resource.id,
'timestamp': datetime.now()
})
except:
pass # Don't let logging failures break admin actions
return result
The logic seems reasonable: don't let logging failures prevent admin actions from completing. But here's what actually happened:
The audit database ran out of disk space. For six hours, every admin action was completed successfully, but nothing was logged. By the time someone noticed, there was a gap in the audit trail during a period when sensitive data was accessed.
The correct fix? Alert when audit logging fails:
try:
audit_log.write(event)
except Exception as e:
# This is CRITICAL - audit failures must be visible
logger.critical(f"AUDIT FAILURE: {e}")
alert_security_team(e)
# Still return success, but make noise about it
The Pattern Everyone Misses: Database Timeouts
Here's something I noticed across all three codebases: developers test the happy path and the obvious failure cases, but nobody tests what happens when the database times out.

Your tests pass. Your code review passes. Everything looks good. Then, in production, a network blip happens during authentication, the exception handler runs, and suddenly you're granting access.
Why This Is Hard to Catch (And Why We Need Automated Tools)
Let me show you why traditional security testing misses fail-open vulnerabilities:

The problem is that fail-open vulnerabilities only manifest under specific failure conditions. Your auth service works fine in staging. It works fine in production 99.9% of the time. But that 0.1% when it doesn't? That's when the vulnerability activates.
Building the Detector: The Technical Approach
The core challenge was identifying return True statements that actually matter. Not every return True in an exception handler is dangerous. For example:
def is_valid_email_format(email):
"""Check if email has valid format (not authentication!)"""
try:
validate_email_format(email)
return True
except FormatError:
return False # This is fine - just validation
This is perfectly safe because it's just format validation, not security. The scanner needs to understand context.
I used several heuristics:
- Function name analysis: Functions with names like
check_permission,verify_*,is_admin,authenticateare flagged as high-priority - Return value context:
return Trueis more suspicious thanreturn Falsein security contexts - Exception type: Catching
Exceptionor bareexcept:is more dangerous than catching specific exceptions - Surrounding code: Database calls or API calls in the try block increase the risk
Here's the prioritization logic:

The Results Dashboard: Making Security Visible
Nobody reads text-only security reports. I learned this the hard way at my last job when I sent a 50-page PDF of findings and got back "looks good, thanks!"
So I built a visual dashboard. Here's what it shows:
╔══════════════════════════════════════════════════════════════╗
║ SECURITY SCAN REPORT ║
║ OWASP Top 10 2025 Scanner ║
╚══════════════════════════════════════════════════════════════╝
SUMMARY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Files Scanned: 312
Scan Duration: 2.3s
Total Issues: 23
CRITICAL: 8 (Immediate action required)
HIGH: 12 (Fix before next release)
MEDIUM: 3 (Address in backlog)
ISSUES BY CATEGORY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
A02: Security Misconfiguration █████████ 12
A10: Exception Handling ████████ 8
A04: Cryptographic Failures ██ 3
TOP 5 CRITICAL ISSUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Fail-Open Auth Check src/auth.py:45 CWE-636
2. Hardcoded API Key config.py:12 CWE-798
3. Fail-Open Admin Check admin/views.py:89 CWE-636
4. Database Password in Code settings.py:34 CWE-798
5. Info Disclosure in Error api/handlers.py:67 CWE-209
This gets attention. People actually read it and ask questions.
CI/CD Integration: Preventing Future Vulnerabilities
The scanner is only useful if it's in your pipeline. I built a GitHub Actions workflow that runs on every pull request:
name: Security Scan
on: [pull_request]
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run OWASP 2025 Scanner
run: |
pip install owasp-scanner-2025
owasp-scan . \
--categories A02,A10 \
--fail-on critical \
--formats sarif,json
- name: Upload results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: security-reports/*.sarif
- name: Comment on PR
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Security scan found critical issues. Check the Security tab for details.'
})
The --fail-on critical flag means the PR can't be merged if critical issues are found. This prevents the "I'll fix it later" problem.
Lessons Learned: What Surprised Me
After scanning dozens of repositories, some patterns emerged that I didn't expect:
1. Fail-Open Is More Common Than You Think
I expected to find maybe one or two instances. Instead, I found fail-open patterns in roughly 15% of codebases. They cluster around certain types of operations:
- Authentication and authorization (most dangerous)
- Rate limiting ("let it through if Redis is down")
- Feature flags ("default to enabled on error")
- Payment verification ("process it anyway")
2. The Comments Tell a Story
Almost every fail-open vulnerability had a comment nearby:
- "TODO: Fix this properly"
- "Temporary workaround"
- "Just for testing"
- "Fix before production" (it was in production)
These were all well-intentioned temporary fixes that became permanent.
3. Nobody Tests the Failure Cases
I looked at the test suites for several projects. They had excellent coverage of the happy path. But almost zero tests for "what happens when the database is unreachable."
The reason? It's hard to test. You need to mock network failures, database timeouts, and service unavailability. Most test suites aren't set up for that level of chaos engineering.
4. Senior Developers Write This Code Too
This isn't a junior developer problem. I found fail-open patterns in code written by senior engineers, architects, and even security-focused developers. The issue isn't skill level - it's visibility. Exception handlers are usually written last, often during debugging, and don't get the same scrutiny as main logic paths.
The Fix: How to Write Exception Handlers Correctly
Here's the pattern I recommend for security-critical exception handling:
###WRONG: Fail-Open####
def check_permission(user, resource):
try:
return auth_service.verify(user, resource)
except Exception:
return True
####CORRECT: Fail-Closed####
def check_permission(user, resource):
try:
return auth_service.verify(user, resource)
except Exception as e:
logger.error(f"Auth failed: {e}", extra={
'user_id': user.id,
'resource': resource.id
})
metrics.increment('auth.errors')
return False # Deny by default
The key principles:
- Fail closed: Default to the secure option (deny access, block request, etc.)
- Log it: Make failures visible so you know when systems are failing
- Monitor it: Increment metrics so you can alert on error spikes
- Be specific: Catch specific exceptions when possible, not broad
Exception
What's Next: The Roadmap
The scanner works, but there's room for improvement:
Planned Features
- JavaScript/TypeScript AST parsing – Currently using regex for JS, want full AST analysis
- Machine learning for false positive reduction – Train on labeled examples to improve accuracy
- Runtime monitoring integration – Hook into APM tools to catch exceptions that only occur in production
- Fix suggestions – Not just "this is wrong" but "here's the correct code"
- Severity customization – Let teams define their own risk thresholds
Try It Yourself
The scanner is open source and available now. Here's how to use it:
# Install
pip install owasp-scanner-2025
# Scan your codebase
owasp-scan /path/to/your/project
# Focus on critical issues only
owasp-scan . --severity critical,high
# Generate a report
owasp-scan . --formats html,json --output ./reports
# Use in CI/CD (fail build on critical issues)
owasp-scan . --fail-on critical
GitHub repository: github.com/dinesh-k-elumalai/owasp-scanner-2025
Final Thoughts: Exception Handling Is Security
Here's what this weekend project taught me: we've been thinking about exception handling wrong.
We treat it as error recovery - a way to gracefully handle failures and keep the application running. And that's true. But in security-critical code paths, exception handlers are part of your security boundary.
When you write return True in an exception handler, you're not just handling an error. You're making a security decision: what happens when authentication fails, when the crypto library throws an exception, when the rate limiter can't reach Redis.
OWASP's decision to add "Mishandling of Exceptional Conditions" as a standalone category recognizes this reality. In modern distributed systems with dozens of microservices, network calls to external APIs, and cloud service dependencies, failures are the norm, not the exception.
Your exception handlers will run in production. Probably more often than you think. The question is: when they do, will they make the secure choice?
Key Takeaways
- Fail-open patterns (CWE-636) are more common than anyone realizes
- Traditional testing misses these vulnerabilities because they only manifest during failures
- AST-based static analysis can catch these issues before they reach production
- Exception handlers in security-critical code need the same scrutiny as main logic paths
- The secure default is always "deny" - fail closed, not open
If you take away one thing from this article, make it this: next time you write an exception handler in authentication, authorization, or any security-critical code, ask yourself: "If this exception actually fires in production, am I granting access or denying it?"
Because somewhere in your codebase, there's probably a return True that shouldn't be there.
Opinions expressed by DZone contributors are their own.
Comments