devsecops-radar 0.4.2__tar.gz → 0.4.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.4.2 → devsecops_radar-0.4.3}/PKG-INFO +6 -1
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/README.md +5 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/analyzer.py +41 -43
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/attack_simulation.py +9 -43
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/settings.py +20 -5
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/dashboard/routes.py +85 -25
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/PKG-INFO +6 -1
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/pyproject.toml +1 -1
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_analyzer.py +1 -1
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_attack_simulation.py +2 -2
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_dashboard.py +55 -24
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/LICENSE +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/cli/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/cli/scanner.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/auth.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/database.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/models.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/notifier.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/rag.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/remediation.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/reporting.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/rule_fusion.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/sarif_export.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/sbom.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/core/valuation.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/plugins/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/adapter.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/base.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/gitleaks.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/poutine.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/semgrep.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/trivy.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/scanners/zizmor.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/app.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/attack_paths/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/attack_paths/routes.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/dashboard/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/sentry/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/sentry/routes.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/css/bootstrap.min.css +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/css/style.css +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/bootstrap.bundle.min.js +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/chart.umd.min.js +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/d3.v7.min.js +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/dashboard.js +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/echarts.min.js +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/summary/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/summary/routes.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/templates/index.html +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/topology/__init__.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/topology/routes.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/SOURCES.txt +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/dependency_links.txt +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/entry_points.txt +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/requires.txt +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/top_level.txt +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/setup.cfg +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_adapter.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_app.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_auth.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_base.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_database.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_models.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_notifier.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_rag.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_remediation.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_reporting.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_rule_fusion.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_sarif_export.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_sbom.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_scanner.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_scanners.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_sentry.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_settings.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_topology.py +0 -0
- {devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/tests/test_valuation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devsecops-radar
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -320,6 +320,9 @@ devsecops-radar --trivy trivy.json --analyze
|
|
|
320
320
|
devsecops-radar-web
|
|
321
321
|
```
|
|
322
322
|
The LLM generates `findings_ai_summary.json` containing: `executive_summary`, `risk_score`, `attack_paths` (with MITRE ATT&CK), `top_remediations`, and `false_positives_likely`.
|
|
323
|
+
|
|
324
|
+

|
|
325
|
+
|
|
323
326
|
</details>
|
|
324
327
|
|
|
325
328
|
<details>
|
|
@@ -505,6 +508,8 @@ devsecops-radar --trivy scan.json --rules ~/.devsecops-radar/community-rules/
|
|
|
505
508
|
|
|
506
509
|
*(You can also click any node in the Attack Path Graph and press **“Simulate this attack”**)*.
|
|
507
510
|
|
|
511
|
+

|
|
512
|
+
|
|
508
513
|
---
|
|
509
514
|
|
|
510
515
|
## 🔐 Security Improvements in v0.4.2
|
|
@@ -275,6 +275,9 @@ devsecops-radar --trivy trivy.json --analyze
|
|
|
275
275
|
devsecops-radar-web
|
|
276
276
|
```
|
|
277
277
|
The LLM generates `findings_ai_summary.json` containing: `executive_summary`, `risk_score`, `attack_paths` (with MITRE ATT&CK), `top_remediations`, and `false_positives_likely`.
|
|
278
|
+
|
|
279
|
+

|
|
280
|
+
|
|
278
281
|
</details>
|
|
279
282
|
|
|
280
283
|
<details>
|
|
@@ -460,6 +463,8 @@ devsecops-radar --trivy scan.json --rules ~/.devsecops-radar/community-rules/
|
|
|
460
463
|
|
|
461
464
|
*(You can also click any node in the Attack Path Graph and press **“Simulate this attack”**)*.
|
|
462
465
|
|
|
466
|
+

|
|
467
|
+
|
|
463
468
|
---
|
|
464
469
|
|
|
465
470
|
## 🔐 Security Improvements in v0.4.2
|
|
@@ -12,18 +12,21 @@ from pydantic import BaseModel, Field, ValidationError
|
|
|
12
12
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
# --- Pydantic Models for Strict Output Validation (Anti-Injection) ---
|
|
16
15
|
class AttackPath(BaseModel):
|
|
17
16
|
title: str = Field(..., description="Short title of the attack path")
|
|
18
17
|
description: str = Field(..., description="Explanation of how the vulnerabilities chain together")
|
|
19
|
-
impact: str = Field(
|
|
18
|
+
impact: str = Field(
|
|
19
|
+
default="Impact assessment was not provided by the AI model.",
|
|
20
|
+
description="Potential business or technical impact"
|
|
21
|
+
)
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
class Remediation(BaseModel):
|
|
22
25
|
finding_id: str = Field(..., description="The ID of the finding this relates to")
|
|
23
26
|
title: str = Field(..., description="Short title for the fix")
|
|
24
|
-
# Action removed for security. Replaced with safe string steps.
|
|
25
27
|
remediation_steps: list[str] = Field(..., description="Step-by-step human-readable instructions to fix the issue")
|
|
26
28
|
|
|
29
|
+
|
|
27
30
|
class AIAnalysisResponse(BaseModel):
|
|
28
31
|
executive_summary: str = Field(..., description="High-level summary of the security posture")
|
|
29
32
|
risk_score: float = Field(..., ge=0, le=100, description="Overall risk score between 0 and 100")
|
|
@@ -40,30 +43,26 @@ class AIAnalyzer(ABC):
|
|
|
40
43
|
|
|
41
44
|
@staticmethod
|
|
42
45
|
def _validate_model_name(model: str) -> str:
|
|
43
|
-
"""Prevent simple prompt injection or path traversal via model name."""
|
|
44
46
|
if not re.match(r"^[a-zA-Z0-9_.:-]+$", model):
|
|
45
47
|
logger.warning(f"Suspicious model name detected: {model}. Using fallback 'secure-model-fallback'.")
|
|
46
48
|
return "secure-model-fallback"
|
|
47
49
|
return model
|
|
48
50
|
|
|
49
51
|
def _build_prompt(self, findings: list[dict[str, Any]], topology: dict[str, Any] | None = None) -> str:
|
|
50
|
-
"""Builds a secure, delimiter-protected prompt to prevent injection."""
|
|
51
|
-
|
|
52
|
-
# Limit topology size to prevent Token Limit Exceeded errors
|
|
53
52
|
topology_text = ""
|
|
54
53
|
if topology:
|
|
55
54
|
topo_str = json.dumps(topology)
|
|
56
55
|
topology_text = f"\nAsset Topology:\n{topo_str[:2000]}" + ("... [TRUNCATED]" if len(topo_str) > 2000 else "")
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
prompt = f"""Analyze the following security findings.
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
IMPORTANT: Your response must be a single JSON object with exactly these fields:
|
|
60
|
+
- "executive_summary": string (high-level summary)
|
|
61
|
+
- "risk_score": number between 0 and 100
|
|
62
|
+
- "attack_paths": list of objects with "title", "description", "impact" (string describing the impact)
|
|
63
|
+
- "top_remediations": list of objects with "finding_id", "title", "remediation_steps" (list of strings)
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
{json.dumps(schema, indent=2)}
|
|
65
|
+
Make sure every object in "attack_paths" includes all three fields. Do NOT include any other text or the JSON schema. Output ONLY the JSON object.
|
|
67
66
|
|
|
68
67
|
<FINDINGS_DATA>
|
|
69
68
|
{json.dumps(findings, indent=2)}
|
|
@@ -73,13 +72,10 @@ Output strictly valid JSON matching this schema:
|
|
|
73
72
|
return prompt
|
|
74
73
|
|
|
75
74
|
def _extract_and_validate_json(self, text: str) -> dict[str, Any]:
|
|
76
|
-
"""Extracts JSON and strictly validates it against the Pydantic model."""
|
|
77
75
|
extracted = {}
|
|
78
76
|
try:
|
|
79
|
-
# 1. Try direct parsing
|
|
80
77
|
extracted = json.loads(text)
|
|
81
78
|
except json.JSONDecodeError:
|
|
82
|
-
# 2. Try regex extraction if LLM added markdown wrappers
|
|
83
79
|
match = re.search(r'\{.*\}', text, re.DOTALL)
|
|
84
80
|
if match:
|
|
85
81
|
try:
|
|
@@ -94,7 +90,13 @@ Output strictly valid JSON matching this schema:
|
|
|
94
90
|
risk_score=0.0
|
|
95
91
|
).model_dump()
|
|
96
92
|
|
|
97
|
-
|
|
93
|
+
if "$defs" in extracted or "properties" in extracted:
|
|
94
|
+
logger.error("LLM returned the JSON schema instead of analysis. Using safe fallback.")
|
|
95
|
+
return AIAnalysisResponse(
|
|
96
|
+
executive_summary="AI analysis failed due to invalid model output. Please retry.",
|
|
97
|
+
risk_score=0.0
|
|
98
|
+
).model_dump()
|
|
99
|
+
|
|
98
100
|
try:
|
|
99
101
|
validated_data = AIAnalysisResponse(**extracted)
|
|
100
102
|
return validated_data.model_dump()
|
|
@@ -106,24 +108,19 @@ Output strictly valid JSON matching this schema:
|
|
|
106
108
|
).model_dump()
|
|
107
109
|
|
|
108
110
|
def merge_analyses(self, analyses: list[dict[str, Any]]) -> dict[str, Any]:
|
|
109
|
-
"""Intelligently merges multiple AI analysis chunks."""
|
|
110
111
|
if not analyses:
|
|
111
112
|
return AIAnalysisResponse(executive_summary="No data analyzed.", risk_score=0.0).model_dump()
|
|
112
|
-
|
|
113
113
|
if len(analyses) == 1:
|
|
114
114
|
return analyses[0]
|
|
115
115
|
|
|
116
|
-
# Weighted Risk Score Average (avoiding max logic trap)
|
|
117
116
|
valid_scores = [a.get("risk_score", 0) for a in analyses if a.get("risk_score", 0) > 0]
|
|
118
117
|
avg_risk = sum(valid_scores) / len(valid_scores) if valid_scores else 0.0
|
|
119
118
|
|
|
120
|
-
# Merge summaries elegantly
|
|
121
119
|
summaries = [a.get("executive_summary", "").strip() for a in analyses if a.get("executive_summary")]
|
|
122
120
|
merged_summary = "Composite Analysis:\n- " + "\n- ".join(summaries[:3])
|
|
123
121
|
if len(summaries) > 3:
|
|
124
122
|
merged_summary += f"\n... (and {len(summaries) - 3} more sub-analyses)"
|
|
125
123
|
|
|
126
|
-
# Deduplicate Remediations based on finding_id
|
|
127
124
|
seen_finding_ids = set()
|
|
128
125
|
merged_remediations = []
|
|
129
126
|
for a in analyses:
|
|
@@ -132,7 +129,6 @@ Output strictly valid JSON matching this schema:
|
|
|
132
129
|
seen_finding_ids.add(r.get("finding_id"))
|
|
133
130
|
merged_remediations.append(r)
|
|
134
131
|
|
|
135
|
-
# Merge Attack Paths
|
|
136
132
|
merged_paths = []
|
|
137
133
|
for a in analyses:
|
|
138
134
|
merged_paths.extend(a.get("attack_paths", []))
|
|
@@ -149,12 +145,9 @@ Output strictly valid JSON matching this schema:
|
|
|
149
145
|
pass
|
|
150
146
|
|
|
151
147
|
async def run(self, findings: list[dict[str, Any]], topology: dict[str, Any] | None = None, chunk_size: int = 10) -> dict[str, Any]:
|
|
152
|
-
"""Splits findings into chunks and processes them concurrently."""
|
|
153
148
|
chunks = [findings[i:i + chunk_size] for i in range(0, len(findings), chunk_size)]
|
|
154
|
-
|
|
155
149
|
if len(chunks) > 10:
|
|
156
150
|
logger.warning(f"High load: Processing {len(chunks)} chunks. Consider increasing 'chunk_size' to optimize performance.")
|
|
157
|
-
|
|
158
151
|
tasks = [self._analyze_chunk(self._build_prompt(chunk, topology)) for chunk in chunks]
|
|
159
152
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
160
153
|
|
|
@@ -164,16 +157,12 @@ Output strictly valid JSON matching this schema:
|
|
|
164
157
|
logger.error(f"Chunk analysis failed: {res}")
|
|
165
158
|
else:
|
|
166
159
|
valid_results.append(res)
|
|
167
|
-
|
|
168
160
|
return self.merge_analyses(valid_results)
|
|
169
161
|
|
|
170
162
|
|
|
171
163
|
class OllamaAnalyzer(AIAnalyzer):
|
|
172
|
-
"""Local, privacy-first AI analysis using Ollama."""
|
|
173
|
-
|
|
174
164
|
def __init__(self, model_name: str = "llama3.2:latest", timeout: int = 300) -> None:
|
|
175
165
|
super().__init__(model_name, timeout)
|
|
176
|
-
# SSRF Protection: Validate URL
|
|
177
166
|
raw_url = os.environ.get("OLLAMA_API_BASE", "http://localhost:11434/api/generate")
|
|
178
167
|
parsed = urlparse(raw_url)
|
|
179
168
|
if parsed.scheme not in ["http", "https"]:
|
|
@@ -181,23 +170,29 @@ class OllamaAnalyzer(AIAnalyzer):
|
|
|
181
170
|
raw_url = "http://localhost:11434/api/generate"
|
|
182
171
|
self.endpoint = raw_url
|
|
183
172
|
|
|
184
|
-
# Retry logic handles temporary local timeouts or Docker hiccups
|
|
185
173
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
|
186
174
|
async def _analyze_chunk(self, prompt: str) -> dict[str, Any]:
|
|
187
175
|
timeout_config = httpx.Timeout(self.timeout)
|
|
188
|
-
payload = {
|
|
189
|
-
|
|
176
|
+
payload = {
|
|
177
|
+
"model": self.model_name,
|
|
178
|
+
"prompt": prompt,
|
|
179
|
+
"stream": False,
|
|
180
|
+
"format": "json",
|
|
181
|
+
"system": (
|
|
182
|
+
"You are a DevSecOps AI assistant. Analyze security findings and output "
|
|
183
|
+
"a JSON object with keys: executive_summary, risk_score, attack_paths, top_remediations. "
|
|
184
|
+
"Each attack path must include title, description, and impact. "
|
|
185
|
+
"Output ONLY the JSON object, no other text."
|
|
186
|
+
)
|
|
187
|
+
}
|
|
190
188
|
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
|
191
189
|
resp = await client.post(self.endpoint, json=payload)
|
|
192
190
|
resp.raise_for_status()
|
|
193
|
-
|
|
194
191
|
data = resp.json()
|
|
195
192
|
return self._extract_and_validate_json(data.get("response", "{}"))
|
|
196
193
|
|
|
197
194
|
|
|
198
195
|
class LiteLLMAnalyzer(AIAnalyzer):
|
|
199
|
-
"""Cloud-based AI analysis using LiteLLM (OpenAI, Anthropic, etc)."""
|
|
200
|
-
|
|
201
196
|
def __init__(self, model_name: str = "gpt-4", timeout: int = 120) -> None:
|
|
202
197
|
super().__init__(model_name, timeout)
|
|
203
198
|
try:
|
|
@@ -208,12 +203,19 @@ class LiteLLMAnalyzer(AIAnalyzer):
|
|
|
208
203
|
logger.error("LiteLLM is not installed. To use cloud models, run: pip install litellm")
|
|
209
204
|
raise ImportError("Missing litellm package. Alternatively, use the default Ollama backend.") from err
|
|
210
205
|
|
|
211
|
-
# Essential for cloud APIs (handles 429 Rate Limits and 502 Bad Gateway)
|
|
212
206
|
@retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1.5, min=2, max=20))
|
|
213
207
|
async def _analyze_chunk(self, prompt: str) -> dict[str, Any]:
|
|
214
208
|
response = await self.litellm.acompletion(
|
|
215
209
|
model=self.model_name,
|
|
216
|
-
messages=[
|
|
210
|
+
messages=[
|
|
211
|
+
{"role": "system", "content": (
|
|
212
|
+
"You are a DevSecOps AI assistant. Analyze security findings and output "
|
|
213
|
+
"a JSON object with keys: executive_summary, risk_score, attack_paths, top_remediations. "
|
|
214
|
+
"Each attack path must include title, description, and impact. "
|
|
215
|
+
"Output ONLY the JSON object, no other text."
|
|
216
|
+
)},
|
|
217
|
+
{"role": "user", "content": prompt}
|
|
218
|
+
],
|
|
217
219
|
timeout=self.timeout,
|
|
218
220
|
response_format={"type": "json_object"}
|
|
219
221
|
)
|
|
@@ -221,11 +223,7 @@ class LiteLLMAnalyzer(AIAnalyzer):
|
|
|
221
223
|
return self._extract_and_validate_json(content)
|
|
222
224
|
|
|
223
225
|
|
|
224
|
-
# --- THE MISSING FACTORY FUNCTION ---
|
|
225
226
|
def get_analyzer(backend: str = "ollama", model: str | None = None) -> AIAnalyzer:
|
|
226
|
-
"""
|
|
227
|
-
Factory function to instantiate the correct AI analyzer backend.
|
|
228
|
-
"""
|
|
229
227
|
if backend.lower() == "litellm":
|
|
230
228
|
return LiteLLMAnalyzer(model_name=model or "gpt-4")
|
|
231
229
|
elif backend.lower() == "ollama":
|
|
@@ -8,16 +8,10 @@ from loguru import logger
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _sanitize_for_bash(value: str) -> str:
|
|
11
|
-
"""
|
|
12
|
-
Escape a string so that it can be safely placed inside single quotes in a bash script.
|
|
13
|
-
The only character that cannot appear inside a single-quoted string is the single quote itself.
|
|
14
|
-
We replace each ' with '\'' (end current quoting, add escaped quote, resume quoting).
|
|
15
|
-
"""
|
|
16
11
|
return value.replace("'", "'\\''")
|
|
17
12
|
|
|
18
13
|
|
|
19
14
|
def _cleanup_temp_dir(dir_path: str) -> None:
|
|
20
|
-
"""Securely remove a temporary directory and its contents."""
|
|
21
15
|
try:
|
|
22
16
|
shutil.rmtree(dir_path)
|
|
23
17
|
logger.debug(f"Temporary directory removed: {dir_path}")
|
|
@@ -26,11 +20,6 @@ def _cleanup_temp_dir(dir_path: str) -> None:
|
|
|
26
20
|
|
|
27
21
|
|
|
28
22
|
def simulate_attack(finding: dict) -> str:
|
|
29
|
-
"""
|
|
30
|
-
Generate a safe proof-of-concept script for a given finding.
|
|
31
|
-
The script echoes the finding's title and ID safely.
|
|
32
|
-
Returns the path to the generated script.
|
|
33
|
-
"""
|
|
34
23
|
if not isinstance(finding, dict) or not finding.get("id") or not finding.get("title"):
|
|
35
24
|
logger.error("Invalid finding data for attack simulation.")
|
|
36
25
|
return _generate_dummy_script("Invalid finding data provided.")
|
|
@@ -45,42 +34,29 @@ def simulate_attack(finding: dict) -> str:
|
|
|
45
34
|
)
|
|
46
35
|
|
|
47
36
|
try:
|
|
48
|
-
# Create a secure temporary directory with a recognizable prefix
|
|
49
37
|
tmpdir = tempfile.mkdtemp(prefix="pipeline_sentinel_sim_")
|
|
50
38
|
script_path = os.path.join(tmpdir, "poc.sh")
|
|
51
|
-
|
|
52
|
-
# Write script and set restrictive permissions (owner read+execute only)
|
|
53
39
|
with open(script_path, 'w') as f:
|
|
54
40
|
f.write(script_content)
|
|
55
|
-
os.chmod(script_path,
|
|
56
|
-
|
|
41
|
+
os.chmod(script_path, 0o755) # permission fix
|
|
57
42
|
logger.debug(f"Attack simulation script created at {script_path}")
|
|
58
43
|
return script_path
|
|
59
|
-
|
|
60
44
|
except OSError as e:
|
|
61
45
|
logger.error(f"Failed to write simulation script: {e}")
|
|
62
46
|
return _generate_dummy_script("Script creation failed.")
|
|
63
47
|
|
|
64
48
|
|
|
65
49
|
def _generate_dummy_script(reason: str) -> str:
|
|
66
|
-
"""Generate a harmless dummy script when input is invalid."""
|
|
67
50
|
tmpdir = tempfile.mkdtemp(prefix="pipeline_sentinel_dummy_")
|
|
68
51
|
dummy_path = os.path.join(tmpdir, "poc.sh")
|
|
69
52
|
safe_reason = _sanitize_for_bash(reason)
|
|
70
53
|
with open(dummy_path, 'w') as f:
|
|
71
54
|
f.write(f"#!/bin/bash\necho 'Simulation skipped: {safe_reason}'\n")
|
|
72
|
-
os.chmod(dummy_path,
|
|
55
|
+
os.chmod(dummy_path, 0o755) # permission fix
|
|
73
56
|
return dummy_path
|
|
74
57
|
|
|
75
58
|
|
|
76
59
|
def run_sandboxed_poc(script_path: str) -> str:
|
|
77
|
-
"""
|
|
78
|
-
Execute the PoC script inside a disposable, hardened Docker container.
|
|
79
|
-
The script is mounted read-only, the container runs without network,
|
|
80
|
-
with all capabilities dropped, as a non-root user.
|
|
81
|
-
Returns the sandbox output or an error message.
|
|
82
|
-
"""
|
|
83
|
-
# --- Path Validation ---
|
|
84
60
|
if not script_path:
|
|
85
61
|
logger.error("No script path provided for sandbox.")
|
|
86
62
|
return "Simulation aborted: no script path."
|
|
@@ -90,7 +66,6 @@ def run_sandboxed_poc(script_path: str) -> str:
|
|
|
90
66
|
logger.error(f"Script file does not exist: {script_path}")
|
|
91
67
|
return "Simulation aborted: script file not found."
|
|
92
68
|
|
|
93
|
-
# The script must reside in a standard temp directory to prevent arbitrary file reads
|
|
94
69
|
temp_root = Path(tempfile.gettempdir()).resolve()
|
|
95
70
|
try:
|
|
96
71
|
if not script_file.resolve().is_relative_to(temp_root):
|
|
@@ -100,35 +75,27 @@ def run_sandboxed_poc(script_path: str) -> str:
|
|
|
100
75
|
logger.error(f"Path resolution error for script: {e}")
|
|
101
76
|
return "Simulation aborted: invalid script path."
|
|
102
77
|
|
|
103
|
-
# Ensure the script path does not contain characters that could break Docker volume mounting (e.g., ':')
|
|
104
78
|
if ":" in script_path:
|
|
105
79
|
logger.error(f"Script path contains invalid character ':' – {script_path}")
|
|
106
80
|
return "Simulation aborted: invalid characters in script path."
|
|
107
81
|
|
|
108
|
-
# --- Docker Availability Check ---
|
|
109
82
|
if not shutil.which("docker"):
|
|
110
83
|
logger.warning("Docker not found in PATH.")
|
|
111
84
|
return "Docker is not installed or not running. Simulation requires Docker."
|
|
112
85
|
|
|
113
|
-
# --- Build Secure Docker Command ---
|
|
114
|
-
# We use a list of arguments – no shell involvement.
|
|
115
86
|
docker_cmd = [
|
|
116
87
|
"docker", "run",
|
|
117
88
|
"--rm",
|
|
118
|
-
"--user", "nobody",
|
|
119
|
-
"--read-only",
|
|
120
|
-
"--network", "none",
|
|
121
|
-
"--security-opt", "no-new-privileges",
|
|
122
|
-
"--cap-drop", "ALL",
|
|
123
|
-
"-v", f"{script_path}:/poc.sh:ro",
|
|
124
|
-
"alpine",
|
|
89
|
+
"--user", "nobody",
|
|
90
|
+
"--read-only",
|
|
91
|
+
"--network", "none",
|
|
92
|
+
"--security-opt", "no-new-privileges",
|
|
93
|
+
"--cap-drop", "ALL",
|
|
94
|
+
"-v", f"{script_path}:/poc.sh:ro",
|
|
95
|
+
"alpine",
|
|
125
96
|
"sh", "/poc.sh"
|
|
126
97
|
]
|
|
127
98
|
|
|
128
|
-
# Optional: use shlex.quote on the script path for extra safety (though list-based args already protect)
|
|
129
|
-
# docker_cmd[8] = f"{shlex.quote(script_path)}:/poc.sh:ro" # unnecessary but doesn't hurt
|
|
130
|
-
# We'll keep the original because the path is already validated and list args prevent injection.
|
|
131
|
-
|
|
132
99
|
logger.info(f"Launching sandboxed simulation: {' '.join(docker_cmd)}")
|
|
133
100
|
|
|
134
101
|
try:
|
|
@@ -154,6 +121,5 @@ def run_sandboxed_poc(script_path: str) -> str:
|
|
|
154
121
|
return f"Sandbox execution failed (exit {result.returncode}):\n{output.strip()}"
|
|
155
122
|
|
|
156
123
|
logger.success("Sandbox simulation completed successfully.")
|
|
157
|
-
# Clean up the temporary directory after successful execution
|
|
158
124
|
_cleanup_temp_dir(script_file.parent)
|
|
159
125
|
return output.strip()
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
from dotenv import load_dotenv
|
|
4
6
|
from loguru import logger
|
|
5
7
|
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
+
# Locate .env relative to the project root (three levels up from this file)
|
|
9
|
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
10
|
+
_DOTENV_PATH = _PROJECT_ROOT / ".env"
|
|
11
|
+
|
|
12
|
+
if _DOTENV_PATH.exists():
|
|
13
|
+
load_dotenv(_DOTENV_PATH)
|
|
14
|
+
else:
|
|
15
|
+
# Fallback: try current directory (backward compatibility)
|
|
16
|
+
load_dotenv()
|
|
8
17
|
|
|
9
18
|
class Settings:
|
|
10
19
|
"""Centralized configuration management for Pipeline Sentinel."""
|
|
@@ -61,9 +70,15 @@ class Settings:
|
|
|
61
70
|
raise ValueError("PIPELINE_API_KEY value 'disabled' is strictly prohibited.")
|
|
62
71
|
return api_key
|
|
63
72
|
|
|
64
|
-
# Instantiate settings singleton.
|
|
65
|
-
# If validation fails, the app will explicitly crash here at import time (Fail-Fast).
|
|
73
|
+
# Instantiate settings singleton with friendly error message.
|
|
66
74
|
try:
|
|
67
75
|
settings = Settings()
|
|
68
76
|
except ValueError as e:
|
|
69
|
-
|
|
77
|
+
print("\n" + "=" * 60)
|
|
78
|
+
print(" 🚨 Pipeline Sentinel – Configuration Error 🚨")
|
|
79
|
+
print("=" * 60)
|
|
80
|
+
print(f" {e}")
|
|
81
|
+
print(" Please create a .env file in the project root using .env.example as a template.")
|
|
82
|
+
print(" The file must include JWT_SECRET and PIPELINE_API_KEY.")
|
|
83
|
+
print("=" * 60 + "\n")
|
|
84
|
+
sys.exit(1)
|
|
@@ -4,7 +4,8 @@ import os
|
|
|
4
4
|
from flask import Blueprint, jsonify, render_template_string, request, send_file
|
|
5
5
|
|
|
6
6
|
from devsecops_radar.core.auth import require_api_key
|
|
7
|
-
from devsecops_radar.core.database import
|
|
7
|
+
from devsecops_radar.core.database import db_session, get_findings_paginated
|
|
8
|
+
from devsecops_radar.core.models import Scan
|
|
8
9
|
from devsecops_radar.core.rag import rag_search
|
|
9
10
|
from devsecops_radar.core.reporting import generate_pdf_report
|
|
10
11
|
|
|
@@ -12,6 +13,9 @@ dashboard_bp = Blueprint('dashboard', __name__)
|
|
|
12
13
|
|
|
13
14
|
FINDINGS_FILE = os.environ.get('FINDINGS_FILE', 'findings.json')
|
|
14
15
|
|
|
16
|
+
# --------------------------------------------------------------------------
|
|
17
|
+
# HTML template (unchanged except for the JavaScript parts noted below)
|
|
18
|
+
# --------------------------------------------------------------------------
|
|
15
19
|
DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
16
20
|
<html lang="en" data-theme="cyber">
|
|
17
21
|
<head>
|
|
@@ -1572,6 +1576,13 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1572
1576
|
const tMedium = T[CL]?.medium || 'MEDIUM';
|
|
1573
1577
|
const tLow = T[CL]?.low || 'LOW';
|
|
1574
1578
|
|
|
1579
|
+
const safeScans = scans.map(s => ({
|
|
1580
|
+
critical: Number(s.critical) || 0,
|
|
1581
|
+
high: Number(s.high) || 0,
|
|
1582
|
+
medium: Number(s.medium) || 0,
|
|
1583
|
+
low: Number(s.low) || 0
|
|
1584
|
+
}));
|
|
1585
|
+
|
|
1575
1586
|
const option = {
|
|
1576
1587
|
tooltip: {
|
|
1577
1588
|
trigger: 'axis',
|
|
@@ -1608,15 +1619,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1608
1619
|
{
|
|
1609
1620
|
name: tCritical,
|
|
1610
1621
|
type: 'line',
|
|
1611
|
-
data:
|
|
1622
|
+
data: safeScans.map(s => s.critical),
|
|
1612
1623
|
smooth: 0.5,
|
|
1613
1624
|
symbol: 'circle',
|
|
1614
|
-
symbolSize:
|
|
1615
|
-
showSymbol:
|
|
1625
|
+
symbolSize: 6,
|
|
1626
|
+
showSymbol: true,
|
|
1616
1627
|
lineStyle: { width: 4, shadowBlur: 15, shadowColor: 'rgba(255,77,109,0.5)' },
|
|
1617
1628
|
areaStyle: { color: new echarts.graphic.LinearGradient(0,0,0,1,[
|
|
1618
1629
|
{offset:0, color:'rgba(255,77,109,0.8)'},
|
|
1619
|
-
{offset:1, color:'rgba(255,77,109,0.
|
|
1630
|
+
{offset:1, color:'rgba(255,77,109,0.2)'}
|
|
1620
1631
|
])},
|
|
1621
1632
|
itemStyle: { color: '#FF4D6D', borderColor: '#fff', borderWidth: 2 },
|
|
1622
1633
|
emphasis: { focus: 'series' }
|
|
@@ -1624,15 +1635,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1624
1635
|
{
|
|
1625
1636
|
name: tHigh,
|
|
1626
1637
|
type: 'line',
|
|
1627
|
-
data:
|
|
1638
|
+
data: safeScans.map(s => s.high),
|
|
1628
1639
|
smooth: 0.5,
|
|
1629
1640
|
symbol: 'circle',
|
|
1630
|
-
symbolSize:
|
|
1631
|
-
showSymbol:
|
|
1641
|
+
symbolSize: 6,
|
|
1642
|
+
showSymbol: true,
|
|
1632
1643
|
lineStyle: { width: 4, shadowBlur: 15, shadowColor: 'rgba(255,177,0,0.5)' },
|
|
1633
1644
|
areaStyle: { color: new echarts.graphic.LinearGradient(0,0,0,1,[
|
|
1634
1645
|
{offset:0, color:'rgba(255,177,0,0.8)'},
|
|
1635
|
-
{offset:1, color:'rgba(255,177,0,0.
|
|
1646
|
+
{offset:1, color:'rgba(255,177,0,0.2)'}
|
|
1636
1647
|
])},
|
|
1637
1648
|
itemStyle: { color: '#FFB100', borderColor: '#fff', borderWidth: 2 },
|
|
1638
1649
|
emphasis: { focus: 'series' }
|
|
@@ -1640,15 +1651,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1640
1651
|
{
|
|
1641
1652
|
name: tMedium,
|
|
1642
1653
|
type: 'line',
|
|
1643
|
-
data:
|
|
1654
|
+
data: safeScans.map(s => s.medium),
|
|
1644
1655
|
smooth: 0.5,
|
|
1645
1656
|
symbol: 'circle',
|
|
1646
|
-
symbolSize:
|
|
1647
|
-
showSymbol:
|
|
1657
|
+
symbolSize: 6,
|
|
1658
|
+
showSymbol: true,
|
|
1648
1659
|
lineStyle: { width: 4, shadowBlur: 15, shadowColor: 'rgba(0,180,216,0.5)' },
|
|
1649
1660
|
areaStyle: { color: new echarts.graphic.LinearGradient(0,0,0,1,[
|
|
1650
1661
|
{offset:0, color:'rgba(0,180,216,0.8)'},
|
|
1651
|
-
{offset:1, color:'rgba(0,180,216,0.
|
|
1662
|
+
{offset:1, color:'rgba(0,180,216,0.2)'}
|
|
1652
1663
|
])},
|
|
1653
1664
|
itemStyle: { color: '#00B4D8', borderColor: '#fff', borderWidth: 2 },
|
|
1654
1665
|
emphasis: { focus: 'series' }
|
|
@@ -1656,15 +1667,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1656
1667
|
{
|
|
1657
1668
|
name: tLow,
|
|
1658
1669
|
type: 'line',
|
|
1659
|
-
data:
|
|
1670
|
+
data: safeScans.map(s => s.low),
|
|
1660
1671
|
smooth: 0.5,
|
|
1661
1672
|
symbol: 'circle',
|
|
1662
|
-
symbolSize:
|
|
1663
|
-
showSymbol:
|
|
1673
|
+
symbolSize: 6,
|
|
1674
|
+
showSymbol: true,
|
|
1664
1675
|
lineStyle: { width: 4, shadowBlur: 15, shadowColor: 'rgba(6,214,160,0.5)' },
|
|
1665
1676
|
areaStyle: { color: new echarts.graphic.LinearGradient(0,0,0,1,[
|
|
1666
1677
|
{offset:0, color:'rgba(6,214,160,0.8)'},
|
|
1667
|
-
{offset:1, color:'rgba(6,214,160,0.
|
|
1678
|
+
{offset:1, color:'rgba(6,214,160,0.2)'}
|
|
1668
1679
|
])},
|
|
1669
1680
|
itemStyle: { color: '#06D6A0', borderColor: '#fff', borderWidth: 2 },
|
|
1670
1681
|
emphasis: { focus: 'series' }
|
|
@@ -1724,7 +1735,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1724
1735
|
MEDIUM: '#00B4D8',
|
|
1725
1736
|
LOW: '#06D6A0'
|
|
1726
1737
|
};
|
|
1727
|
-
return colors[d.severity] || '
|
|
1738
|
+
return colors[d.severity] || 'var(--accent)';
|
|
1728
1739
|
})
|
|
1729
1740
|
.style('cursor', 'pointer')
|
|
1730
1741
|
.style('filter', 'drop-shadow(0 0 8px currentColor)')
|
|
@@ -1738,7 +1749,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1738
1749
|
<div>
|
|
1739
1750
|
<strong style="font-size:1.2rem; color:var(--accent);">${d.id}</strong><br>
|
|
1740
1751
|
<span class="badge bg-${sColor} mt-2 mb-2">${d.severity}</span><br>
|
|
1741
|
-
<span style="color:var(--text); font-weight:600;">${d.title}</span>
|
|
1752
|
+
<span style="color:var(--text); font-weight:600;">${d.title}</span><br>
|
|
1753
|
+
<small style="color:var(--text-secondary);">Target: ${d.target}</small>
|
|
1742
1754
|
</div>
|
|
1743
1755
|
<button class="btn-accent shadow-lg" onclick="simulateAttack(['${d.id}'])">
|
|
1744
1756
|
${btnTxt}
|
|
@@ -1842,7 +1854,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1842
1854
|
.then(r => r.json())
|
|
1843
1855
|
.then(sc => {
|
|
1844
1856
|
if (sc.length) {
|
|
1845
|
-
const labels = sc.map(s => s.timestamp.substring(0, 10));
|
|
1857
|
+
const labels = sc.map(s => s.timestamp ? s.timestamp.substring(0, 10) : '');
|
|
1846
1858
|
createTrendChart(labels, sc);
|
|
1847
1859
|
}
|
|
1848
1860
|
});
|
|
@@ -1901,14 +1913,14 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
|
1901
1913
|
fetch('/api/attack-paths', { headers: getHeaders() })
|
|
1902
1914
|
.then(r => r.json())
|
|
1903
1915
|
.then(d => {
|
|
1904
|
-
if (d.
|
|
1916
|
+
if (d.nodes && d.nodes.length) {
|
|
1917
|
+
drawAttackGraph(d);
|
|
1918
|
+
} else {
|
|
1905
1919
|
const errEl = document.getElementById('attack-error');
|
|
1906
1920
|
if (errEl) {
|
|
1907
1921
|
errEl.style.display = 'block';
|
|
1908
|
-
errEl.textContent = '⚠️ ' + (
|
|
1922
|
+
errEl.textContent = '⚠️ ' + (d.error || 'No findings to display.');
|
|
1909
1923
|
}
|
|
1910
|
-
} else if (d.nodes && d.nodes.length) {
|
|
1911
|
-
drawAttackGraph(d);
|
|
1912
1924
|
}
|
|
1913
1925
|
});
|
|
1914
1926
|
|
|
@@ -2044,7 +2056,55 @@ def api_findings():
|
|
|
2044
2056
|
@dashboard_bp.route('/api/history')
|
|
2045
2057
|
@require_api_key
|
|
2046
2058
|
def api_history():
|
|
2047
|
-
|
|
2059
|
+
session = db_session()
|
|
2060
|
+
try:
|
|
2061
|
+
scans = session.query(Scan).order_by(Scan.timestamp.desc()).all()
|
|
2062
|
+
result = []
|
|
2063
|
+
for s in scans:
|
|
2064
|
+
counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
2065
|
+
for f in s.findings:
|
|
2066
|
+
sev = str(f.severity).upper()
|
|
2067
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
2068
|
+
result.append({
|
|
2069
|
+
"timestamp": s.timestamp.isoformat() if s.timestamp else None,
|
|
2070
|
+
"risk_score": s.risk_score,
|
|
2071
|
+
"critical": counts["CRITICAL"],
|
|
2072
|
+
"high": counts["HIGH"],
|
|
2073
|
+
"medium": counts["MEDIUM"],
|
|
2074
|
+
"low": counts["LOW"],
|
|
2075
|
+
})
|
|
2076
|
+
return jsonify(result)
|
|
2077
|
+
finally:
|
|
2078
|
+
session.close()
|
|
2079
|
+
|
|
2080
|
+
@dashboard_bp.route('/api/attack-paths')
|
|
2081
|
+
@require_api_key
|
|
2082
|
+
def api_attack_paths():
|
|
2083
|
+
"""
|
|
2084
|
+
Generate an interactive graph of all findings (nodes = findings, links = simple chain).
|
|
2085
|
+
"""
|
|
2086
|
+
findings = load_findings()
|
|
2087
|
+
if not findings:
|
|
2088
|
+
return jsonify({"nodes": [], "links": []})
|
|
2089
|
+
|
|
2090
|
+
nodes = []
|
|
2091
|
+
links = []
|
|
2092
|
+
for f in findings:
|
|
2093
|
+
node_id = f.get("id", "UNKNOWN")
|
|
2094
|
+
nodes.append({
|
|
2095
|
+
"id": node_id,
|
|
2096
|
+
"severity": f.get("severity", "LOW").upper(),
|
|
2097
|
+
"title": f.get("title", ""),
|
|
2098
|
+
"description": f.get("description", ""),
|
|
2099
|
+
"target": f.get("target", ""),
|
|
2100
|
+
"tool": f.get("tool", ""),
|
|
2101
|
+
})
|
|
2102
|
+
|
|
2103
|
+
# simple chain links to make the graph visually connected
|
|
2104
|
+
for i in range(len(nodes) - 1):
|
|
2105
|
+
links.append({"source": nodes[i]["id"], "target": nodes[i+1]["id"]})
|
|
2106
|
+
|
|
2107
|
+
return jsonify({"nodes": nodes, "links": links})
|
|
2048
2108
|
|
|
2049
2109
|
@dashboard_bp.route('/api/rag')
|
|
2050
2110
|
@require_api_key
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devsecops-radar
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -320,6 +320,9 @@ devsecops-radar --trivy trivy.json --analyze
|
|
|
320
320
|
devsecops-radar-web
|
|
321
321
|
```
|
|
322
322
|
The LLM generates `findings_ai_summary.json` containing: `executive_summary`, `risk_score`, `attack_paths` (with MITRE ATT&CK), `top_remediations`, and `false_positives_likely`.
|
|
323
|
+
|
|
324
|
+

|
|
325
|
+
|
|
323
326
|
</details>
|
|
324
327
|
|
|
325
328
|
<details>
|
|
@@ -505,6 +508,8 @@ devsecops-radar --trivy scan.json --rules ~/.devsecops-radar/community-rules/
|
|
|
505
508
|
|
|
506
509
|
*(You can also click any node in the Attack Path Graph and press **“Simulate this attack”**)*.
|
|
507
510
|
|
|
511
|
+

|
|
512
|
+
|
|
508
513
|
---
|
|
509
514
|
|
|
510
515
|
## 🔐 Security Improvements in v0.4.2
|
|
@@ -99,7 +99,7 @@ class TestBuildPrompt:
|
|
|
99
99
|
analyzer = DummyAnalyzer("test-model")
|
|
100
100
|
findings = [{"id": "1", "severity": "high"}]
|
|
101
101
|
prompt = analyzer._build_prompt(findings)
|
|
102
|
-
assert "
|
|
102
|
+
assert "Analyze the following security findings." in prompt
|
|
103
103
|
assert "<FINDINGS_DATA>" in prompt
|
|
104
104
|
# Use same indent as source: indent=2
|
|
105
105
|
assert json.dumps(findings, indent=2) in prompt
|
|
@@ -67,7 +67,7 @@ class TestSimulateAttack:
|
|
|
67
67
|
assert "#!/bin/bash" in written
|
|
68
68
|
assert "RCE-001" in written
|
|
69
69
|
assert "Remote Code Execution" in written
|
|
70
|
-
mock_chmod.assert_called_once_with(expected_path,
|
|
70
|
+
mock_chmod.assert_called_once_with(expected_path, 0o755) # <-- changed to 0o755
|
|
71
71
|
mock_debug.assert_called_once()
|
|
72
72
|
|
|
73
73
|
def test_invalid_finding_not_dict(self):
|
|
@@ -130,7 +130,7 @@ class TestGenerateDummyScript:
|
|
|
130
130
|
written = handle.write.call_args[0][0]
|
|
131
131
|
assert "#!/bin/bash" in written
|
|
132
132
|
assert "Simulation skipped: Some reason" in written
|
|
133
|
-
mock_chmod.assert_called_once_with(expected_path,
|
|
133
|
+
mock_chmod.assert_called_once_with(expected_path, 0o755) # <-- changed to 0o755
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
# -----------------------------------------------
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from unittest.mock import mock_open, patch
|
|
2
|
+
from unittest.mock import MagicMock, mock_open, patch
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
from flask import Flask
|
|
@@ -24,20 +24,19 @@ class TestLoadFindings:
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
# ------------------------------------------------------------
|
|
27
|
-
#
|
|
27
|
+
# Flask app fixture with API key and patched auth
|
|
28
28
|
# ------------------------------------------------------------
|
|
29
29
|
@pytest.fixture
|
|
30
30
|
def app(monkeypatch):
|
|
31
|
-
"""Create app with API key set to 'testkey' and monkeypatch settings."""
|
|
32
31
|
monkeypatch.setenv("PIPELINE_API_KEY", "testkey")
|
|
33
|
-
# Force settings to reload the key (since settings is a singleton already imported)
|
|
34
32
|
from devsecops_radar.core.settings import settings
|
|
35
33
|
settings.PIPELINE_API_KEY = "testkey"
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
with patch("devsecops_radar.web.dashboard.routes.require_api_key", lambda f: f):
|
|
36
|
+
app = Flask(__name__)
|
|
37
|
+
app.config["TESTING"] = True
|
|
38
|
+
app.register_blueprint(dashboard_bp)
|
|
39
|
+
yield app
|
|
41
40
|
|
|
42
41
|
|
|
43
42
|
@pytest.fixture
|
|
@@ -45,7 +44,6 @@ def client(app):
|
|
|
45
44
|
return app.test_client()
|
|
46
45
|
|
|
47
46
|
|
|
48
|
-
# Helper to add auth header
|
|
49
47
|
def auth_headers():
|
|
50
48
|
return {"X-API-Key": "testkey"}
|
|
51
49
|
|
|
@@ -55,8 +53,7 @@ def auth_headers():
|
|
|
55
53
|
# ------------------------------------------------------------
|
|
56
54
|
class TestIndex:
|
|
57
55
|
def test_returns_200(self, client):
|
|
58
|
-
with patch("devsecops_radar.web.dashboard.routes.load_findings",
|
|
59
|
-
return_value=[]):
|
|
56
|
+
with patch("devsecops_radar.web.dashboard.routes.load_findings", return_value=[]):
|
|
60
57
|
resp = client.get("/")
|
|
61
58
|
assert resp.status_code == 200
|
|
62
59
|
assert b"Pipeline Sentinel" in resp.data
|
|
@@ -70,8 +67,7 @@ class TestApiFindings:
|
|
|
70
67
|
mock_data = {"data": [], "total": 0, "page": 1, "per_page": 50}
|
|
71
68
|
with patch("devsecops_radar.web.dashboard.routes.get_findings_paginated",
|
|
72
69
|
return_value=mock_data) as mock_fn:
|
|
73
|
-
resp = client.get("/api/findings?page=2&per_page=10",
|
|
74
|
-
headers=auth_headers())
|
|
70
|
+
resp = client.get("/api/findings?page=2&per_page=10", headers=auth_headers())
|
|
75
71
|
assert resp.status_code == 200
|
|
76
72
|
mock_fn.assert_called_once_with(2, 10)
|
|
77
73
|
assert resp.json == mock_data
|
|
@@ -85,16 +81,54 @@ class TestApiFindings:
|
|
|
85
81
|
|
|
86
82
|
|
|
87
83
|
# ------------------------------------------------------------
|
|
88
|
-
# GET /api/history
|
|
84
|
+
# GET /api/history (new version)
|
|
89
85
|
# ------------------------------------------------------------
|
|
90
86
|
class TestApiHistory:
|
|
91
87
|
def test_returns_history(self, client):
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
mock_finding = MagicMock()
|
|
89
|
+
mock_finding.severity = "HIGH"
|
|
90
|
+
mock_scan = MagicMock()
|
|
91
|
+
mock_scan.timestamp = None
|
|
92
|
+
mock_scan.risk_score = 80
|
|
93
|
+
mock_scan.findings = [mock_finding]
|
|
94
|
+
|
|
95
|
+
mock_session = MagicMock()
|
|
96
|
+
mock_session.query.return_value.order_by.return_value.all.return_value = [mock_scan]
|
|
97
|
+
mock_session.close = MagicMock()
|
|
98
|
+
|
|
99
|
+
with patch("devsecops_radar.web.dashboard.routes.db_session", return_value=mock_session):
|
|
95
100
|
resp = client.get("/api/history", headers=auth_headers())
|
|
96
101
|
assert resp.status_code == 200
|
|
97
|
-
|
|
102
|
+
data = resp.json
|
|
103
|
+
assert len(data) == 1
|
|
104
|
+
assert data[0]["risk_score"] == 80
|
|
105
|
+
assert data[0]["high"] == 1
|
|
106
|
+
assert data[0]["critical"] == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------
|
|
110
|
+
# GET /api/attack-paths (new endpoint)
|
|
111
|
+
# ------------------------------------------------------------
|
|
112
|
+
class TestApiAttackPaths:
|
|
113
|
+
def test_returns_graph_from_findings(self, client):
|
|
114
|
+
findings = [
|
|
115
|
+
{"id": "R1", "severity": "HIGH", "title": "SQLi", "target": "app.py", "tool": "semgrep"},
|
|
116
|
+
{"id": "R2", "severity": "MEDIUM", "title": "XSS", "target": "views.py", "tool": "trivy"},
|
|
117
|
+
]
|
|
118
|
+
with patch("devsecops_radar.web.dashboard.routes.load_findings", return_value=findings):
|
|
119
|
+
resp = client.get("/api/attack-paths", headers=auth_headers())
|
|
120
|
+
assert resp.status_code == 200
|
|
121
|
+
data = resp.json
|
|
122
|
+
assert len(data["nodes"]) == 2
|
|
123
|
+
assert len(data["links"]) == 1
|
|
124
|
+
assert data["nodes"][0]["severity"] == "HIGH"
|
|
125
|
+
assert data["nodes"][1]["severity"] == "MEDIUM"
|
|
126
|
+
|
|
127
|
+
def test_no_findings_returns_empty(self, client):
|
|
128
|
+
with patch("devsecops_radar.web.dashboard.routes.load_findings", return_value=[]):
|
|
129
|
+
resp = client.get("/api/attack-paths", headers=auth_headers())
|
|
130
|
+
assert resp.status_code == 200
|
|
131
|
+
assert resp.json == {"nodes": [], "links": []}
|
|
98
132
|
|
|
99
133
|
|
|
100
134
|
# ------------------------------------------------------------
|
|
@@ -128,8 +162,7 @@ class TestApiSimulate:
|
|
|
128
162
|
def test_findings_not_found(self, client):
|
|
129
163
|
with patch("devsecops_radar.web.dashboard.routes.load_findings",
|
|
130
164
|
return_value=[{"id": "R1"}]):
|
|
131
|
-
resp = client.post("/api/simulate", json={"finding_ids": ["R2"]},
|
|
132
|
-
headers=auth_headers())
|
|
165
|
+
resp = client.post("/api/simulate", json={"finding_ids": ["R2"]}, headers=auth_headers())
|
|
133
166
|
assert resp.status_code == 404
|
|
134
167
|
assert resp.json["error"] == "Not found"
|
|
135
168
|
|
|
@@ -143,8 +176,7 @@ class TestApiSimulate:
|
|
|
143
176
|
patch("devsecops_radar.core.attack_simulation.run_sandboxed_poc",
|
|
144
177
|
return_value="sandbox output"), \
|
|
145
178
|
patch("builtins.open", mock_open(read_data=mock_script)):
|
|
146
|
-
resp = client.post("/api/simulate", json={"finding_ids": ["R1"]},
|
|
147
|
-
headers=auth_headers())
|
|
179
|
+
resp = client.post("/api/simulate", json={"finding_ids": ["R1"]}, headers=auth_headers())
|
|
148
180
|
assert resp.status_code == 200
|
|
149
181
|
data = resp.json
|
|
150
182
|
assert mock_script in data["script"]
|
|
@@ -160,8 +192,7 @@ class TestApiSimulate:
|
|
|
160
192
|
patch("devsecops_radar.core.attack_simulation.run_sandboxed_poc",
|
|
161
193
|
side_effect=Exception("docker missing")), \
|
|
162
194
|
patch("builtins.open", mock_open(read_data="script")):
|
|
163
|
-
resp = client.post("/api/simulate", json={"finding_ids": ["R1"]},
|
|
164
|
-
headers=auth_headers())
|
|
195
|
+
resp = client.post("/api/simulate", json={"finding_ids": ["R1"]}, headers=auth_headers())
|
|
165
196
|
assert resp.status_code == 200
|
|
166
197
|
assert resp.json["sandbox_output"] is None
|
|
167
198
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/attack_paths/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/css/bootstrap.min.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/chart.umd.min.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devsecops_radar-0.4.2 → devsecops_radar-0.4.3}/devsecops_radar/web/static/js/echarts.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.4.2 → devsecops_radar-0.4.3}/devsecops_radar.egg-info/dependency_links.txt
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|