An OWASP Top 10 File Scanner with Live Malware Lookups
1. Two questions, one pass over the file
Most laptop scanners answer only one of two questions. A linter or SAST tool asks “does this code contain a risky pattern?” An antivirus engine asks “does this file match a known-bad signature?” This scanner asks both on the same pass, because the answers are complementary: a pattern match tells you the code you wrote is dangerous, and a hash match tells you the file you received is dangerous.
| Pattern engine | Online reputation | |
|---|---|---|
| Input | The file’s text content | The file’s SHA-256 hash (and package names) |
| Question | Does this code introduce an OWASP Top 10 weakness? | Has this exact file been seen as malware? |
| Runs | Fully offline, on-device | Sends only the hash to public APIs |
| Output | Severity-ranked findings by OWASP category | Detection ratios and known-sample flags |
2. The OWASP Top 10 pattern engine
The engine is a table of (category, severity, label, regex) rows — one or more per OWASP 2021
category. Keeping the rules as data rather than code means a new detection is one line, and the same table drives both the Bash
terminal scanner and the Python dashboard. A representative slice:
# (OWASP ID, severity, label, regex) PATTERNS = [ ("A01", "HIGH", "Path traversal", r"\.\./\.\./"), ("A02", "CRITICAL", "Hardcoded password", r"password\s*=\s*[\"'][^\"']{4,}[\"']"), ("A02", "CRITICAL", "Hardcoded secret or API key", r"(secret|api_key)\s*=\s*[\"'][^\"']{4,}[\"']"), ("A03", "CRITICAL", "SQL injection sink", r"execute\(.*%.*\)"), ("A04", "MEDIUM", "Insecure randomness", r"random\.random\("), ("A05", "CRITICAL", "AWS secret key", r"(aws_secret|AWS_SECRET)\s*="), ("A07", "HIGH", "Hardcoded bearer token", r"Bearer [A-Za-z0-9_.~+/-]{20,}"), ("A08", "HIGH", "Unsafe deserialization", r"pickle\.loads?\("), ("A09", "MEDIUM", "Password/secret in log", r"(log|print|logger)\s*.*(password|secret|api_key)"), ("A10", "HIGH", "SSRF / metadata endpoint", r"169\.254\.169\.254"), ] def scan_text(text: str): """Yield one finding per matching line, ordered as the file reads.""" for lineno, line in enumerate(text.splitlines(), 1): for owasp_id, severity, label, rx in PATTERNS: if re.search(rx, line): yield {"id": owasp_id, "severity": severity, "label": label, "line": lineno}
Full OWASP 2021 coverage in the scanner spans all ten categories:
| ID | Category | Examples detected |
|---|---|---|
| A01 | Broken Access Control | Admin bypass, path traversal, IDOR |
| A02 | Cryptographic Failures | Hardcoded secrets, weak hash (MD5/SHA1), private keys |
| A03 | Injection | SQL, command, LDAP, template, prompt injection |
| A04 | Insecure Design | Debug mode, insecure randomness |
| A05 | Security Misconfiguration | AWS keys, wildcard CORS, disabled SSL |
| A06 | Vulnerable Components | Outdated packages, unsafe pickle |
| A07 | Auth Failures | JWT none alg, hardcoded tokens, insecure cookies |
| A08 | Software & Data Integrity | Unvalidated deserialization, eval on external data |
| A09 | Logging Failures | Passwords/secrets in logs |
| A10 | SSRF | User-controlled URLs, metadata endpoint access |
execute("... %s ...") but cannot prove the argument is attacker-controlled. Treat findings as
“look here,” not “proven exploitable.”
3. Four reputation databases, hash-only
The second half of a scan is reputation. Every file is hashed once with SHA-256, and that hash — never the file — is checked against four services. Three need no API key at all; only VirusTotal requires one, and its free tier is generous (500 lookups/day).
| Database | What it answers | Key required |
|---|---|---|
| VirusTotal | File hash vs 70+ AV engines | Yes (free tier: 500/day) |
| CIRCL hashlookup | Is this hash known-good or known-bad? | No |
| MalwareBazaar | Is this hash a catalogued malware sample? | No |
| NIST NVD | Known CVEs for a package name | No |
The VirusTotal lookup is a single authenticated GET keyed on the hash. The response’s
last_analysis_stats gives the detection ratio directly:
import os, hashlib, urllib.request, urllib.error, json def sha256_file(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def virustotal(sha256: str): vt_key = os.environ.get("VIRUSTOTAL_API_KEY") if not vt_key: return {"source": "VIRUSTOTAL", "status": "UNKNOWN", "detail": "Set VIRUSTOTAL_API_KEY env var"} req = urllib.request.Request( f"https://www.virustotal.com/api/v3/files/{sha256}", headers={"x-apikey": vt_key, "User-Agent": "owasp-scanner/2.0"}) try: data = json.loads(urllib.request.urlopen(req, timeout=15).read()) stats = data["data"]["attributes"]["last_analysis_stats"] hits, total = stats["malicious"], sum(stats.values()) status = "MALICIOUS" if hits else "CLEAN" return {"source": "VIRUSTOTAL", "status": status, "detail": f"{hits}/{total} engines"} except urllib.error.HTTPError as e: if e.code == 404: # hash never submitted = not known-bad return {"source": "VIRUSTOTAL", "status": "UNKNOWN", "detail": "not in dataset"} raise
UNKNOWN rather than a green tick, so a novel sample is never
mislabelled as harmless. CIRCL and MalwareBazaar are queried the same hash-only way, and the NVD lookup takes a package name
via keywordSearch to surface known CVEs for a dependency.
4. The HTTPS localhost dashboard
The dashboard is a small Flask app that serves the same engine over a browser UI. It binds to localhost only and speaks TLS with a self-signed certificate, so scan results and any pasted content never cross the network in cleartext — even though they never leave the machine either. Findings are ranked by severity, and each file row carries its four reputation verdicts.
import os from flask import Flask, request, jsonify SSL_CERT = os.path.expanduser("~/.owasp_ssl/cert.pem") SSL_KEY = os.path.expanduser("~/.owasp_ssl/key.pem") app = Flask(__name__) @app.route("/scan", methods=["POST"]) def scan(): text = request.get_json(force=True).get("content", "") findings = sorted(scan_text(text), key=lambda f: ["CRITICAL", "HIGH", "MEDIUM", "LOW"].index(f["severity"])) return jsonify(findings=findings) if __name__ == "__main__": # bind to loopback only; never 0.0.0.0 app.run(host="127.0.0.1", port=8443, ssl_context=(SSL_CERT, SSL_KEY))
5. Setup and daily use
Two dependencies, one self-signed certificate, and an optional VirusTotal key:
brew install fswatch pip3 install flask # self-signed cert for the localhost dashboard mkdir -p ~/.owasp_ssl openssl req -x509 -newkey rsa:4096 \ -keyout ~/.owasp_ssl/key.pem \ -out ~/.owasp_ssl/cert.pem \ -days 365 -nodes -subj "/CN=localhost" # optional: enable VirusTotal lookups export VIRUSTOTAL_API_KEY=your_key_here
The Bash side installs as shell functions, so scanning is part of the prompt rather than a separate tool to remember:
source owasp_terminal_prompt.sh oscan <file> # scan a single file odir <path> # scan all code under a directory ogit [repo] # scan only git-changed files owatch # live watcher — auto-scans new files (fswatch) olog # show recent findings # HTTPS dashboard python3 owasp_dashboard.py # then open https://localhost:8443
The owatch mode is the one that changes habits: fswatch feeds new and changed files into the
engine as they land, so a secret pasted into a config or a malicious dependency dropped into a project is flagged at write
time rather than at some later audit.
6. What it is and is not
- It is a fast, auditable first line of defense: OWASP pattern hits plus a hash reputation check, on-device, with a clear severity order.
- It is not a full SAST platform or an antivirus engine — the regex layer has no data-flow analysis, and reputation only catches files that have already been seen elsewhere.
- It is private by construction: only a SHA-256 hash and package names are sent to public APIs; file contents stay on the laptop, and the dashboard is loopback-only over TLS.
References
- owasp-security-scanner — source for the scanner, dashboard, and terminal functions.
- OWASP Top 10 (2021) · OWASP Cheat Sheet Series.
- VirusTotal API v3 · CIRCL hashlookup · MalwareBazaar API · NIST NVD API.