#!/usr/bin/env python3 """ security_linter.py — Scan code for security vulnerabilities. Reports security findings with severity ratings (CRITICAL/HIGH/MEDIUM/LOW). Outputs a JSON security lint report. Usage: python3 security_linter.py --path . python3 security_linter.py --path . --output security_report.json python3 security_linter.py --path . --format json # default python3 security_linter.py --path . --format markdown """ import argparse import json import re import sys from pathlib import Path from typing import List, Dict, Any, Optional SEVERITY_CRITICAL = "CRITICAL" SEVERITY_HIGH = "HIGH" SEVERITY_MEDIUM = "MEDIUM" SEVERITY_LOW = "LOW" class SecurityFinding: """Represents a security finding.""" def __init__( self, file: str, line: int, issue: str, severity: str, cwe: Optional[str] = None, recommendation: Optional[str] = None, ): self.file = file self.line = line self.issue = issue self.severity = severity self.cwe = cwe self.recommendation = recommendation def to_dict(self) -> Dict[str, Any]: return { "file": self.file, "line": self.line, "issue": self.issue, "severity": self.severity, "cwe": self.cwe, "recommendation": self.recommendation, } # Pattern entries: (pattern_regex, description, severity, cwe, recommendation) # Pattern strings use normal strings (not raw) to allow ['"] character classes without # backslash-injection issues. \s and \b are escaped to give \s and \b in the actual regex. SECURITY_PATTERNS = [ # eval/exec - arbitrary code execution (r"\beval\s*\(", "Use of eval() - arbitrary code execution risk", SEVERITY_CRITICAL, "CWE-95", "Replace with ast.literal_eval() or a safer alternative"), (r"\bexec\s*\(", "Use of exec() - arbitrary code execution risk", SEVERITY_CRITICAL, "CWE-95", "Refactor to avoid exec(); use functions or config files"), # subprocess with shell=True (r"subprocess\.(?:run|call|check_output|Popen)\s*\([^)]*shell\s*=\s*True", "subprocess with shell=True - shell injection risk", SEVERITY_HIGH, "CWE-78", "Use shell=False and pass command as a list"), # pickle.loads - arbitrary code execution (r"pickle\.loads?\s*\(", "Use of pickle - arbitrary code execution on untrusted data", SEVERITY_HIGH, "CWE-502", "Use json or a safe serialization format for untrusted data"), # yaml.load without Loader (r"yaml\.load\s*\(", "yaml.load() - unsafe deserialization", SEVERITY_HIGH, "CWE-502", "Use yaml.safe_load()"), # tempfile.mktemp - insecure temp file creation (r"tempfile\.mktemp\s*\(", "tempfile.mktemp() - insecure temporary file creation", SEVERITY_MEDIUM, "CWE-377", "Use tempfile.NamedTemporaryFile or TemporaryDirectory"), # random module for crypto (r"\brandom\.(?:random|randint|choice|shuffle)\b", "random module used for security/cryptographic purposes", SEVERITY_MEDIUM, "CWE-338", "Use secrets module for cryptographic randomness"), # md5 or sha1 for security (r"hashlib\.(?:md5|sha1)\s*\(", "Weak hash function (MD5/SHA1) used for security/crypto", SEVERITY_MEDIUM, "CWE-327", "Use SHA-256 or better for cryptographic purposes"), # hardcoded password patterns - single or double quote char class, >=4 content chars ('[\'"][^\'"]{4,}[\'"]', "Hardcoded password detected", SEVERITY_HIGH, "CWE-259", "Use environment variables or a secrets manager"), ('[\'"][^\'"]{6,}[\'"]', "Hardcoded API key or secret detected", SEVERITY_HIGH, "CWE-798", "Use environment variables or a secrets vault"), # SQL injection patterns - parentheses balanced (r"cursor\.execute\s*\([^)]*\)", "Potential SQL injection - inspect query construction", SEVERITY_HIGH, "CWE-89", "Use parameterized queries with placeholders"), # assert used for security validation (r"\bassert\s+[^,)]*\b(?:password|token|secret|permission|auth|admin)\b", "assert used for security validation - can be disabled with -O", SEVERITY_MEDIUM, "CWE-253", "Use explicit if/raise for security checks; assert can be stripped"), # __import__ dynamic (r"__import__\s*\(", "Dynamic import via __import__ - potential code injection", SEVERITY_MEDIUM, "CWE-829", "Use importlib.import_module with validated module names"), ] def scan_file(path: Path) -> List[SecurityFinding]: findings = [] try: with open(path, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() except (OSError, UnicodeDecodeError): return findings for line_num, line in enumerate(lines, start=1): for pattern, issue, severity, cwe, recommendation in SECURITY_PATTERNS: if re.search(pattern, line): findings.append( SecurityFinding( file=str(path), line=line_num, issue=issue, severity=severity, cwe=cwe, recommendation=recommendation, ) ) return findings def scan_directory(path: Path, extensions=None) -> List[SecurityFinding]: if extensions is None: extensions = {".py"} findings = [] if not path.exists(): raise FileNotFoundError(f"Path not found: {path}") for file_path in path.rglob("*"): if file_path.is_file() and file_path.suffix in extensions: findings.extend(scan_file(file_path)) return findings def generate_json_report(findings: List[SecurityFinding]) -> Dict[str, Any]: by_severity = {SEVERITY_CRITICAL: [], SEVERITY_HIGH: [], SEVERITY_MEDIUM: [], SEVERITY_LOW: []} for f in findings: by_severity[f.severity].append(f.to_dict()) severity_counts = {s: len(v) for s, v in by_severity.items()} total = sum(severity_counts.values()) return {"security_scan": {"total_findings": total, "by_severity": severity_counts, "findings": [f.to_dict() for f in findings]}} def generate_markdown_report(findings: List[SecurityFinding]) -> str: by_severity = {SEVERITY_CRITICAL: [], SEVERITY_HIGH: [], SEVERITY_MEDIUM: [], SEVERITY_LOW: []} for f in findings: by_severity[f.severity].append(f) emoji = {SEVERITY_CRITICAL: "🔴", SEVERITY_HIGH: "🟠", SEVERITY_MEDIUM: "🟡", SEVERITY_LOW: "🟢"} lines = ["# Security Lint Report\n", f"Total findings: **{len(findings)}**\n\n"] has_findings = False for severity in [SEVERITY_CRITICAL, SEVERITY_HIGH, SEVERITY_MEDIUM, SEVERITY_LOW]: flist = by_severity[severity] if flist: has_findings = True lines.append(f"## {emoji[severity]} {severity} ({len(flist)} findings)\n") for f in flist: lines.append(f"- **{f.file}:{f.line}** — {f.issue}") lines.append("") if not has_findings: lines.append("✅ No security issues found.\n") return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="Scan code for security vulnerabilities") parser.add_argument("--path", type=Path, default=Path("."), help="Path to scan (file or directory)") parser.add_argument("--output", "-o", type=Path, default=None, help="Output file") parser.add_argument("--format", choices=["json", "markdown"], default="json", help="Output format (default: json)") parser.add_argument("--extensions", type=str, default=".py", help="Comma-separated file extensions (default: .py)") args = parser.parse_args() exts = {e.strip() for e in args.extensions.split(",")} findings = scan_directory(args.path, extensions=exts) output = json.dumps(generate_json_report(findings), indent=2) if args.format == "json" else generate_markdown_report(findings) if args.output: args.output.write_text(output, encoding="utf-8") else: print(output) bad = sum(1 for f in findings if f.severity in (SEVERITY_CRITICAL, SEVERITY_HIGH)) sys.exit(1 if bad > 0 else 0) if __name__ == "__main__": main()