Padmanav commited on
Commit
d860535
·
1 Parent(s): 523cc36

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 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