175 lines
7.9 KiB
Python
175 lines
7.9 KiB
Python
|
|
#!/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()
|