From eec2ab2642e37e3e8df19cebed03d4e51066fb35 Mon Sep 17 00:00:00 2001 From: STEP35 Date: Sun, 26 Apr 2026 02:54:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20security=20linter=20(#158)=20?= =?UTF-8?q?=E2=80=94=209.4:=20Security=20Linter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/security_linter.py: standalone CLI that scans Python code for common security vulnerabilities with severity ratings (CRITICAL/HIGH/ MEDIUM/LOW). Outputs JSON report by default, Markdown optional. Checks include: eval/exec, subprocess shell=True, pickle, yaml.load, hardcoded secrets, weak hashes, SQL injection patterns, and dynamic imports. Add scripts/test_security_linter.py: pytest test suite validating core detection patterns and report generation. This implements the smallest concrete fix to satisfy the acceptance criteria: runs security linters, reports findings with severity, outputs security lint report. Closes #158 --- scripts/security_linter.py | 174 ++++++++++++++++++++++++++++++++ scripts/test_security_linter.py | 95 +++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 scripts/security_linter.py create mode 100644 scripts/test_security_linter.py diff --git a/scripts/security_linter.py b/scripts/security_linter.py new file mode 100644 index 0000000..079ac75 --- /dev/null +++ b/scripts/security_linter.py @@ -0,0 +1,174 @@ +#!/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() diff --git a/scripts/test_security_linter.py b/scripts/test_security_linter.py new file mode 100644 index 0000000..e1e35aa --- /dev/null +++ b/scripts/test_security_linter.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Tests for scripts/security_linter.py — Issue #158: 9.4 Security Linter.""" + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from security_linter import ( + scan_file, + scan_directory, + generate_json_report, + generate_markdown_report, + SEVERITY_CRITICAL, + SEVERITY_HIGH, + SEVERITY_MEDIUM, + SEVERITY_LOW, +) + + +def test_scan_file_detects_eval(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("result = eval(user_input)\n") + f.flush() + findings = scan_file(Path(f.name)) + assert len(findings) >= 1 + assert findings[0].severity == SEVERITY_CRITICAL + assert "eval" in findings[0].issue.lower() + + +def test_scan_file_detects_hardcoded_password(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("password = 'supersecret123'\n") + f.flush() + findings = scan_file(Path(f.name)) + assert any(f.severity == SEVERITY_HIGH for f in findings) + + +def test_scan_file_detects_subprocess_shell_true(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("subprocess.run(cmd, shell=True)\n") + f.flush() + findings = scan_file(Path(f.name)) + assert any(f.severity == SEVERITY_HIGH and "shell" in f.issue.lower() for f in findings) + + +def test_scan_file_detects_pickle(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("data = pickle.loads(raw)\n") + f.flush() + findings = scan_file(Path(f.name)) + assert any(f.severity == SEVERITY_HIGH and "pickle" in f.issue.lower() for f in findings) + + +def test_scan_file_detects_yaml_load(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("config = yaml.load(stream)\n") + f.flush() + findings = scan_file(Path(f.name)) + assert any("yaml.load" in f.issue.lower() for f in findings) + + +def test_json_report_structure(): + from security_linter import SecurityFinding + findings = [ + SecurityFinding("foo.py", 1, "eval() used", SEVERITY_CRITICAL, "CWE-95", "Use ast.literal_eval"), + SecurityFinding("bar.py", 10, "hardcoded password", SEVERITY_HIGH, "CWE-259", None), + ] + report = generate_json_report(findings) + assert "security_scan" in report + assert report["security_scan"]["total_findings"] == 2 + assert report["security_scan"]["by_severity"][SEVERITY_CRITICAL] == 1 + assert report["security_scan"]["by_severity"][SEVERITY_HIGH] == 1 + + +def test_markdown_report_contains_severity(): + from security_linter import SecurityFinding + findings = [ + SecurityFinding("test.py", 1, "eval() used", SEVERITY_CRITICAL, "CWE-95", "Use ast.literal_eval"), + ] + md = generate_markdown_report(findings) + assert "CRITICAL" in md or "🔴" in md + assert "eval() used" in md + assert "CWE-95" in md + + +def test_scan_directory_empty_dir(): + with tempfile.TemporaryDirectory() as tmpdir: + findings = scan_directory(Path(tmpdir)) + assert findings == [] + + +def test_scan_file_no_issues(): + safe_code = \ No newline at end of file