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.
- {devsecops_radar-0.3.0/devsecops_radar.egg-info → devsecops_radar-0.3.3}/PKG-INFO +4 -1
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/cli/scanner.py +46 -31
- devsecops_radar-0.3.3/devsecops_radar/core/auth.py +28 -0
- devsecops_radar-0.3.3/devsecops_radar/core/settings.py +19 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/gitleaks.py +12 -4
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/poutine.py +8 -3
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/semgrep.py +8 -3
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/trivy.py +9 -4
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/zizmor.py +8 -3
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/app.py +14 -3
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/dashboard/routes.py +13 -8
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3/devsecops_radar.egg-info}/PKG-INFO +4 -1
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/SOURCES.txt +6 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/requires.txt +3 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/pyproject.toml +4 -1
- devsecops_radar-0.3.3/tests/test_analyzer.py +39 -0
- devsecops_radar-0.3.3/tests/test_api.py +29 -0
- devsecops_radar-0.3.3/tests/test_cli.py +30 -0
- devsecops_radar-0.3.3/tests/test_database.py +43 -0
- devsecops_radar-0.3.3/tests/test_rule_fusion.py +30 -0
- devsecops_radar-0.3.3/tests/test_scanners.py +81 -0
- devsecops_radar-0.3.0/tests/test_cli.py +0 -0
- devsecops_radar-0.3.0/tests/test_scanners.py +0 -70
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/LICENSE +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/MANIFEST.in +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/README.md +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/cli/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/analyzer.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/attack_simulation.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/database.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/models.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/parser.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/rag.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/remediation.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/reporting.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/rule_fusion.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/sbom.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/core/valuation.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/plugins/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/adapter.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/scanners/base.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/attack_paths/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/attack_paths/routes.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/dashboard/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/sentry/routes.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/css/bootstrap.min.css +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/css/style.css +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/bootstrap.bundle.min.js +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/chart.umd.min.js +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/dashboard.js +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/summary/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/summary/routes.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/templates/index.html +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/topology/__init__.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/topology/routes.py +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/dependency_links.txt +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/entry_points.txt +0 -0
- {devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
55
|
-
if
|
|
56
|
-
adapter = ScannerAdapter(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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(
|
|
28
|
+
def start_server():
|
|
18
29
|
app = create_app()
|
|
19
|
-
app.run(host=
|
|
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.
|
|
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
|
-
|
|
180
|
+
headers['X-API-Key'] = API_KEY;
|
|
179
181
|
}
|
|
180
|
-
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devsecops-radar"
|
|
7
|
-
version = "0.3.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/attack_paths/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/css/bootstrap.min.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar/web/static/js/chart.umd.min.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.3.0 → devsecops_radar-0.3.3}/devsecops_radar.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|