Spaces:
Sleeping
Sleeping
feat: add security depth — secret scanning, dependency vulns, report surface
Browse files- Add dependency_scanner.py with pip-audit integration (requirements/pyproject)
- Add scan_secrets_regex() to ast_parser.py for non-Python files (.env, YAML, JS)
- Add SecurityScanResult model to EngineeringReport with ast/secret/dep fields
- Wire secret_findings and dep_vulnerabilities into bug_detection_agent context
- Populate security_scan on EngineeringReport in analysis_service pipeline
- Add pip-audit==2.7.3 to requirements.txt
- app/agents/bug_detection_agent.py +15 -1
- app/models/report.py +9 -0
- app/services/analysis_service.py +21 -0
- app/tools/ast_parser.py +42 -1
- app/tools/dependency_scanner.py +70 -0
- requirements.txt +3 -1
app/agents/bug_detection_agent.py
CHANGED
|
@@ -5,7 +5,8 @@ from app.tools.file_scanner import get_python_files, sanitize_for_prompt
|
|
| 5 |
from app.core.llm import call_llm_for_json
|
| 6 |
from app.core.prompts import BUG_DETECTION_PROMPT
|
| 7 |
from app.models.issue import IssueReport, Bug, Warning, Suggestion
|
| 8 |
-
from app.tools.ast_parser import scan_repository_security
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
logger = get_logger(__name__)
|
|
@@ -22,6 +23,13 @@ async def run(local_path: str) -> IssueReport:
|
|
| 22 |
|
| 23 |
# Step 1.5 - AST-based security scan (no LLM, zero cost, deterministic)
|
| 24 |
ast_security_findings = scan_repository_security(local_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
logger.info(
|
| 26 |
"AST security scan complete",
|
| 27 |
extra={"findings": len(ast_security_findings)},
|
|
@@ -59,6 +67,12 @@ Bandit Output:
|
|
| 59 |
AST Security Findings:
|
| 60 |
{chr(10).join(f"[{f['severity'].upper()}] {f['rule']} at line {f['line']} in {f['file']}: {f['detail']}" for f in ast_security_findings[:20])}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
Test Results:
|
| 63 |
Passed: {test_results["passed"]}
|
| 64 |
Failed: {test_results["failed"]}
|
|
|
|
| 5 |
from app.core.llm import call_llm_for_json
|
| 6 |
from app.core.prompts import BUG_DETECTION_PROMPT
|
| 7 |
from app.models.issue import IssueReport, Bug, Warning, Suggestion
|
| 8 |
+
from app.tools.ast_parser import scan_repository_security, scan_secrets_regex
|
| 9 |
+
from app.tools.dependency_scanner import scan_dependencies
|
| 10 |
|
| 11 |
|
| 12 |
logger = get_logger(__name__)
|
|
|
|
| 23 |
|
| 24 |
# Step 1.5 - AST-based security scan (no LLM, zero cost, deterministic)
|
| 25 |
ast_security_findings = scan_repository_security(local_path)
|
| 26 |
+
# Regex-based secret scan (covers non-Python files: .env, YAML, JS, etc.)
|
| 27 |
+
secret_findings = scan_secrets_regex(local_path)
|
| 28 |
+
logger.info("Secret scan complete", extra={"findings": len(secret_findings)})
|
| 29 |
+
|
| 30 |
+
# Dependency vulnerability scan
|
| 31 |
+
dep_vulnerabilities = scan_dependencies(local_path)
|
| 32 |
+
logger.info("Dependency scan complete", extra={"vulnerabilities": len(dep_vulnerabilities)})
|
| 33 |
logger.info(
|
| 34 |
"AST security scan complete",
|
| 35 |
extra={"findings": len(ast_security_findings)},
|
|
|
|
| 67 |
AST Security Findings:
|
| 68 |
{chr(10).join(f"[{f['severity'].upper()}] {f['rule']} at line {f['line']} in {f['file']}: {f['detail']}" for f in ast_security_findings[:20])}
|
| 69 |
|
| 70 |
+
Dependency Vulnerabilities:
|
| 71 |
+
{chr(10).join(f"[VULN] {v['package']}=={v['version']} {v['id']}: {v['description'][:100]}" for v in dep_vulnerabilities[:10]) or "None found"}
|
| 72 |
+
|
| 73 |
+
Secret Scan (non-Python files):
|
| 74 |
+
{chr(10).join(f"[{f['severity'].upper()}] {f['rule']} at line {f['line']} in {f['file']}" for f in secret_findings[:10]) or "None found"}
|
| 75 |
+
|
| 76 |
Test Results:
|
| 77 |
Passed: {test_results["passed"]}
|
| 78 |
Failed: {test_results["failed"]}
|
app/models/report.py
CHANGED
|
@@ -4,6 +4,14 @@ from app.models.issue import IssueReport
|
|
| 4 |
from app.models.review import ReviewSuggestions, GeneratedTests, SpecialistReview
|
| 5 |
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
class EngineeringReport(BaseModel):
|
| 8 |
repository: RepositoryMetadata
|
| 9 |
issues: IssueReport
|
|
@@ -12,4 +20,5 @@ class EngineeringReport(BaseModel):
|
|
| 12 |
full_report: str = ""
|
| 13 |
generated_at: str = ""
|
| 14 |
specialist_review: SpecialistReview | None = None
|
|
|
|
| 15 |
from_cache: bool = False
|
|
|
|
| 4 |
from app.models.review import ReviewSuggestions, GeneratedTests, SpecialistReview
|
| 5 |
|
| 6 |
|
| 7 |
+
class SecurityScanResult(BaseModel):
|
| 8 |
+
ast_findings: list[dict] = []
|
| 9 |
+
secret_findings: list[dict] = []
|
| 10 |
+
dependency_vulnerabilities: list[dict] = []
|
| 11 |
+
total_high: int = 0
|
| 12 |
+
total_critical: int = 0
|
| 13 |
+
|
| 14 |
+
|
| 15 |
class EngineeringReport(BaseModel):
|
| 16 |
repository: RepositoryMetadata
|
| 17 |
issues: IssueReport
|
|
|
|
| 20 |
full_report: str = ""
|
| 21 |
generated_at: str = ""
|
| 22 |
specialist_review: SpecialistReview | None = None
|
| 23 |
+
security_scan: SecurityScanResult = SecurityScanResult()
|
| 24 |
from_cache: bool = False
|
app/services/analysis_service.py
CHANGED
|
@@ -24,6 +24,10 @@ from app.tools.github_tool import (
|
|
| 24 |
delete_repository,
|
| 25 |
resolve_remote_head_sha,
|
| 26 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
logger = get_logger(__name__)
|
| 29 |
|
|
@@ -103,6 +107,23 @@ async def _run_pipeline(github_url: str) -> EngineeringReport:
|
|
| 103 |
report = await report_generator_agent.run(
|
| 104 |
metadata, issues, tests, review, specialist_review
|
| 105 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
metrics.record_run(
|
| 107 |
"report_generator_agent", time.monotonic() - _t, success=True
|
| 108 |
)
|
|
|
|
| 24 |
delete_repository,
|
| 25 |
resolve_remote_head_sha,
|
| 26 |
)
|
| 27 |
+
from app.models.report import SecurityScanResult
|
| 28 |
+
from app.tools.ast_parser import scan_repository_security, scan_secrets_regex
|
| 29 |
+
from app.tools.dependency_scanner import scan_dependencies
|
| 30 |
+
|
| 31 |
|
| 32 |
logger = get_logger(__name__)
|
| 33 |
|
|
|
|
| 107 |
report = await report_generator_agent.run(
|
| 108 |
metadata, issues, tests, review, specialist_review
|
| 109 |
)
|
| 110 |
+
|
| 111 |
+
# Already computed inside bug_detection_agent, but we surface them on the report too
|
| 112 |
+
# These are fast (no LLM) so re-running is acceptable
|
| 113 |
+
ast_findings = scan_repository_security(local_path)
|
| 114 |
+
secret_findings = scan_secrets_regex(local_path)
|
| 115 |
+
dep_vulns = scan_dependencies(local_path)
|
| 116 |
+
|
| 117 |
+
high = sum(1 for f in ast_findings + secret_findings if f.get("severity") == "high")
|
| 118 |
+
critical = sum(1 for f in ast_findings if f.get("severity") == "critical")
|
| 119 |
+
|
| 120 |
+
report.security_scan = SecurityScanResult(
|
| 121 |
+
ast_findings=ast_findings,
|
| 122 |
+
secret_findings=secret_findings,
|
| 123 |
+
dependency_vulnerabilities=dep_vulns,
|
| 124 |
+
total_high=high,
|
| 125 |
+
total_critical=critical,
|
| 126 |
+
)
|
| 127 |
metrics.record_run(
|
| 128 |
"report_generator_agent", time.monotonic() - _t, success=True
|
| 129 |
)
|
app/tools/ast_parser.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import ast
|
| 2 |
import os
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
|
|
@@ -156,4 +157,44 @@ def scan_repository_security(local_path: str) -> list[dict]:
|
|
| 156 |
for file in files:
|
| 157 |
if file.endswith(".py"):
|
| 158 |
all_findings.extend(detect_security_issues(os.path.join(root, file)))
|
| 159 |
-
return all_findings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import ast
|
| 2 |
import os
|
| 3 |
+
import re as _re
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
|
|
|
|
| 157 |
for file in files:
|
| 158 |
if file.endswith(".py"):
|
| 159 |
all_findings.extend(detect_security_issues(os.path.join(root, file)))
|
| 160 |
+
return all_findings
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
_REGEX_SECRET_PATTERNS = [
|
| 164 |
+
(r"(?i)(api_key|apikey|api-key)\s*[=:]\s*['\"]([A-Za-z0-9_\-]{16,})['\"]", "hardcoded-api-key"),
|
| 165 |
+
(r"(?i)(secret|password|passwd|token|auth_token|access_token)\s*[=:]\s*['\"]([A-Za-z0-9_\-]{8,})['\"]", "hardcoded-secret"),
|
| 166 |
+
(r"(?i)-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----", "exposed-private-key"),
|
| 167 |
+
(r"(?i)(AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|SLACK_TOKEN)\s*[=:]\s*['\"]?([A-Za-z0-9/+=]{16,})['\"]?", "cloud-credential"),
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
_SCAN_EXTENSIONS = {".py", ".js", ".ts", ".env", ".yaml", ".yml", ".json", ".sh", ".cfg", ".ini"}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def scan_secrets_regex(local_path: str) -> list[dict]:
|
| 174 |
+
"""
|
| 175 |
+
Regex-based secret scan across all common file types (not just Python).
|
| 176 |
+
Complements the AST-based scanner which only covers Python.
|
| 177 |
+
"""
|
| 178 |
+
findings: list[dict] = []
|
| 179 |
+
for root, dirs, files in os.walk(local_path):
|
| 180 |
+
dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".venv", "venv", "node_modules"}]
|
| 181 |
+
for file in files:
|
| 182 |
+
ext = os.path.splitext(file)[1].lower()
|
| 183 |
+
if ext not in _SCAN_EXTENSIONS:
|
| 184 |
+
continue
|
| 185 |
+
file_path = os.path.join(root, file)
|
| 186 |
+
try:
|
| 187 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 188 |
+
for lineno, line in enumerate(f, 1):
|
| 189 |
+
for pattern, rule in _REGEX_SECRET_PATTERNS:
|
| 190 |
+
if _re.search(pattern, line):
|
| 191 |
+
findings.append({
|
| 192 |
+
"rule": rule,
|
| 193 |
+
"line": lineno,
|
| 194 |
+
"file": file_path,
|
| 195 |
+
"detail": f"Possible secret matched pattern '{rule}'",
|
| 196 |
+
"severity": "high",
|
| 197 |
+
})
|
| 198 |
+
except Exception: # nosec B110
|
| 199 |
+
pass
|
| 200 |
+
return findings
|
app/tools/dependency_scanner.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dependency vulnerability scanner using pip-audit.
|
| 3 |
+
Scans requirements.txt / pyproject.toml for known CVEs.
|
| 4 |
+
Best-effort: returns empty list if pip-audit is not installed or fails.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import subprocess # nosec B404
|
| 8 |
+
import os
|
| 9 |
+
from app.core.logger import get_logger
|
| 10 |
+
|
| 11 |
+
logger = get_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def scan_dependencies(local_path: str) -> list[dict]:
|
| 15 |
+
"""
|
| 16 |
+
Run pip-audit on the repo and return a list of vulnerability dicts.
|
| 17 |
+
Each dict has: package, version, id, description, fix_versions.
|
| 18 |
+
Returns empty list if pip-audit unavailable or no manifest found.
|
| 19 |
+
"""
|
| 20 |
+
# Find dependency manifest
|
| 21 |
+
manifest = None
|
| 22 |
+
for candidate in ["requirements.txt", "pyproject.toml", "setup.cfg"]:
|
| 23 |
+
full = os.path.join(local_path, candidate)
|
| 24 |
+
if os.path.exists(full):
|
| 25 |
+
manifest = full
|
| 26 |
+
break
|
| 27 |
+
|
| 28 |
+
if manifest is None:
|
| 29 |
+
logger.info("No dependency manifest found — skipping vulnerability scan")
|
| 30 |
+
return []
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
result = subprocess.run( # nosec B603 B607
|
| 34 |
+
["pip-audit", "--requirement", manifest, "--format", "json", "--no-deps"],
|
| 35 |
+
capture_output=True,
|
| 36 |
+
text=True,
|
| 37 |
+
timeout=120,
|
| 38 |
+
)
|
| 39 |
+
except FileNotFoundError:
|
| 40 |
+
logger.warning("pip-audit not installed — skipping dependency scan")
|
| 41 |
+
return []
|
| 42 |
+
except subprocess.TimeoutExpired:
|
| 43 |
+
logger.warning("pip-audit timed out")
|
| 44 |
+
return []
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
logger.warning("pip-audit failed", extra={"error": str(exc)})
|
| 47 |
+
return []
|
| 48 |
+
|
| 49 |
+
if result.returncode not in (0, 1): # 0=clean, 1=vulns found, others=error
|
| 50 |
+
logger.warning("pip-audit error", extra={"stderr": result.stderr[:200]})
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
data = json.loads(result.stdout)
|
| 55 |
+
except json.JSONDecodeError:
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
findings = []
|
| 59 |
+
for dep in data.get("dependencies", []):
|
| 60 |
+
for vuln in dep.get("vulns", []):
|
| 61 |
+
findings.append({
|
| 62 |
+
"package": dep.get("name", "unknown"),
|
| 63 |
+
"version": dep.get("version", "unknown"),
|
| 64 |
+
"id": vuln.get("id", ""),
|
| 65 |
+
"description": vuln.get("description", ""),
|
| 66 |
+
"fix_versions": vuln.get("fix_versions", []),
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
logger.info("Dependency scan complete", extra={"vulnerabilities": len(findings)})
|
| 70 |
+
return findings
|
requirements.txt
CHANGED
|
@@ -31,4 +31,6 @@ mypy==1.10.0
|
|
| 31 |
|
| 32 |
# Async task queue
|
| 33 |
celery==5.4.0
|
| 34 |
-
redis==5.0.8
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# Async task queue
|
| 33 |
celery==5.4.0
|
| 34 |
+
redis==5.0.8
|
| 35 |
+
|
| 36 |
+
pip-audit==2.7.3
|