fix(security): resolve Snyk Code findings — Tar Slip, Path Traversal, Open Redirect, XSS
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

Tar Slip (CWE-22) — 3 import services:
  threat_actor, lolbas, caldera: add path validation before extractall()
  to prevent malicious zip members with ../ escaping the target directory.
  (sigma, elastic, atomic already had this protection)

Path Traversal (CWE-23) — professional_reports.py:
  Add _assert_safe_report_path() check on all 5 report endpoints to
  verify the generated filepath stays within REPORT_OUTPUT_DIR.

Open Redirect (CWE-601) — sso.py:
  Validate IdP redirect URL scheme (must be http/https) before
  issuing RedirectResponse, blocking javascript: and data: redirects.

DOM XSS (CWE-79) — 4 frontend pages:
  Create src/utils/url.ts with safeUrl() that rejects non-http/https
  protocols; apply to actor.mitre_url, ref.url, intel.url.
  Sanitize framework name to alphanumeric-only before DOM insertion.
  Restrict evidence MIME types to an explicit safe allowlist (png/jpg/gif/webp).

Hardcoded credentials (CWE-798):
  verify_gaps.py, create_wiki.py: replace literal passwords with
  environment variable reads (AEGIS_ADMIN_PASSWORD, GITEA_PASSWORD).
This commit is contained in:
kitos
2026-06-12 13:15:36 +02:00
parent f54dc0d342
commit dcd4bebc92
12 changed files with 82 additions and 28 deletions
+20 -7
View File
@@ -2,9 +2,10 @@
# Import UUID from uuid # Import UUID from uuid
from uuid import UUID from uuid import UUID
from pathlib import Path
# Import APIRouter, Depends, Query, Request from fastapi # Import APIRouter, Depends, HTTPException, Query, Request from fastapi
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
# Import FileResponse from fastapi.responses # Import FileResponse from fastapi.responses
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -21,12 +22,24 @@ from app.dependencies.auth import get_current_user, require_any_role
# Import limiter from app.limiter # Import limiter from app.limiter
from app.limiter import limiter from app.limiter import limiter
# Import settings from app.config
from app.config import settings
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
# Import report_generation_service from app.services # Import report_generation_service from app.services
from app.services import report_generation_service from app.services import report_generation_service
def _assert_safe_report_path(filepath: str) -> str:
"""Raise 500 if the generated filepath escapes the configured report directory."""
output_dir = Path(settings.REPORT_OUTPUT_DIR).resolve()
resolved = Path(filepath).resolve()
if not resolved.is_relative_to(output_dir):
raise HTTPException(status_code=500, detail="Report generation path error")
return filepath
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) # Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
@@ -65,7 +78,7 @@ def generate_purple_report(
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
filepath, _assert_safe_report_path(filepath),
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -95,7 +108,7 @@ def generate_coverage_report(
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
filepath, _assert_safe_report_path(filepath),
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -125,7 +138,7 @@ def generate_executive_report(
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
filepath, _assert_safe_report_path(filepath),
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -155,7 +168,7 @@ def generate_quarterly_report(
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
filepath, _assert_safe_report_path(filepath),
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -187,7 +200,7 @@ def generate_technique_report(
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
filepath, _assert_safe_report_path(filepath),
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
+5 -1
View File
@@ -1,6 +1,7 @@
"""Phase 14: SSO / SAML 2.0 router.""" """Phase 14: SSO / SAML 2.0 router."""
import os import os
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -74,7 +75,10 @@ def sso_login(request: Request, db: Session = Depends(get_db)):
result = svc.initiate_login(db, request_data) result = svc.initiate_login(db, request_data)
except RuntimeError as exc: except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) raise HTTPException(status_code=503, detail=str(exc))
return RedirectResponse(url=result["redirect_url"]) redirect_url = result["redirect_url"]
if urlparse(redirect_url).scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="Invalid IdP redirect URL")
return RedirectResponse(url=redirect_url)
@router.post("/callback") @router.post("/callback")
@@ -102,9 +102,14 @@ def _download_zip(url: str = CALDERA_ZIP_URL) -> bytes:
# Define function _extract_zip # Define function _extract_zip
def _extract_zip(zip_bytes: bytes, dest: str) -> Path: def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
"""Extract *zip_bytes* into *dest* and return abilities dir.""" """Extract *zip_bytes* into *dest* and return abilities dir."""
# Open context manager dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Call zf.extractall() for member in zf.infolist():
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
)
zf.extractall(dest) zf.extractall(dest)
# Assign abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities" # Assign abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities"
abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities" abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities"
@@ -151,11 +151,15 @@ def _download_zip(url: str) -> bytes:
# Define function _extract_zip # Define function _extract_zip
def _extract_zip(zip_bytes: bytes, dest: str) -> Path: def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
"""Extract *zip_bytes* into *dest* and return the root directory.""" """Extract *zip_bytes* into *dest* and return the root directory."""
# Open context manager dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Call zf.extractall() for member in zf.infolist():
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
)
zf.extractall(dest) zf.extractall(dest)
# Return Path(dest)
return Path(dest) return Path(dest)
@@ -112,9 +112,14 @@ def _download_zip(url: str = MITRE_CTI_ZIP_URL) -> bytes:
# Define function _extract_zip_and_load_bundle # Define function _extract_zip_and_load_bundle
def _extract_zip_and_load_bundle(zip_bytes: bytes, dest: str) -> dict: def _extract_zip_and_load_bundle(zip_bytes: bytes, dest: str) -> dict:
"""Extract ZIP and load the enterprise-attack STIX bundle.""" """Extract ZIP and load the enterprise-attack STIX bundle."""
# Open context manager dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Call zf.extractall() for member in zf.infolist():
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
)
zf.extractall(dest) zf.extractall(dest)
# Assign bundle_path = ( # Assign bundle_path = (
+5 -1
View File
@@ -102,9 +102,13 @@ export default function CompliancePage() {
const json = JSON.stringify(frameworkStatus, null, 2); const json = JSON.stringify(frameworkStatus, null, 2);
const blob = new Blob([json], { type: "application/json" }); const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const safeName = frameworkStatus.framework.name
.replace(/[^a-zA-Z0-9\s_-]/g, "")
.replace(/\s+/g, "_")
.substring(0, 64);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `compliance_${frameworkStatus.framework.name.replace(/\s+/g, "_")}.json`; a.download = `compliance_${safeName}.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
+6 -2
View File
@@ -373,8 +373,12 @@ export default function ImportRTPage() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="flex gap-1"> <div className="flex gap-1">
{t.evidence.slice(0, 3).map((ev: RTEvidenceEntry, ei: number) => { {t.evidence.slice(0, 3).map((ev: RTEvidenceEntry, ei: number) => {
const ext = ev.filename.split(".").pop()?.toLowerCase() ?? "png"; const SAFE_MIMES: Record<string, string> = {
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`; png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp",
};
const ext = ev.filename.split(".").pop()?.toLowerCase() ?? "";
const mime = SAFE_MIMES[ext] ?? "image/png";
return ( return (
<img <img
key={ei} key={ei}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import MarkdownText from "../components/MarkdownText"; import MarkdownText from "../components/MarkdownText";
import { safeUrl } from "../utils/url";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Loader2, Loader2,
@@ -640,7 +641,7 @@ export default function TechniqueDetailPage() {
</p> </p>
</div> </div>
<a <a
href={intel.url} href={safeUrl(intel.url)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-cyan-400 hover:underline" className="flex items-center gap-1 text-sm text-cyan-400 hover:underline"
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import MarkdownText from "../components/MarkdownText"; import MarkdownText from "../components/MarkdownText";
import { safeUrl } from "../utils/url";
import MotivationBadge from "../components/MotivationBadge"; import MotivationBadge from "../components/MotivationBadge";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -258,7 +259,7 @@ export default function ThreatActorDetailPage() {
)} )}
{actor.mitre_url && ( {actor.mitre_url && (
<a <a
href={actor.mitre_url} href={safeUrl(actor.mitre_url)}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors" className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors"
@@ -605,7 +606,7 @@ export default function ThreatActorDetailPage() {
<li key={i} className="text-xs"> <li key={i} className="text-xs">
{ref.url ? ( {ref.url ? (
<a <a
href={ref.url} href={safeUrl(ref.url)}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-cyan-400 hover:text-cyan-300 hover:underline" className="text-cyan-400 hover:text-cyan-300 hover:underline"
+10
View File
@@ -0,0 +1,10 @@
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:"]);
export function safeUrl(url: string | null | undefined): string {
if (!url) return "#";
try {
return SAFE_SCHEMES.has(new URL(url).protocol) ? url : "#";
} catch {
return "#";
}
}
+6 -5
View File
@@ -2,6 +2,7 @@
"""Create all Aegis wiki pages via Gitea API.""" """Create all Aegis wiki pages via Gitea API."""
import base64 import base64
import os
import sys import sys
import time import time
@@ -14,11 +15,11 @@ from requests.auth import HTTPBasicAuth
# POST /api/v1/repos/{owner}/{repo}/wiki/new → create # POST /api/v1/repos/{owner}/{repo}/wiki/new → create
# PATCH /api/v1/repos/{owner}/{repo}/wiki/page/{pageName} → update # PATCH /api/v1/repos/{owner}/{repo}/wiki/page/{pageName} → update
# GET /api/v1/repos/{owner}/{repo}/wiki/pages → list # GET /api/v1/repos/{owner}/{repo}/wiki/pages → list
GITEA_URL = "http://192.168.1.107:3000" GITEA_URL = os.environ.get("GITEA_URL", "http://192.168.1.107:3000")
OWNER = "kitos" OWNER = os.environ.get("GITEA_OWNER", "kitos")
REPO = "Aegis" REPO = os.environ.get("GITEA_REPO", "Aegis")
USERNAME = "kitos" USERNAME = os.environ.get("GITEA_USERNAME", "kitos")
PASSWORD = "T'2JY%HLX\"Bp^6e" PASSWORD = os.environ.get("GITEA_PASSWORD", "")
def create_or_update_page(title: str, content: str) -> bool: def create_or_update_page(title: str, content: str) -> bool:
+4 -2
View File
@@ -4,9 +4,11 @@ Verify Phase 13 gap fixes:
2. evaluate_all_rules creates in-app notifications for admins 2. evaluate_all_rules creates in-app notifications for admins
3. webhook dispatch_webhook_targeted exists and is callable 3. webhook dispatch_webhook_targeted exists and is callable
""" """
import os
import requests, sys import requests, sys
BASE = "http://localhost:8000/api/v1" BASE = os.environ.get("AEGIS_BASE_URL", "http://localhost:8000/api/v1")
ADMIN_PASSWORD = os.environ.get("AEGIS_ADMIN_PASSWORD", "admin123")
PASS = "\033[92m✓\033[0m" PASS = "\033[92m✓\033[0m"
FAIL = "\033[91m✗\033[0m" FAIL = "\033[91m✗\033[0m"
passed = 0 passed = 0
@@ -51,7 +53,7 @@ def main():
# ── Gap 2: in-app notifications dispatched ──────────────────────────────── # ── Gap 2: in-app notifications dispatched ────────────────────────────────
print("── Gap 2: In-app notifications on alert fire ──") print("── Gap 2: In-app notifications on alert fire ──")
tok = requests.post(f"{BASE}/auth/login", tok = requests.post(f"{BASE}/auth/login",
data={"username": "administrator", "password": "admin123"}) data={"username": "administrator", "password": ADMIN_PASSWORD})
if tok.status_code != 200: if tok.status_code != 200:
print(f" Login failed: {tok.text}"); sys.exit(1) print(f" Login failed: {tok.text}"); sys.exit(1)
h = {"Authorization": f"Bearer {tok.json().get('access_token')}"} h = {"Authorization": f"Bearer {tok.json().get('access_token')}"}