capscore-agent / api /app /workers /security_scanner.py
mathurinacheisoft's picture
Deploy CAPScore (clean recreate)
2a7f679 verified
Raw
History Blame Contribute Delete
6.46 kB
from __future__ import annotations
import asyncio
import re
import tempfile
import os
import shutil
from pathlib import Path
from typing import List
import subprocess
from ..models import SecurityScanResult, SecurityFinding
SECRET_PATTERNS = [
(r'sk-[A-Za-z0-9]{32,}', "OpenAI API key", "critical"),
(r'AKIA[0-9A-Z]{16}', "AWS Access Key ID", "critical"),
(r'ghp_[A-Za-z0-9]{36}', "GitHub Personal Access Token", "critical"),
(r'croo_sk_[A-Za-z0-9]{20,}', "CROO SDK Key", "critical"),
(r'sk-ant-api\d{2}-[A-Za-z0-9\-_]{93,}', "Anthropic API key", "critical"),
(r'(?i)password\s*=\s*["\'][^"\']{8,}["\']', "Hardcoded password", "high"),
(r'(?i)secret\s*=\s*["\'][^"\']{8,}["\']', "Hardcoded secret", "high"),
(r'(?i)api_key\s*=\s*["\'][^"\']{8,}["\']', "Hardcoded API key", "high"),
(r'(?i)private_key\s*=\s*["\'][^"\']{8,}["\']', "Hardcoded private key", "critical"),
(r'0x[a-fA-F0-9]{64}', "Possible private key hex", "high"),
]
DANGEROUS_PATTERNS = [
(r'curl\s+.*\|\s*(?:bash|sh)', "curl pipe to shell"),
(r'eval\(.*input', "eval() on user input"),
(r'exec\(.*request', "exec() on request data"),
(r'os\.system\(.*input', "os.system() on user input"),
(r'subprocess\..*shell\s*=\s*True', "subprocess with shell=True"),
(r'__import__.*input', "__import__() on user input"),
]
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist", "build"}
SKIP_EXTENSIONS = {".pyc", ".jpg", ".png", ".gif", ".ico", ".woff", ".ttf", ".mp4"}
def _should_skip(path: Path) -> bool:
return (
any(part in SKIP_DIRS for part in path.parts) or
path.suffix.lower() in SKIP_EXTENSIONS or
path.stat().st_size > 1_000_000 # skip files > 1MB
)
async def scan_security(github_url: str) -> SecurityScanResult:
tmpdir = None
findings: List[SecurityFinding] = []
dangerous_patterns_found: List[str] = []
unpinned_deps: List[str] = []
try:
tmpdir = tempfile.mkdtemp(prefix="capscore_sec_")
repo_path = Path(tmpdir) / "repo"
proc = await asyncio.create_subprocess_exec(
"git", "clone", "--depth", "1", github_url, str(repo_path),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
if not repo_path.exists():
return SecurityScanResult(
risk_level="low",
score=100.0,
findings=[],
dangerous_patterns=["Could not clone for security scan"],
)
# Scan files
for file_path in repo_path.rglob("*"):
if not file_path.is_file() or _should_skip(file_path):
continue
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
relative = str(file_path.relative_to(repo_path))
# Skip .env files (expected to have secrets) but flag committed .env
is_env_file = file_path.name in {".env", ".env.local", ".env.production"}
if is_env_file and not file_path.name.endswith(".example"):
findings.append(SecurityFinding(
file=relative, line=0,
pattern=".env file committed",
severity="critical",
description=f"Committed .env file found: {relative}. Remove from git history.",
))
for pattern, description, severity in SECRET_PATTERNS:
for i, line in enumerate(content.split("\n"), 1):
if re.search(pattern, line):
# Skip if it's clearly a placeholder
if any(p in line.lower() for p in ["your_key", "xxx", "placeholder", "example", "changeme", "<"]):
continue
findings.append(SecurityFinding(
file=relative, line=i,
pattern=description,
severity=severity,
description=f"Possible {description} found at {relative}:{i}",
))
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
dangerous_patterns_found.append(f"{description} in {relative}")
except Exception:
pass
# Check for unpinned dependencies
req_file = repo_path / "requirements.txt"
if req_file.exists():
for line in req_file.read_text().split("\n"):
line = line.strip()
if line and not line.startswith("#") and ">=" not in line and "==" not in line and "~=" not in line:
if line and not line.startswith("-"):
unpinned_deps.append(line)
# Determine risk level
has_critical = any(f.severity == "critical" for f in findings)
has_high = any(f.severity == "high" for f in findings)
if has_critical or len(dangerous_patterns_found) > 2:
risk_level = "critical"
elif has_high or dangerous_patterns_found:
risk_level = "high"
elif findings:
risk_level = "medium"
else:
risk_level = "low"
# Score: higher = safer
deductions = len([f for f in findings if f.severity == "critical"]) * 30
deductions += len([f for f in findings if f.severity == "high"]) * 15
deductions += len(dangerous_patterns_found) * 10
deductions += min(len(unpinned_deps) * 2, 20)
score = max(0.0, 100.0 - deductions)
return SecurityScanResult(
findings=findings[:20], # cap at 20 findings
dangerous_patterns=dangerous_patterns_found[:10],
unpinned_deps=unpinned_deps[:10],
risk_level=risk_level,
score=round(score, 1),
)
except asyncio.TimeoutError:
return SecurityScanResult(risk_level="low", score=70.0, findings=[], dangerous_patterns=["Scan timed out"])
except Exception as e:
return SecurityScanResult(risk_level="low", score=70.0, findings=[], dangerous_patterns=[f"Scan error: {str(e)}"])
finally:
if tmpdir and os.path.exists(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)