Padmanav commited on
Commit
7700a48
·
1 Parent(s): ba4292e

feat(security): add Semgrep scanner with python + secrets rulesets

Browse files
.semgrepignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore test fixtures and generated files
2
+ tests/eval/benchmark_dataset.py
3
+ tests/load/
4
+ *.pyc
5
+ __pycache__/
6
+ .venv/
7
+ venv/
8
+ node_modules/
9
+ .git/
10
+ dist/
11
+ build/
12
+ *.egg-info/
app/tools/semgrep_scanner.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import subprocess # nosec B404
3
+ from typing import Any
4
+
5
+ from app.core.logger import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+ # Rulesets to run. These are free, no login required.
10
+ # p/python — common Python bugs, anti-patterns, security issues
11
+ # p/secrets — hardcoded credentials, tokens, keys
12
+ SEMGREP_RULESETS = ["p/python", "p/secrets"]
13
+
14
+ # Severity mapping from Semgrep's levels to our internal convention
15
+ _SEVERITY_MAP = {
16
+ "ERROR": "critical",
17
+ "WARNING": "high",
18
+ "INFO": "medium",
19
+ }
20
+
21
+
22
+ def run_semgrep(local_path: str) -> dict[str, Any]:
23
+ """
24
+ Run Semgrep against local_path and return a normalised result dict.
25
+
26
+ Returns:
27
+ {
28
+ "success": bool,
29
+ "findings": [
30
+ {
31
+ "rule_id": str,
32
+ "severity": "critical" | "high" | "medium",
33
+ "message": str,
34
+ "file": str,
35
+ "line": int,
36
+ }
37
+ ],
38
+ "error": str | None,
39
+ }
40
+ """
41
+ result: dict[str, Any] = {"success": False, "findings": [], "error": None}
42
+
43
+ try:
44
+ process = subprocess.run( # nosec B603 B607
45
+ [
46
+ "semgrep",
47
+ "--config", ",".join(SEMGREP_RULESETS),
48
+ "--json",
49
+ "--quiet",
50
+ "--no-git-ignore", # scan everything, not just tracked files
51
+ "--timeout", "60",
52
+ ".",
53
+ ],
54
+ cwd=local_path,
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=90,
58
+ )
59
+
60
+ # Semgrep exits 1 when findings are present — that is NOT an error
61
+ if process.returncode not in (0, 1):
62
+ result["error"] = process.stderr[:500] if process.stderr else "semgrep exited with code " + str(process.returncode)
63
+ logger.warning("Semgrep exited unexpectedly", extra={"returncode": process.returncode, "stderr": result["error"]})
64
+ return result
65
+
66
+ raw = json.loads(process.stdout or "{}")
67
+ findings = []
68
+ for match in raw.get("results", []):
69
+ findings.append({
70
+ "rule_id": match.get("check_id", "unknown"),
71
+ "severity": _SEVERITY_MAP.get(match.get("extra", {}).get("severity", "INFO"), "medium"),
72
+ "message": match.get("extra", {}).get("message", "")[:300],
73
+ "file": match.get("path", "unknown"),
74
+ "line": match.get("start", {}).get("line", 0),
75
+ })
76
+
77
+ result["success"] = True
78
+ result["findings"] = findings
79
+ logger.info("Semgrep scan complete", extra={"findings": len(findings)})
80
+
81
+ except FileNotFoundError:
82
+ result["error"] = "semgrep not installed — skipping"
83
+ logger.warning("Semgrep not found on PATH — install with: pip install semgrep")
84
+ except subprocess.TimeoutExpired:
85
+ result["error"] = "semgrep timed out after 90s"
86
+ logger.warning("Semgrep scan timed out", extra={"path": local_path})
87
+ except json.JSONDecodeError as exc:
88
+ result["error"] = f"Failed to parse semgrep JSON output: {exc}"
89
+ logger.warning("Semgrep JSON parse error", extra={"error": str(exc)})
90
+ except Exception as exc: # nosec B110
91
+ result["error"] = str(exc)
92
+ logger.warning("Semgrep scan failed", extra={"error": str(exc)})
93
+
94
+ return result
app/tools/static_analyzer.py CHANGED
@@ -1,6 +1,8 @@
1
  import subprocess # nosec B404
2
  from typing import Any
3
 
 
 
4
 
5
  def run_ruff(local_path: str) -> dict[str, Any]:
6
  result: dict[str, Any] = {"output": "", "issues": [], "success": False}
@@ -43,10 +45,29 @@ def run_bandit(local_path: str) -> dict[str, Any]:
43
 
44
 
45
  def run_full_analysis(local_path: str) -> dict[str, Any]:
46
- ruff_results = run_ruff(local_path)
47
  bandit_results = run_bandit(local_path)
 
 
 
 
 
 
 
 
 
48
  return {
49
- "ruff": ruff_results,
50
- "bandit": bandit_results,
51
- "total_issues": len(ruff_results["issues"]) + len(bandit_results["issues"]),
52
- }
 
 
 
 
 
 
 
 
 
 
 
1
  import subprocess # nosec B404
2
  from typing import Any
3
 
4
+ from app.tools.semgrep_scanner import run_semgrep
5
+
6
 
7
  def run_ruff(local_path: str) -> dict[str, Any]:
8
  result: dict[str, Any] = {"output": "", "issues": [], "success": False}
 
45
 
46
 
47
  def run_full_analysis(local_path: str) -> dict[str, Any]:
48
+ ruff_results = run_ruff(local_path)
49
  bandit_results = run_bandit(local_path)
50
+ semgrep_result = run_semgrep(local_path)
51
+
52
+ # Normalise Semgrep findings into the same flat issues list format
53
+ # used by ruff/bandit so callers don't need to know the difference
54
+ semgrep_issues = [
55
+ f"[{f['severity'].upper()}] {f['rule_id']} {f['file']}:{f['line']} — {f['message']}"
56
+ for f in semgrep_result.get("findings", [])
57
+ ]
58
+
59
  return {
60
+ "ruff": ruff_results,
61
+ "bandit": bandit_results,
62
+ "semgrep": {
63
+ "issues": semgrep_issues,
64
+ "findings": semgrep_result.get("findings", []), # structured form for downstream use
65
+ "success": semgrep_result.get("success", False),
66
+ "error": semgrep_result.get("error"),
67
+ },
68
+ "total_issues": (
69
+ len(ruff_results["issues"])
70
+ + len(bandit_results["issues"])
71
+ + len(semgrep_issues)
72
+ ),
73
+ }
requirements.txt CHANGED
@@ -35,4 +35,6 @@ redis==5.0.8
35
 
36
  pip-audit==2.7.3
37
 
38
- locust==2.32.4
 
 
 
35
 
36
  pip-audit==2.7.3
37
 
38
+ locust==2.32.4
39
+
40
+ semgrep>=1.70.0