Audit Your Code for Common Vulnerabilities
Learn how to audit your code for common vulnerabilities in this Security foundations tutorial. Hands-on steps, troubleshooting, and next lesson.
Focus: audit your code for common vulnerabilities
You've written clean code, reviewed it for readability, and refactored until your functions were tiny and your tests were green. But have you looked at your code the way an attacker does? The painful truth is that most vulnerabilities aren't exotic zero-days — they're simple developer oversights like SQL injection, hardcoded secrets, and unsafe deserialization lurking in the code you wrote last week. This lesson shows you how to systematically audit your code for common vulnerabilities, turning a vague worry into a repeatable checklist that will make your Python projects dramatically harder to break into.
The Problem This Lesson Solves
Most developers discover a vulnerability only after it's exploited — when a database is dumped on Pastebin or a hacker sends a tweet from your company's account. The cost of a post-exploitation fix is enormous: emergency patches, forensics, breached notifications, and a scarred reputation.
But here's the thing: the most common vulnerabilities aren't discovered by clever attackers — they're visible to anyone who reads the code. SQL injection happens because someone concatenated a string into a query instead of using a parameter. A hardcoded API key sits in a file committed to Git. A pickle.load() call processes untrusted bytes. These are the flaws that a simple, disciplined audit catches.
The problem this lesson solves is threefold:
- You don't know what to look for. Without a checklist, you skim your code the same way you wrote it — and you miss your own blind spots.
- You don't have a repeatable process. You might spot one bug in one place, but you can't guarantee you've covered your whole codebase.
- You can't prioritize what you find. Every issue feels critical, so you either fix nothing or fix everything — and neither is practical.
By the end of this lesson, you'll know exactly how to audit your code for common vulnerabilities using the C.I.A. lens (Confidentiality, Integrity, Availability) and a practical checklist, so you can fix the things that matter without drowning in false alarms.
Pro tip — Auditing isn't about being paranoid; it's about being methodical. A 20-minute walkthrough of a small script can prevent a data breach that would cost you weeks of damage control.
Core Concept: The Vulnerability Audit as a Mental Model
Think of your code as a house. You don't build a house and assume it's safe — you hire an inspector to check the locks, the wiring, and the foundation. A vulnerability audit is the code version of that home inspection. You walk through each room (your functions, services, and configuration) with a flashlight, looking for the common failure points that most attackers exploit.
The mental model rests on three simple questions, one for each letter of the C.I.A. triad:
- Confidentiality — Can an attacker read data they shouldn't? (e.g., leaked secrets, exposed database contents)
- Integrity — Can an attacker alter data or trick the code into doing something it shouldn't? (e.g., SQL injection, command injection)
- Availability — Can an attacker crash your app or make it unusable? (e.g., resource exhaustion, unbounded loops)
Each question maps to a set of vulnerability classes — known patterns that are easy to spot once you know what they look like. Here's the core checklist you'll use:
| C.I.A. pillar | Typical vulnerability | Where it hides |
|---|---|---|
| Confidentiality | Hardcoded secrets / credentials | Config files, source code history |
| Confidentiality | Insecure data storage (plaintext passwords) | Database code, models.py |
| Integrity | SQL / NoSQL injection | String-built queries |
| Integrity | Command injection | os.system(), subprocess with shell=True |
| Integrity | Unsafe deserialization | pickle, yaml.load on untrusted input |
| Availability | Unbounded resource consumption | File uploads, regex patterns, loops over user input |
This is your mental model: a vulnerability audit is a search for these specific classes, guided by the C.I.A. questions. You're not looking for exotic bugs — you're looking for the same recurring mistakes that account for most public breach reports.
Key insight — You can't audit what you can't see. Before you start, make a fresh clone of your repository and search your entire history for secrets — they're often hiding in past commits. Use
git log -S 'password' --allorgit log -pto find them.
How It Works, Step by Step: The Audit Process
The audit is a five-step process that you can apply to any codebase, from a 100-line script to a multi-service microservices app. It's designed to be fast, repeatable, and exhaustive.
Step 1: Inventory your attack surface
Before you look for vulnerabilities, you need to know what you're securing. List every place where your code accepts input from the outside world:
- HTTP request parameters and bodies (from a web framework)
- CLI arguments and environment variables
- File uploads and downloads
- Database queries and ORM operations
- Messages from a message queue
- Deserialization from JSON/XML/YAML/Pickle
- URLs that you fetch with
requests
Pro tip — Draw a diagram on paper: every external input is a line going into your system. Mark each one with a number. Your audit is a checklist against those numbered entry points.
Step 2: Scan for the top vulnerability classes
For each input point, run through the C.I.A. checklist:
- Injection (SQL/NoSQL/Command/HTML) — Is the input concatenated into a query, command, or template? Is there any escaping or parameterization?
- Secrets management — Any hardcoded passwords, API keys, tokens, or connection strings? Are they in environment variables or vaults?
- Authentication and authorization — Can an attacker bypass a login or access another user's data (
Insecure Direct Object Reference)? - Data validation — Are inputs length-checked, type-checked, and range-checked? Can a username be 10,000 characters long?
- Deserialization — Is
pickleoryaml.loadever called on data that didn't come from your own trusted code? - Resource exhaustion — Can an attacker cause you to allocate unbounded memory, CPU, or disk? (e.g., a zip bomb, a huge upload)
Step 3: Use automated tools to speed the scan
Automated tools find low-hanging fruit fast, but they have false positives and false negatives. Use them as a first pass, not the final answer.
bandit— Python-specific security linter that flags common issues (hardcoded passwords,pickle,eval, etc.)safetyorpip-audit— Checks your dependencies against known CVEsgit-secrets— Prevents secrets from being committed
Step 4: Manually review the critical paths
Automated tools won't understand your business logic. Manually trace through the most sensitive flows — user login, payment processing, file upload, admin actions. Ask: "If I were a malicious actor, how could I abuse this flow?"
Step 5: Prioritize and fix
Score each issue by three factors:
- Exploitability — How easy is it for an attacker to use?
- Impact — How bad is it if they do? (Data loss, full takeover, DoS)
- Effort to fix — Can you fix it in 5 minutes or 5 days?
Fix anything that is both easy to exploit and high-impact first. Low-impact issues can be backlogged.
Hands-On Walkthrough: Auditing a Flask App (and a Script)
Let's walk through a concrete example. Here's a small Flask web app with several common vulnerabilities. We'll audit it together, then fix them.
The vulnerable code
# app.py — VULNERABLE version
import sqlite3
import subprocess
import pickle
from flask import Flask, request, render_template_string, session
app = Flask(__name__)
app.secret_key = 'super-secret-key' # Hardcoded secret (VULN: hardcoded secret)
@app.route('/login')
def login():
username = request.args.get('username')
password = request.args.get('password')
# VULN: SQL injection — string concatenation
query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
conn = sqlite3.connect('users.db')
cursor = conn.execute(query)
row = cursor.fetchone()
if row:
return "Logged in!"
return "Login failed"
@app.route('/download')
def download():
filename = request.args.get('file')
# VULN: Command injection — os.system with shell=True
subprocess.call(f"cat {filename}", shell=True)
return "Done"
@app.route('/load')
def load():
data = request.args.get('data')
# VULN: Unsafe deserialization
obj = pickle.loads(bytes.fromhex(data))
return str(obj)
if __name__ == '__main__':
app.run()
Now apply the C.I.A. checklist:
- Confidentiality issue —
app.secret_keyis visible in the source code. Anyone who reads the repo can forge session cookies. - Integrity issue — The
loginfunction directly concatenatesusernameandpasswordinto a SQL query. An attacker can sendusername=admin' OR '1'='1, which makes the query return a row and bypasses authentication. That's full confidentiality and integrity breach. - Integrity/Confidentiality issue —
subprocess.call(f"cat {filename}", shell=True)lets an attacker passfile=file.txt; rm -rf /and execute arbitrary commands. That's command injection. - Integrity/Availability issue —
pickle.loadson user-supplied data is a classic remote code execution vector. A malicious pickle payload can execute any code on your server.
The fixed version
Here's how each vulnerability is resolved:
# app.py — FIXED version
import sqlite3
import subprocess
import os
from flask import Flask, request, render_template_string, session
app = Flask(__name__)
# FIX: Use environment variable for secret key, never hardcode it
app.secret_key = os.environ.get('SECRET_KEY')
@app.route('/login')
def login():
username = request.args.get('username')
password = request.args.get('password')
# FIX: Parameterized query prevents SQL injection
conn = sqlite3.connect('users.db')
cursor = conn.execute(
"SELECT * FROM users WHERE username=? AND password=?",
(username, password)
)
row = cursor.fetchone()
if row:
return "Logged in!"
return "Login failed"
@app.route('/download')
def download():
filename = request.args.get('file')
# FIX: Use subprocess with a list (no shell=True), and whitelist allowed files
allowed = {'report.txt', 'data.csv'}
if filename in allowed:
subprocess.run(['cat', filename], check=False)
else:
return "Invalid file"
return "Done"
@app.route('/load')
def load():
data = request.args.get('data')
# FIX: Never use pickle on untrusted data — use JSON instead
# Since the data is from a query param, it's untrusted. We'll just reject it.
return "Unsupported operation", 400
if __name__ == '__main__':
app.run()
Expected output after running python app.py and accessing the routes with the fixed code:
/login?username=admin&password=x→ returnsLogin failed(or whatever the DB has), but theOR '1'='1'payload returnsLogin failedbecause the parameterized query treats it as literal data, not SQL./download?file=report.txt→ prints content of report.txt;/download?file=../../etc/passwd→ returnsInvalid file./load?data=...→ returnsUnsupported operation.
Pro tip — Run a quick manual test after fixing: check the app still works for normal input. A security fix that breaks functionality is worse than the vulnerability you were trying to fix.
Auditing a standalone script
Not every project is a web app. Here's a quick script audit:
# process_data.py — VULNERABLE
import json
import os
def process_user_data(user_input):
# This is a CLI or API endpoint
data = json.loads(user_input)
# VULN: Unbounded data size — can be a huge JSON payload causing memory exhaustion
# VULN: No validation on nested keys
name = data['name']
# VULN: Path traversal — user-controlled file path
with open(name, 'r') as f:
content = f.read()
return content
Fixed:
# process_data.py — FIXED
import json
import os
def process_user_data(user_input):
# FIX: Limit input size before parsing
if len(user_input) > 10_000:
raise ValueError("Input too large")
data = json.loads(user_input)
# FIX: Validate that required key exists and is a string
if 'name' not in data or not isinstance(data['name'], str):
raise ValueError("Invalid 'name'")
name = data['name']
# FIX: Prevent path traversal by basename only
safe_path = os.path.join('/safe/directory', os.path.basename(name))
with open(safe_path, 'r') as f:
content = f.read()
return content
Expected output: for user_input='{"name":"../../etc/passwd"}', the fixed version raises ValueError: Invalid 'name' if it's not a string, or tries to open /safe/directory/passwd — not the system file.
Compare Options: When to Use What in Your Audit
The biggest decision in an audit is what tools and techniques to combine. Here's a comparison:
| Approach | Best for | Pros | Cons |
|---|---|---|---|
| Automated linters (bandit) | Fast scanning of common patterns | Catches repeated issues quickly, zero false negatives on known patterns | False positives, doesn't understand business logic, misses context |
| Dependency checkers (safety, pip-audit) | Known CVEs in third-party libraries | Directly addresses real-known vulnerabilities, easy to run | Only catches published vulnerabilities, not misconfigurations |
| Manual code review | Business logic flaws, authorization mistakes | Understands the flow, finds design-level issues | Time-consuming, error-prone |
| Secret scanning (git-secrets, trufflehog) | Preventing secrets in repo | Finds leaked credentials in history | Can miss secrets in images or encrypted files |
| Threat modeling (e.g., STRIDE) | High-level design review | Systematic, thinks like an attacker | Overkill for small apps |
When to choose what: For a small script, bandit + a 10-minute manual review usually suffices. For a production web service, you'll want bandit, pip-audit, git-secrets, and a thorough manual walkthrough of authentication, authorization, and data validation. For a large legacy system, add a proper threat modeling session.
Pro tip — Don't rely on a single tool. A vulnerability that a linter misses (like an incorrect access control check) is exactly the one an attacker will find. Always combine automated and manual audits.
Troubleshooting & Edge Cases
Common pitfalls during an audit
- I ran
banditand got 0 issues, but I know my code is vulnerable. -
banditonly catches known patterns. It won't catch a business-logic flaw like "any logged-in user can delete another user's account" — that's an IDOR. You must manually review authorization checks. -
I found a vulnerability in a dependency, but I can't upgrade because it breaks the app.
-
That's a real constraint. In this case, mitigate by adding a WAF rule, blocking the endpoint, or adding input validation. Document the exception and set a reminder to upgrade.
-
My SQL injection fix didn't work — the query still fails.
-
Double-check that you used the correct placeholder style for your database driver.
sqlite3uses?, butpsycopg2(PostgreSQL) uses%s, and MySQL connectors may use%sor?. Incorrect placeholders cause an error or don't parameterize. -
I used parameterized queries, but the values still cause errors.
-
Parameterization prevents injection, not type errors. If you pass a string to a query expecting an integer, it still fails. Add validation to ensure the input is the expected type.
-
My secret was already committed in Git history. Removing it from the current file isn't enough.
- You must rewrite history or use
git filter-repoto scrub it, then rotate the secret. Never just delete the line — attackers have likely already scraped the history.
Edge cases that break your audit
- Unsafe regex patterns — A regex like
(a+)+can cause ReDoS (Regular Expression Denial of Service). C.I.A.'s Availability pillar! Look for nested quantifiers on untrusted input. - Zip bombs — If you let users upload .zip files, a tiny file can expand to gigabytes. Starve your disk and cause a DoS. Always limit decompression size.
- Race conditions — Two requests can simultaneously modify the same resource, leading to integrity issues. Not easy to spot statically; load-test your critical flows.
What You Learned & What's Next
You've just leveled up from a developer who writes code to a developer who secures code. Here's what you can now do:
- Explain the core idea behind auditing: it's a repeatable checklist built on the C.I.A. triad (Confidentiality, Integrity, Availability), not a mystical talent.
- Apply the audit to your own code: inventory your attack surface, scan for the top vulnerability classes, use automated tools as a first pass, and manually trace critical business flows.
- Prioritize fixes by exploitability, impact, and effort — and know when to use an automated linter vs. a manual review.
- Troubleshoot common audit failures (false negatives, dependency upgrade conflicts, and leftover secrets in Git history).
You've completed the hands-on exercise of auditing a vulnerable Flask app — you found the SQL injection, the command injection, the unsafe deserialization, and the hardcoded secret, and you fixed each one with best practices.
What's next in the Security foundations track? The natural follow-up is "Writing secure code" — where you'll learn to bake these security practices into your coding workflow from the first keystroke, not as an afterthought. You'll cover secure authentication, encryption, and input validation as you write, so you don't need to audit as heavily later. If you're looking for a specific next lesson, check your learning path — you've built the foundational skill of recognizing vulnerabilities; now you'll learn to prevent them in real time.
Practice recap
Pick a small Python script you wrote for work or a personal project (under 500 lines). Run bandit -r . and note any issues it finds; then manually trace every external input through your code and ask the C.I.A. questions. Fix at least one vulnerability you find (e.g., switch to parameterized queries, use os.environ for secrets, or add a size limit on user input) and re-run your tests to make sure nothing broke.
Common mistakes
- Running
banditonce, seeing zero issues, and thinking you're safe — linters miss logic flaws like IDOR or missing authorization checks. - Failing to rotate a secret after removing it from code — it's still in Git history, and attackers have already scraped it.
- Using parameterized queries but with the wrong placeholder syntax for your database (using
?in PostgreSQL where%sis needed) — it either errors or doesn't protect you. - Forgetting that user input can be huge — a 100MB JSON upload can exhaust memory even if you validate the content.
- Bypassing the audit because "the app is internal only" — internal apps are still one phishing attack away from being exposed to outsiders.
Variations
- For a more formal threat model, use the STRIDE methodology (Spoofing, Tampering, Repudiation, Information Disclosure, DoS, Elevation of Privilege) to systematically walk through each category.
- For large legacy codebases, try a two-phase audit: automated scanning (bandit, pip-audit) first, then a manual review focused on authentication and authorization flows.
- For microservices, audit each service's API gateway and inter-service calls separately, and use mTLS to secure communication between services.
Real-world use cases
- An e-commerce startup discovers a SQL injection in its product search endpoint during a pre-launch audit, avoiding a data breach that would have exposed customer credit cards.
- A DevOps engineer runs
git-secretson a CI pipeline and catches an AWS access key committed in a test file, rotating it before the repo becomes public. - A solo developer audits a Flask blog and replaces
picklewith JSON for session data, closing a remote code execution vulnerability before a security researcher finds it.
Key takeaways
- A vulnerability audit is a repeatable checklist built on the C.I.A. triad — Confidentiality, Integrity, Availability — not an artistic skill.
- Inventory your attack surface first: every external input (web requests, files, CLI args) is a potential entry point.
- Run automated tools like
banditandpip-auditas a first pass, but manually review business logic for flaws the tools can't see. - Parameterized queries, no
shell=True, and avoidingpickleon untrusted data are three quick wins that eliminate half of common vulnerabilities. - Priority of fixes: exploitability * impact / effort — fix easy high-impact issues first, and don't ignore secrets that were committed in the past.
- Secure coding starts at the keyboard, not after the fact — the next lesson will teach you to write secure code from the first draft.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.