devsecops-radar 0.3.0__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {devsecops_radar-0.3.0/devsecops_radar.egg-info → devsecops_radar-0.3.3}/PKG-INFO +4 -1
  2. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/cli/scanner.py +46 -31
  3. devsecops_radar-0.3.3/devsecops_radar/core/auth.py +28 -0
  4. devsecops_radar-0.3.3/devsecops_radar/core/settings.py +19 -0
  5. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/gitleaks.py +12 -4
  6. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/poutine.py +8 -3
  7. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/semgrep.py +8 -3
  8. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/trivy.py +9 -4
  9. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/zizmor.py +8 -3
  10. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/app.py +14 -3
  11. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/dashboard/routes.py +13 -8
  12. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3/devsecops_radar.egg-info}/PKG-INFO +4 -1
  13. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/SOURCES.txt +6 -0
  14. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/requires.txt +3 -0
  15. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/pyproject.toml +4 -1
  16. devsecops_radar-0.3.3/tests/test_analyzer.py +39 -0
  17. devsecops_radar-0.3.3/tests/test_api.py +29 -0
  18. devsecops_radar-0.3.3/tests/test_cli.py +30 -0
  19. devsecops_radar-0.3.3/tests/test_database.py +43 -0
  20. devsecops_radar-0.3.3/tests/test_rule_fusion.py +30 -0
  21. devsecops_radar-0.3.3/tests/test_scanners.py +81 -0
  22. devsecops_radar-0.3.0/tests/test_cli.py +0 -0
  23. devsecops_radar-0.3.0/tests/test_scanners.py +0 -70
  24. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/LICENSE +0 -0
  25. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/MANIFEST.in +0 -0
  26. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/README.md +0 -0
  27. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/__init__.py +0 -0
  28. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/cli/__init__.py +0 -0
  29. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/__init__.py +0 -0
  30. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/analyzer.py +0 -0
  31. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/attack_simulation.py +0 -0
  32. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/database.py +0 -0
  33. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/models.py +0 -0
  34. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/parser.py +0 -0
  35. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/rag.py +0 -0
  36. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/remediation.py +0 -0
  37. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/reporting.py +0 -0
  38. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/rule_fusion.py +0 -0
  39. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/sbom.py +0 -0
  40. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/valuation.py +0 -0
  41. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/plugins/__init__.py +0 -0
  42. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/adapter.py +0 -0
  43. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/base.py +0 -0
  44. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/__init__.py +0 -0
  45. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/attack_paths/__init__.py +0 -0
  46. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/attack_paths/routes.py +0 -0
  47. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/dashboard/__init__.py +0 -0
  48. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/sentry/routes.py +0 -0
  49. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/css/bootstrap.min.css +0 -0
  50. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/css/style.css +0 -0
  51. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/bootstrap.bundle.min.js +0 -0
  52. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/chart.umd.min.js +0 -0
  53. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/dashboard.js +0 -0
  54. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/summary/__init__.py +0 -0
  55. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/summary/routes.py +0 -0
  56. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/templates/index.html +0 -0
  57. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/topology/__init__.py +0 -0
  58. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/topology/routes.py +0 -0
  59. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/dependency_links.txt +0 -0
  60. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/entry_points.txt +0 -0
  61. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/top_level.txt +0 -0
  62. {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devsecops-radar
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Unified CI/CD Security Dashboard — Pipeline Sentinel
5
5
  Author-email: Mehrdoost <70381337+Mehrdoost@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -22,6 +22,9 @@ Requires-Dist: reportlab>=4.0
22
22
  Requires-Dist: litellm>=1.50
23
23
  Requires-Dist: sqlalchemy>=2.0
24
24
  Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: pyjwt>=2.8
26
+ Requires-Dist: pytest>=8.0
27
+ Requires-Dist: pytest-flask>=1.3
25
28
  Dynamic: license-file
26
29
 
27
30
  <!-- markdownlint-disable MD033 MD041 -->
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import asyncio
2
3
  import json
3
4
  import os
4
5
  import sys
@@ -40,8 +41,20 @@ def parse_args():
40
41
  parser.add_argument('--wizard', action='store_true', help='Interactive first-time setup wizard')
41
42
  return parser.parse_args()
42
43
 
43
- def run_scans(args, plugins):
44
- all_findings = []
44
+ async def run_scanner_async(name, target, adapter):
45
+ try:
46
+ if os.path.isfile(target):
47
+ logger.info(f"Parsing {name} JSON file: {target}")
48
+ validated = await asyncio.to_thread(adapter.parse, target)
49
+ else:
50
+ logger.info(f"Running {name} on: {target}")
51
+ validated = await asyncio.to_thread(adapter.run, target)
52
+ return [v.dict() for v in validated]
53
+ except Exception as e:
54
+ logger.error(f"{name} failed: {e}")
55
+ return []
56
+
57
+ async def run_scans(args, plugins):
45
58
  scanner_targets = {
46
59
  'trivy': args.trivy,
47
60
  'semgrep': args.semgrep,
@@ -49,21 +62,20 @@ def run_scans(args, plugins):
49
62
  'zizmor': args.zizmor,
50
63
  'gitleaks': getattr(args, 'gitleaks', None),
51
64
  }
65
+ tasks = []
52
66
  for name, target in scanner_targets.items():
53
67
  if target:
54
- scanner = plugins.get(name)
55
- if scanner:
56
- adapter = ScannerAdapter(scanner)
57
- try:
58
- if os.path.isfile(target):
59
- logger.info(f"Parsing {name} JSON file: {target}")
60
- validated = adapter.parse(target)
61
- else:
62
- logger.info(f"Running {name} on: {target}")
63
- validated = adapter.run(target)
64
- all_findings.extend([v.dict() for v in validated])
65
- except Exception as e:
66
- logger.error(f"{name} failed: {e}")
68
+ plugin = plugins.get(name)
69
+ if plugin:
70
+ adapter = ScannerAdapter(plugin)
71
+ tasks.append(run_scanner_async(name, target, adapter))
72
+ results = await asyncio.gather(*tasks, return_exceptions=True)
73
+ all_findings = []
74
+ for res in results:
75
+ if isinstance(res, list):
76
+ all_findings.extend(res)
77
+ elif isinstance(res, Exception):
78
+ logger.error(f"Scan task failed with exception: {res}")
67
79
  return all_findings
68
80
 
69
81
  def load_custom_rules(args):
@@ -109,27 +121,32 @@ def run_analysis(args, findings, topology=None):
109
121
  def wizard():
110
122
  """Interactive setup wizard for first-time users."""
111
123
  print("🛡️ Welcome to Pipeline Sentinel – Quick Setup Wizard")
112
- print("This will guide you through scanning a sample project.\n")
113
- # 1. Ollama check
124
+ print("This will install necessary components.\n")
114
125
  import subprocess
126
+ # 1. Ollama check
115
127
  try:
116
128
  subprocess.run(['ollama', '--version'], capture_output=True, check=True)
129
+ print("[✔] Ollama found.")
117
130
  except:
118
131
  print("[!] Ollama not found. Installing...")
119
- subprocess.run(['curl', '-fsSL', 'https://ollama.com/install.sh', '|', 'sh'], shell=True)
120
- # 2. Pull model
121
- print("📥 Pulling AI model llama3.2 ...")
132
+ subprocess.run('curl -fsSL https://ollama.com/install.sh | sh', shell=True)
133
+ # 2. Pull AI model
134
+ print("📥 Pulling AI model (llama3.2)...")
122
135
  subprocess.run(['ollama', 'pull', 'llama3.2:latest'])
123
- # 3. Scan current directory with Semgrep and Trivy if available
124
- print("🔍 Scanning current directory with Semgrep...")
136
+ # 3. Suggestions for optional tools
125
137
  if subprocess.run(['which', 'semgrep'], capture_output=True).returncode == 0:
126
- subprocess.run(['semgrep', '--config=auto', '--json', '--output', 'semgrep.json', '.'], check=False)
127
- print("🐳 Scanning with Trivy (if Docker installed)...")
138
+ print("[✔] Semgrep available.")
139
+ else:
140
+ print("[ ] Semgrep not found (optional).")
128
141
  if subprocess.run(['which', 'docker'], capture_output=True).returncode == 0:
129
- subprocess.run(['trivy', 'image', '--format', 'json', '--output', 'trivy.json', 'alpine:latest'], check=False)
130
- # 4. Merge and start dashboard
131
- os.system('devsecops-radar --trivy trivy.json --semgrep semgrep.json')
132
- os.system('devsecops-radar-web')
142
+ print("[✔] Docker available.")
143
+ else:
144
+ print("[ ] Docker not found (optional).")
145
+ # 4. Final instructions
146
+ print("\n✅ Setup complete! You can now run:")
147
+ print(" devsecops-radar --trivy sample_trivy.json --semgrep sample_semgrep.json")
148
+ print(" devsecops-radar-web")
149
+ print("\nThen open http://localhost:8080 in your browser.")
133
150
 
134
151
  def main():
135
152
  args = parse_args()
@@ -140,8 +157,7 @@ def main():
140
157
  logger.add(sys.stderr, format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | {message}")
141
158
 
142
159
  plugins = discover_plugins()
143
- findings = []
144
- findings.extend(run_scans(args, plugins))
160
+ findings = asyncio.run(run_scans(args, plugins))
145
161
  findings.extend(load_custom_rules(args))
146
162
 
147
163
  if not findings:
@@ -163,7 +179,6 @@ def main():
163
179
 
164
180
  if args.fix and ai_summary:
165
181
  if args.review:
166
- # Human-in-the-loop: show diff and ask confirmation
167
182
  from devsecops_radar.core.remediation import generate_fix_commands
168
183
  cmds = generate_fix_commands(findings, ai_summary)
169
184
  print("Proposed fixes:\n", cmds)
@@ -0,0 +1,28 @@
1
+ import jwt
2
+ import datetime
3
+ from functools import wraps
4
+ from flask import request, jsonify
5
+ from devsecops_radar.core.settings import settings
6
+
7
+ def create_token(user: str = "admin") -> str:
8
+ payload = {
9
+ "user": user,
10
+ "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=settings.JWT_EXPIRATION_HOURS),
11
+ "iat": datetime.datetime.utcnow()
12
+ }
13
+ return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
14
+
15
+ def verify_token(token: str) -> dict:
16
+ return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
17
+
18
+ def login_required(f):
19
+ @wraps(f)
20
+ def decorated(*args, **kwargs):
21
+ # Only enforce authentication if the admin has configured an API key.
22
+ if settings.PIPELINE_API_KEY != "disabled":
23
+ key = request.headers.get("X-API-Key")
24
+ if key != settings.PIPELINE_API_KEY:
25
+ return jsonify({"error": "API key required"}), 401
26
+ # Without an API key, all requests are permitted (default for local use).
27
+ return f(*args, **kwargs)
28
+ return decorated
@@ -0,0 +1,19 @@
1
+ import os
2
+ import secrets
3
+
4
+ class Settings:
5
+ JWT_SECRET: str = os.environ.get("JWT_SECRET", "")
6
+ JWT_ALGORITHM: str = "HS256"
7
+ JWT_EXPIRATION_HOURS: int = 24
8
+ PIPELINE_API_KEY: str = os.environ.get("PIPELINE_API_KEY", "disabled")
9
+ DEBUG: bool = os.environ.get("DEBUG", "false").lower() == "true"
10
+ HOST: str = os.environ.get("HOST", "127.0.0.1")
11
+ PORT: int = int(os.environ.get("PORT", "8080"))
12
+
13
+ def __init__(self):
14
+ if not self.JWT_SECRET:
15
+ self.JWT_SECRET = secrets.token_hex(32)
16
+ print("WARNING: JWT_SECRET not set. Using temporary secret:", self.JWT_SECRET)
17
+ print("Set the JWT_SECRET environment variable for production use.")
18
+
19
+ settings = Settings()
@@ -3,6 +3,7 @@ import subprocess
3
3
  import tempfile
4
4
  import os
5
5
  from typing import List, Dict, Any
6
+ from loguru import logger
6
7
  from devsecops_radar.plugins import ScannerPlugin
7
8
 
8
9
  class GitleaksScanner(ScannerPlugin):
@@ -15,17 +16,24 @@ class GitleaksScanner(ScannerPlugin):
15
16
  with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as tmp:
16
17
  outfile = tmp.name
17
18
  try:
18
- subprocess.run(['gitleaks', 'detect', '--source', target, '--report-format', 'json', '--report-path', outfile], check=True)
19
+ subprocess.run(
20
+ ['gitleaks', 'detect', '--source', target, '--report-format', 'json', '--report-path', outfile],
21
+ check=True
22
+ )
19
23
  return self.parse(outfile)
20
24
  finally:
21
25
  if os.path.exists(outfile):
22
26
  os.unlink(outfile)
23
27
 
24
28
  def parse(self, file_path: str) -> List[Dict[str, Any]]:
25
- with open(file_path) as f:
26
- data = json.load(f)
29
+ try:
30
+ with open(file_path) as f:
31
+ data = json.load(f)
32
+ except Exception as e:
33
+ logger.error(f"Could not parse Gitleaks output: {e}")
34
+ return []
27
35
  findings = []
28
- for item in data:
36
+ for item in data if isinstance(data, list) else data.get("Findings", []):
29
37
  findings.append({
30
38
  "tool": "Gitleaks",
31
39
  "target": item.get("file", ""),
@@ -3,6 +3,7 @@ import subprocess
3
3
  import tempfile
4
4
  import os
5
5
  from typing import List, Dict, Any
6
+ from loguru import logger
6
7
  from devsecops_radar.plugins import ScannerPlugin
7
8
 
8
9
  class PoutineScanner(ScannerPlugin):
@@ -25,15 +26,19 @@ class PoutineScanner(ScannerPlugin):
25
26
  os.unlink(outfile)
26
27
 
27
28
  def parse(self, file_path: str) -> List[Dict[str, Any]]:
28
- with open(file_path) as f:
29
- data = json.load(f)
29
+ try:
30
+ with open(file_path) as f:
31
+ data = json.load(f)
32
+ except Exception as e:
33
+ logger.error(f"Could not parse Poutine output: {e}")
34
+ return []
30
35
  findings = []
31
36
  for result in data.get("findings", []):
32
37
  findings.append({
33
38
  "tool": "Poutine",
34
39
  "target": result.get("location", {}).get("file", ""),
35
40
  "id": result.get("rule_id", ""),
36
- "severity": result.get("severity", "UNKNOWN").upper(),
41
+ "severity": (result.get("severity", "UNKNOWN") or "UNKNOWN").upper(),
37
42
  "title": result.get("message", ""),
38
43
  "description": result.get("description", ""),
39
44
  "line": result.get("location", {}).get("line", 0)
@@ -3,6 +3,7 @@ import subprocess
3
3
  import tempfile
4
4
  import os
5
5
  from typing import List, Dict, Any
6
+ from loguru import logger
6
7
  from devsecops_radar.plugins import ScannerPlugin
7
8
 
8
9
  class SemgrepScanner(ScannerPlugin):
@@ -25,15 +26,19 @@ class SemgrepScanner(ScannerPlugin):
25
26
  os.unlink(outfile)
26
27
 
27
28
  def parse(self, file_path: str) -> List[Dict[str, Any]]:
28
- with open(file_path) as f:
29
- data = json.load(f)
29
+ try:
30
+ with open(file_path) as f:
31
+ data = json.load(f)
32
+ except Exception as e:
33
+ logger.error(f"Could not parse Semgrep output: {e}")
34
+ return []
30
35
  findings = []
31
36
  for result in data.get("results", []):
32
37
  findings.append({
33
38
  "tool": "Semgrep",
34
39
  "target": result.get("path", ""),
35
40
  "id": result.get("check_id", ""),
36
- "severity": result.get("extra", {}).get("severity", "WARNING").upper(),
41
+ "severity": (result.get("extra", {}).get("severity", "WARNING") or "WARNING").upper(),
37
42
  "title": result.get("check_id", ""),
38
43
  "description": result.get("extra", {}).get("message", ""),
39
44
  "line": result.get("start", {}).get("line", 0)
@@ -3,6 +3,7 @@ import subprocess
3
3
  import tempfile
4
4
  import os
5
5
  from typing import List, Dict, Any
6
+ from loguru import logger
6
7
  from devsecops_radar.plugins import ScannerPlugin
7
8
 
8
9
  class TrivyScanner(ScannerPlugin):
@@ -25,8 +26,12 @@ class TrivyScanner(ScannerPlugin):
25
26
  os.unlink(outfile)
26
27
 
27
28
  def parse(self, file_path: str) -> List[Dict[str, Any]]:
28
- with open(file_path) as f:
29
- data = json.load(f)
29
+ try:
30
+ with open(file_path) as f:
31
+ data = json.load(f)
32
+ except Exception as e:
33
+ logger.error(f"Could not parse Trivy output: {e}")
34
+ return []
30
35
  findings = []
31
36
  for result in data.get("Results", []):
32
37
  target_name = result.get("Target", "Unknown")
@@ -34,8 +39,8 @@ class TrivyScanner(ScannerPlugin):
34
39
  findings.append({
35
40
  "tool": "Trivy",
36
41
  "target": target_name,
37
- "id": vuln.get("VulnerabilityID"),
38
- "severity": vuln.get("Severity", "UNKNOWN").upper(),
42
+ "id": vuln.get("VulnerabilityID", ""),
43
+ "severity": (vuln.get("Severity", "UNKNOWN") or "UNKNOWN").upper(),
39
44
  "title": vuln.get("Title", ""),
40
45
  "description": vuln.get("Description", ""),
41
46
  "package": vuln.get("PkgName", ""),
@@ -3,6 +3,7 @@ import subprocess
3
3
  import tempfile
4
4
  import os
5
5
  from typing import List, Dict, Any
6
+ from loguru import logger
6
7
  from devsecops_radar.plugins import ScannerPlugin
7
8
 
8
9
  class ZizmorScanner(ScannerPlugin):
@@ -25,15 +26,19 @@ class ZizmorScanner(ScannerPlugin):
25
26
  os.unlink(outfile)
26
27
 
27
28
  def parse(self, file_path: str) -> List[Dict[str, Any]]:
28
- with open(file_path) as f:
29
- data = json.load(f)
29
+ try:
30
+ with open(file_path) as f:
31
+ data = json.load(f)
32
+ except Exception as e:
33
+ logger.error(f"Could not parse Zizmor output: {e}")
34
+ return []
30
35
  findings = []
31
36
  for result in data.get("findings", []):
32
37
  findings.append({
33
38
  "tool": "Zizmor",
34
39
  "target": result.get("path", ""),
35
40
  "id": result.get("rule_id", ""),
36
- "severity": result.get("severity", "UNKNOWN").upper(),
41
+ "severity": (result.get("severity", "UNKNOWN") or "UNKNOWN").upper(),
37
42
  "title": result.get("message", ""),
38
43
  "description": result.get("description", ""),
39
44
  "line": result.get("location", {}).get("line", 0)
@@ -1,9 +1,11 @@
1
- from flask import Flask
1
+ from flask import Flask, jsonify, request
2
2
  from devsecops_radar.web.dashboard.routes import dashboard_bp
3
3
  from devsecops_radar.web.attack_paths.routes import attack_paths_bp
4
4
  from devsecops_radar.web.topology.routes import topology_bp
5
5
  from devsecops_radar.web.summary.routes import summary_bp
6
6
  from devsecops_radar.web.sentry.routes import sentry_bp
7
+ from devsecops_radar.core.auth import login_required, create_token
8
+ from devsecops_radar.core.settings import settings
7
9
 
8
10
  def create_app():
9
11
  app = Flask(__name__)
@@ -12,11 +14,20 @@ def create_app():
12
14
  app.register_blueprint(topology_bp)
13
15
  app.register_blueprint(summary_bp)
14
16
  app.register_blueprint(sentry_bp)
17
+
18
+ @app.route('/api/auth/login', methods=['POST'])
19
+ def login():
20
+ data = request.get_json() or {}
21
+ if data.get("password") == settings.PIPELINE_API_KEY:
22
+ token = create_token()
23
+ return jsonify({"token": token})
24
+ return jsonify({"error": "Invalid credentials"}), 403
25
+
15
26
  return app
16
27
 
17
- def start_server(host='0.0.0.0', port=8080):
28
+ def start_server():
18
29
  app = create_app()
19
- app.run(host=host, port=port, debug=True)
30
+ app.run(host=settings.HOST, port=settings.PORT, debug=settings.DEBUG)
20
31
 
21
32
  if __name__ == '__main__':
22
33
  start_server()
@@ -3,6 +3,7 @@ import json
3
3
  import os
4
4
  from devsecops_radar.core.database import get_all_scans, get_findings_paginated
5
5
  from devsecops_radar.core.rag import rag_search
6
+ from devsecops_radar.core.auth import login_required
6
7
 
7
8
  dashboard_bp = Blueprint('dashboard', __name__)
8
9
 
@@ -38,7 +39,7 @@ DASHBOARD_HTML = r"""
38
39
  <nav class="navbar navbar-dark border-bottom border-secondary mb-4" style="background:#1e293b;">
39
40
  <div class="container-fluid">
40
41
  <span class="navbar-brand mb-0 h1">🛡️ Pipeline Sentinel</span>
41
- <span class="text-muted">v0.3.2</span>
42
+ <span class="text-muted">v0.3.3</span>
42
43
  </div>
43
44
  </nav>
44
45
 
@@ -174,10 +175,16 @@ DASHBOARD_HTML = r"""
174
175
  <script>
175
176
  const API_KEY = "{{ api_key }}";
176
177
  function getHeaders() {
178
+ const headers = {};
177
179
  if (API_KEY && API_KEY !== 'disabled') {
178
- return { 'X-API-Key': API_KEY };
180
+ headers['X-API-Key'] = API_KEY;
179
181
  }
180
- return {};
182
+ // Optionally add JWT token if stored
183
+ const token = localStorage.getItem('jwt_token');
184
+ if (token) {
185
+ headers['Authorization'] = 'Bearer ' + token;
186
+ }
187
+ return headers;
181
188
  }
182
189
 
183
190
  let allFindings = [];
@@ -221,14 +228,12 @@ DASHBOARD_HTML = r"""
221
228
  renderTable(filtered);
222
229
  }
223
230
 
224
- // ✅ FIX: use data.items for the array, and use allFindings for charts & stats
225
231
  fetch('/api/findings', { headers: getHeaders() })
226
232
  .then(res => res.json())
227
233
  .then(data => {
228
234
  allFindings = data.items;
229
235
  renderTable(allFindings);
230
236
 
231
- // Doughnut chart counts
232
237
  const counts = {CRITICAL:0, HIGH:0, MEDIUM:0, LOW:0};
233
238
  allFindings.forEach(f => {
234
239
  const sev = f.severity.toUpperCase();
@@ -246,7 +251,6 @@ DASHBOARD_HTML = r"""
246
251
  options: { plugins: { legend: { labels: { color: 'white' } } } }
247
252
  });
248
253
 
249
- // Pipeline stats (Poutine + Zizmor)
250
254
  const pipeline = allFindings.filter(f => f.tool === 'Poutine' || f.tool === 'Zizmor');
251
255
  const pCounts = {CRITICAL:0, HIGH:0, MEDIUM:0, LOW:0};
252
256
  pipeline.forEach(f => {
@@ -258,7 +262,6 @@ DASHBOARD_HTML = r"""
258
262
  document.getElementById('pipeline-medium').textContent = pCounts.MEDIUM;
259
263
  document.getElementById('pipeline-low').textContent = pCounts.LOW;
260
264
 
261
- // Attach filter events
262
265
  document.getElementById('searchInput').addEventListener('input', applyFilters);
263
266
  document.getElementById('toolFilter').addEventListener('change', applyFilters);
264
267
  document.getElementById('severityFilter').addEventListener('change', applyFilters);
@@ -297,7 +300,6 @@ DASHBOARD_HTML = r"""
297
300
  }
298
301
  if (!data.nodes || data.nodes.length === 0) return;
299
302
  const container = document.getElementById('attack-graph');
300
- // ✅ Clean previous SVG before drawing new one
301
303
  container.innerHTML = '';
302
304
  const width = container.clientWidth;
303
305
  const height = container.clientHeight;
@@ -445,16 +447,19 @@ def index():
445
447
  )
446
448
 
447
449
  @dashboard_bp.route('/api/findings')
450
+ @login_required
448
451
  def api_findings():
449
452
  page = request.args.get('page', 1, type=int)
450
453
  per_page = request.args.get('per_page', 50, type=int)
451
454
  return jsonify(get_findings_paginated(page, per_page))
452
455
 
453
456
  @dashboard_bp.route('/api/history')
457
+ @login_required
454
458
  def api_history():
455
459
  return jsonify(get_all_scans())
456
460
 
457
461
  @dashboard_bp.route('/api/rag')
462
+ @login_required
458
463
  def api_rag():
459
464
  q = request.args.get('q', '')
460
465
  if not q:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devsecops-radar
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Unified CI/CD Security Dashboard — Pipeline Sentinel
5
5
  Author-email: Mehrdoost <70381337+Mehrdoost@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -22,6 +22,9 @@ Requires-Dist: reportlab>=4.0
22
22
  Requires-Dist: litellm>=1.50
23
23
  Requires-Dist: sqlalchemy>=2.0
24
24
  Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: pyjwt>=2.8
26
+ Requires-Dist: pytest>=8.0
27
+ Requires-Dist: pytest-flask>=1.3
25
28
  Dynamic: license-file
26
29
 
27
30
  <!-- markdownlint-disable MD033 MD041 -->
@@ -14,6 +14,7 @@ devsecops_radar/cli/scanner.py
14
14
  devsecops_radar/core/__init__.py
15
15
  devsecops_radar/core/analyzer.py
16
16
  devsecops_radar/core/attack_simulation.py
17
+ devsecops_radar/core/auth.py
17
18
  devsecops_radar/core/database.py
18
19
  devsecops_radar/core/models.py
19
20
  devsecops_radar/core/parser.py
@@ -22,6 +23,7 @@ devsecops_radar/core/remediation.py
22
23
  devsecops_radar/core/reporting.py
23
24
  devsecops_radar/core/rule_fusion.py
24
25
  devsecops_radar/core/sbom.py
26
+ devsecops_radar/core/settings.py
25
27
  devsecops_radar/core/valuation.py
26
28
  devsecops_radar/plugins/__init__.py
27
29
  devsecops_radar/scanners/adapter.py
@@ -48,5 +50,9 @@ devsecops_radar/web/summary/routes.py
48
50
  devsecops_radar/web/templates/index.html
49
51
  devsecops_radar/web/topology/__init__.py
50
52
  devsecops_radar/web/topology/routes.py
53
+ tests/test_analyzer.py
54
+ tests/test_api.py
51
55
  tests/test_cli.py
56
+ tests/test_database.py
57
+ tests/test_rule_fusion.py
52
58
  tests/test_scanners.py
@@ -7,3 +7,6 @@ reportlab>=4.0
7
7
  litellm>=1.50
8
8
  sqlalchemy>=2.0
9
9
  pydantic>=2.0
10
+ pyjwt>=2.8
11
+ pytest>=8.0
12
+ pytest-flask>=1.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devsecops-radar"
7
- version = "0.3.0"
7
+ version = "0.3.3"
8
8
  description = "Unified CI/CD Security Dashboard — Pipeline Sentinel"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -28,6 +28,9 @@ dependencies = [
28
28
  "litellm>=1.50",
29
29
  "sqlalchemy>=2.0",
30
30
  "pydantic>=2.0",
31
+ "pyjwt>=2.8",
32
+ "pytest>=8.0",
33
+ "pytest-flask>=1.3",
31
34
  ]
32
35
 
33
36
  [project.urls]
@@ -0,0 +1,39 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from devsecops_radar.core.analyzer import OllamaAnalyzer, extract_json, select_findings_for_llm
4
+
5
+ def test_extract_json_plain():
6
+ text = '{"executive_summary": "test", "attack_paths": [], "top_remediations": []}'
7
+ result = extract_json(text)
8
+ assert result["executive_summary"] == "test"
9
+
10
+ def test_extract_json_malformed():
11
+ result = extract_json("some text {invalid")
12
+ assert "executive_summary" in result # falls back to wrapping the text
13
+
14
+ def test_select_findings_for_llm():
15
+ findings = [{"severity": "CRITICAL"}] * 120 + [{"severity": "LOW"}] * 50
16
+ selected = select_findings_for_llm(findings, max_items=100)
17
+ assert len(selected) == 100
18
+ # All criticals should be included
19
+ criticals = [f for f in selected if f["severity"] == "CRITICAL"]
20
+ assert len(criticals) == 100
21
+
22
+ @patch('requests.Session.post')
23
+ def test_ollama_analyzer_success(mock_post):
24
+ mock_response = MagicMock()
25
+ mock_response.json.return_value = {"response": '{"executive_summary": "ok", "attack_paths": [], "top_remediations": []}'}
26
+ mock_response.raise_for_status.return_value = None
27
+ mock_post.return_value = mock_response
28
+ analyzer = OllamaAnalyzer()
29
+ findings = [{"severity": "CRITICAL", "id": "1", "tool": "test"}]
30
+ analysis = analyzer.analyze(findings)
31
+ assert analysis["executive_summary"] == "ok"
32
+
33
+ @patch('requests.Session.post')
34
+ def test_ollama_analyzer_network_error(mock_post):
35
+ mock_post.side_effect = Exception("Network down")
36
+ analyzer = OllamaAnalyzer()
37
+ findings = [{"severity": "CRITICAL", "id": "1", "tool": "test"}]
38
+ analysis = analyzer.analyze(findings)
39
+ assert "AI failed" in analysis["executive_summary"]
@@ -0,0 +1,29 @@
1
+ import pytest
2
+ from devsecops_radar.web.app import create_app
3
+ import os
4
+
5
+ @pytest.fixture
6
+ def app():
7
+ app = create_app()
8
+ app.config['TESTING'] = True
9
+ return app
10
+
11
+ @pytest.fixture
12
+ def client(app):
13
+ return app.test_client()
14
+
15
+ def test_dashboard_page(client):
16
+ resp = client.get('/')
17
+ assert resp.status_code == 200
18
+
19
+ def test_findings_api_requires_key_when_set(monkeypatch, client):
20
+ monkeypatch.setenv("PIPELINE_API_KEY", "secret")
21
+ resp = client.get('/api/findings')
22
+ assert resp.status_code == 401
23
+ resp = client.get('/api/findings', headers={"X-API-Key": "secret"})
24
+ assert resp.status_code == 200
25
+
26
+ def test_findings_api_open_when_disabled(monkeypatch, client):
27
+ monkeypatch.setenv("PIPELINE_API_KEY", "disabled")
28
+ resp = client.get('/api/findings')
29
+ assert resp.status_code == 200
@@ -0,0 +1,30 @@
1
+ import subprocess
2
+ import os
3
+ import tempfile
4
+ import pytest
5
+
6
+ def test_cli_help():
7
+ result = subprocess.run(['devsecops-radar', '--help'], capture_output=True, text=True)
8
+ assert result.returncode == 0
9
+ assert '--trivy' in result.stdout
10
+
11
+ def test_cli_wizard_flag():
12
+ result = subprocess.run(['devsecops-radar', '--wizard'], capture_output=True, text=True)
13
+ assert result.returncode == 0
14
+ assert 'Quick Setup Wizard' in result.stdout or 'Welcome' in result.stdout
15
+
16
+ def test_cli_merge_sample_files():
17
+ sample_dir = os.path.join(os.path.dirname(__file__), '..')
18
+ trivy_sample = os.path.join(sample_dir, 'sample_trivy.json')
19
+ semgrep_sample = os.path.join(sample_dir, 'sample_semgrep.json')
20
+ if not os.path.exists(trivy_sample) or not os.path.exists(semgrep_sample):
21
+ pytest.skip("Sample files not found")
22
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as outfile:
23
+ outpath = outfile.name
24
+ result = subprocess.run(
25
+ ['devsecops-radar', '--trivy', trivy_sample, '--semgrep', semgrep_sample, '--output', outpath],
26
+ capture_output=True, text=True
27
+ )
28
+ assert result.returncode == 0
29
+ assert 'Merged' in result.stdout
30
+ os.unlink(outpath)
@@ -0,0 +1,43 @@
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+
5
+ # Set a temporary database BEFORE importing any devsecops_radar modules
6
+ @pytest.fixture(scope="module")
7
+ def temp_db():
8
+ # Create a unique temp file path for the database
9
+ fd, tmpfile = tempfile.mkstemp(suffix='.db')
10
+ os.close(fd)
11
+ old_val = os.environ.get("DATABASE_URL")
12
+ os.environ["DATABASE_URL"] = f"sqlite:///{tmpfile}"
13
+
14
+ # Now import the database functions — they will use the temp DB
15
+ from devsecops_radar.core.database import save_scan, get_all_scans, get_findings_paginated
16
+
17
+ yield tmpfile, save_scan, get_all_scans, get_findings_paginated
18
+
19
+ # Cleanup
20
+ os.unlink(tmpfile)
21
+ if old_val is None:
22
+ del os.environ["DATABASE_URL"]
23
+ else:
24
+ os.environ["DATABASE_URL"] = old_val
25
+
26
+
27
+ def test_save_and_retrieve(temp_db):
28
+ tmpfile, save_scan, get_all_scans, get_findings_paginated = temp_db
29
+ findings = [
30
+ {
31
+ "tool": "test",
32
+ "severity": "HIGH",
33
+ "id": "1",
34
+ "target": "t",
35
+ "title": "t",
36
+ "description": "d"
37
+ }
38
+ ]
39
+ save_scan(findings)
40
+ scans = get_all_scans()
41
+ assert len(scans) > 0
42
+ paginated = get_findings_paginated(1, 10)
43
+ assert paginated["total"] >= 1
@@ -0,0 +1,30 @@
1
+ import json
2
+ import tempfile
3
+ import os
4
+ from devsecops_radar.core.rule_fusion import RuleFusion
5
+
6
+ class TestPolicyEvaluation:
7
+ def test_policy_pass(self):
8
+ findings = [{"severity": "CRITICAL"}] * 3
9
+ policy = {"max_critical": 5, "on_violation": "fail"}
10
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
11
+ json.dump(policy, f)
12
+ f.flush()
13
+ passed, msg = RuleFusion.evaluate_policy(findings, f.name)
14
+ os.unlink(f.name)
15
+ assert passed
16
+ assert "passed" in msg
17
+
18
+ def test_policy_fail(self):
19
+ findings = [{"severity": "CRITICAL"}] * 6
20
+ policy = {"max_critical": 5, "on_violation": "fail"}
21
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
22
+ json.dump(policy, f)
23
+ f.flush()
24
+ passed, msg = RuleFusion.evaluate_policy(findings, f.name)
25
+ os.unlink(f.name)
26
+ assert not passed
27
+
28
+ def test_policy_file_not_found(self):
29
+ passed, msg = RuleFusion.evaluate_policy([], "/nonexistent/policy.json")
30
+ assert passed
@@ -0,0 +1,81 @@
1
+ import json
2
+ import tempfile
3
+ import os
4
+ import pytest
5
+ from devsecops_radar.scanners.trivy import TrivyScanner
6
+ from devsecops_radar.scanners.semgrep import SemgrepScanner
7
+ from devsecops_radar.scanners.poutine import PoutineScanner
8
+ from devsecops_radar.scanners.zizmor import ZizmorScanner
9
+ from devsecops_radar.scanners.gitleaks import GitleaksScanner
10
+
11
+ def write_temp_json(data):
12
+ tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
13
+ json.dump(data, tmp)
14
+ tmp.close()
15
+ return tmp.name
16
+
17
+ class TestTrivyScanner:
18
+ def test_valid_json(self):
19
+ data = {"Results": [{"Target": "img", "Vulnerabilities": [{"VulnerabilityID": "CVE-1", "Severity": "HIGH"}]}]}
20
+ path = write_temp_json(data)
21
+ findings = TrivyScanner().parse(path)
22
+ os.unlink(path)
23
+ assert len(findings) == 1
24
+ assert findings[0]["severity"] == "HIGH"
25
+
26
+ def test_invalid_json(self):
27
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
28
+ f.write("not json")
29
+ path = f.name
30
+ findings = TrivyScanner().parse(path)
31
+ os.unlink(path)
32
+ assert findings == []
33
+
34
+ def test_missing_results(self):
35
+ data = {"not_results": []}
36
+ path = write_temp_json(data)
37
+ findings = TrivyScanner().parse(path)
38
+ os.unlink(path)
39
+ assert findings == []
40
+
41
+ class TestSemgrepScanner:
42
+ def test_valid(self):
43
+ data = {"results": [{"path": "a.py", "check_id": "x", "extra": {"severity": "ERROR", "message": "bad"}}]}
44
+ path = write_temp_json(data)
45
+ findings = SemgrepScanner().parse(path)
46
+ os.unlink(path)
47
+ assert len(findings) == 1
48
+ assert findings[0]["severity"] == "ERROR"
49
+
50
+ def test_invalid(self):
51
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
52
+ f.write("{invalid")
53
+ path = f.name
54
+ findings = SemgrepScanner().parse(path)
55
+ os.unlink(path)
56
+ assert findings == []
57
+
58
+ class TestPoutineScanner:
59
+ def test_valid(self):
60
+ data = {"findings": [{"rule_id": "x", "severity": "HIGH", "message": "bad", "location": {"file": "f", "line": 1}}]}
61
+ path = write_temp_json(data)
62
+ findings = PoutineScanner().parse(path)
63
+ os.unlink(path)
64
+ assert findings[0]["severity"] == "HIGH"
65
+
66
+ class TestZizmorScanner:
67
+ def test_valid(self):
68
+ data = {"findings": [{"rule_id": "z1", "severity": "LOW", "message": "m", "path": "p", "location": {"line": 2}}]}
69
+ path = write_temp_json(data)
70
+ findings = ZizmorScanner().parse(path)
71
+ os.unlink(path)
72
+ assert findings[0]["severity"] == "LOW"
73
+
74
+ class TestGitleaksScanner:
75
+ def test_valid_list(self):
76
+ data = [{"file": "f", "ruleID": "r", "description": "d", "line": 1}]
77
+ path = write_temp_json(data)
78
+ findings = GitleaksScanner().parse(path)
79
+ os.unlink(path)
80
+ assert len(findings) == 1
81
+ assert findings[0]["severity"] == "HIGH"
File without changes
@@ -1,70 +0,0 @@
1
- import json
2
- import tempfile
3
- import os
4
- from devsecops_radar.scanners.trivy import TrivyScanner
5
- from devsecops_radar.scanners.semgrep import SemgrepScanner
6
- from devsecops_radar.scanners.poutine import PoutineScanner
7
-
8
- sample_trivy = {
9
- "Results": [{
10
- "Target": "test-app",
11
- "Vulnerabilities": [{
12
- "VulnerabilityID": "CVE-TEST-1",
13
- "Severity": "CRITICAL",
14
- "Title": "Test vuln",
15
- "Description": "Test description",
16
- "PkgName": "test-pkg",
17
- "InstalledVersion": "1.0",
18
- "FixedVersion": "2.0"
19
- }]
20
- }]
21
- }
22
-
23
- sample_semgrep = {
24
- "results": [{
25
- "path": "test.py",
26
- "check_id": "test.rule",
27
- "extra": {"severity": "HIGH", "message": "Test finding"},
28
- "start": {"line": 10}
29
- }]
30
- }
31
-
32
- sample_poutine = {
33
- "findings": [{
34
- "rule_id": "test-rule",
35
- "severity": "MEDIUM",
36
- "message": "Test poutine",
37
- "description": "Desc",
38
- "location": {"file": ".gitlab-ci.yml", "line": 1}
39
- }]
40
- }
41
-
42
- def write_temp(data):
43
- tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
44
- json.dump(data, tmp)
45
- tmp.close()
46
- return tmp.name
47
-
48
- def test_trivy_parse():
49
- path = write_temp(sample_trivy)
50
- findings = TrivyScanner().parse(path)
51
- os.unlink(path)
52
- assert len(findings) == 1
53
- assert findings[0]['tool'] == 'Trivy'
54
- assert findings[0]['severity'] == 'CRITICAL'
55
-
56
- def test_semgrep_parse():
57
- path = write_temp(sample_semgrep)
58
- findings = SemgrepScanner().parse(path)
59
- os.unlink(path)
60
- assert len(findings) == 1
61
- assert findings[0]['tool'] == 'Semgrep'
62
- assert findings[0]['severity'] == 'HIGH'
63
-
64
- def test_poutine_parse():
65
- path = write_temp(sample_poutine)
66
- findings = PoutineScanner().parse(path)
67
- os.unlink(path)
68
- assert len(findings) == 1
69
- assert findings[0]['tool'] == 'Poutine'
70
- assert findings[0]['severity'] == 'MEDIUM'
File without changes