diff --git a/suite-api/apps/api/analytics_router.py b/suite-api/apps/api/analytics_router.py index 8a9ebd5c0..c85a7733a 100644 --- a/suite-api/apps/api/analytics_router.py +++ b/suite-api/apps/api/analytics_router.py @@ -1103,6 +1103,44 @@ async def executive_summary(request: Request) -> Dict[str, Any]: resolution_rate = round(resolved / max(total, 1) * 100, 1) risk_score = min(100, by_severity["critical"] * 25 + by_severity["high"] * 10 + by_severity["medium"] * 3) + # Real MTTR from analytics DB + mttr_hours = None + try: + mttr_hours = db.calculate_mttr() + except Exception: + mttr_hours = None + + # SLA compliance: % of open findings within SLA window (days by severity) + _SLA_DAYS = {"critical": 7, "high": 30, "medium": 90, "low": 180} + sla_breached = 0 + sla_total_open = 0 + _now = datetime.now(timezone.utc) + for f in findings: + st = f.status.value if hasattr(f.status, "value") else str(f.status) + if st.lower() not in ("open", "in_progress"): + continue + sla_total_open += 1 + sev = (f.severity.value if hasattr(f.severity, "value") else str(f.severity)).lower() + sla_window = _SLA_DAYS.get(sev, 90) + created_at = getattr(f, "created_at", None) + if created_at: + try: + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + age_days = (_now - created_at).days + if age_days > sla_window: + sla_breached += 1 + except (ValueError, TypeError): + pass + sla_compliance_pct = round( + (1 - sla_breached / max(sla_total_open, 1)) * 100, 1 + ) if sla_total_open else 100.0 + + # False positive rate + fp_rate = round(by_status.get("false_positive", 0) / max(total, 1) * 100, 1) + return { "status": "ok", "total_findings": total, @@ -1112,11 +1150,19 @@ async def executive_summary(request: Request) -> Dict[str, Any]: "risk_score": risk_score, "risk_level": "critical" if risk_score >= 75 else "high" if risk_score >= 50 else "medium" if risk_score >= 25 else "low", "resolution_rate": resolution_rate, + "sla": { + "compliant_pct": sla_compliance_pct, + "breached_count": sla_breached, + "tracked_open": sla_total_open, + "thresholds_days": _SLA_DAYS, + }, "kpis": { - "mttr_hours": 0, - "false_positive_rate": round(by_status.get("false_positive", 0) / max(total, 1) * 100, 1), - "sla_compliance": round(resolution_rate, 1), + "mttr_hours": round(mttr_hours, 2) if mttr_hours is not None else None, + "mttr_days": round(mttr_hours / 24, 2) if mttr_hours is not None else None, + "false_positive_rate": fp_rate, + "sla_compliance": sla_compliance_pct, "scanner_coverage": len(by_scanner), + "resolution_rate": resolution_rate, }, } diff --git a/suite-core/core/autofix_templates.py b/suite-core/core/autofix_templates.py index e7d5b7a8f..cbccbc0ae 100644 --- a/suite-core/core/autofix_templates.py +++ b/suite-core/core/autofix_templates.py @@ -881,6 +881,533 @@ class FixTemplate: "sensitive data exposure", "verbose error", "debug info", ], ), + + + # ========================================================================= + # CWE-287: Improper Authentication + # ========================================================================= + FixTemplate( + cwe_id="CWE-287", + cwe_name="Improper Authentication", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"is_authenticated\s*=\s*True", + r"skip_auth\s*=\s*True", + ], + fix_description=( + "Authentication is missing, bypassed, or insufficiently verified. " + "Attackers can impersonate users or access protected resources without " + "valid credentials. Add decorator-based or explicit authentication guards. " + "This is OWASP Top 10 A07:2021 - Identification and Authentication Failures." + ), + fix_snippets={ + "python": { + "before": ( + "# Missing: no authentication check before sensitive operation\n" + "def get_user_data(user_id: int):\n" + " return db.query(User).filter(User.id == user_id).first()" + ), + "after": ( + "from functools import wraps\n" + "from flask import session, abort\n\n" + "def require_auth(f):\n" + " @wraps(f)\n" + " def decorated(*args, **kwargs):\n" + " if 'user_id' not in session:\n" + " abort(401)\n" + " return f(*args, **kwargs)\n" + " return decorated\n\n" + "@require_auth\n" + "def get_user_data(user_id: int):\n" + " current_user = session['user_id']\n" + " if current_user != user_id and not is_admin(current_user):\n" + " abort(403)\n" + " return db.query(User).filter(User.id == user_id).first()" + ), + }, + "javascript": { + "before": ( + "// Missing: no authentication\n" + "app.get('/api/user/:id', async (req, res) => {\n" + " const user = await User.findById(req.params.id);\n" + " res.json(user);\n" + "});" + ), + "after": ( + "const jwt = require('jsonwebtoken');\n\n" + "function authenticateToken(req, res, next) {\n" + " const token = req.headers['authorization']?.split(' ')[1];\n" + " if (!token) return res.sendStatus(401);\n" + " jwt.verify(token, process.env.JWT_SECRET, (err, user) => {\n" + " if (err) return res.sendStatus(403);\n" + " req.user = user;\n" + " next();\n" + " });\n" + "}\n\n" + "app.get('/api/user/:id', authenticateToken, async (req, res) => {\n" + " if (req.user.id !== req.params.id && !req.user.isAdmin) {\n" + " return res.sendStatus(403);\n" + " }\n" + " const user = await User.findById(req.params.id);\n" + " res.json(user);\n" + "});" + ), + }, + }, + confidence=0.80, + severity="critical", + fix_type="code_patch", + testing_guidance=( + "1. Test unauthenticated requests return 401.\n" + "2. Test requests with invalid/expired tokens return 403.\n" + "3. Test that authenticated users cannot access other users' data.\n" + "4. Verify admin users have appropriate elevated access." + ), + risk_assessment="High impact — authentication is the primary defense layer.", + effort_minutes=30, + mitre_techniques=["T1078", "T1110"], + compliance_refs=["CWE-287", "OWASP A07:2021", "NIST AC-14", "PCI-DSS 8.2"], + title_keywords=[ + "missing authentication", "improper authentication", "auth bypass", + "unauthenticated", "no auth check", "authentication failure", + ], + ), + + # ========================================================================= + # CWE-862: Missing Authorization + # ========================================================================= + FixTemplate( + cwe_id="CWE-862", + cwe_name="Missing Authorization", + languages=["python", "java", "javascript"], + vulnerable_patterns=[ + r"\.find_by_id\s*\(\s*(?:request|req)\.", + r"@login_required\s*\n.*def.*without.*ownership", + ], + fix_description=( + "The application does not perform proper authorization checks before " + "granting access to resources or operations. Authenticated users may " + "access data or functionality they should not. Add ownership and role " + "checks before every resource mutation. OWASP A01:2021." + ), + fix_snippets={ + "python": { + "before": ( + "@login_required\n" + "def update_document(doc_id):\n" + " doc = Document.query.get(doc_id)\n" + " doc.update(request.json)\n" + " return jsonify(doc)" + ), + "after": ( + "@login_required\n" + "def update_document(doc_id):\n" + " doc = Document.query.get_or_404(doc_id)\n" + " # Authorization: owner or admin only\n" + " if doc.owner_id != current_user.id and not current_user.has_role('admin'):\n" + " abort(403, 'Insufficient permissions to modify this document')\n" + " doc.update(request.json)\n" + " return jsonify(doc)" + ), + }, + "java": { + "before": ( + "@GetMapping(\"/documents/{id}\")\n" + "public Document getDocument(@PathVariable Long id) {\n" + " return documentRepository.findById(id).orElseThrow();\n" + "}" + ), + "after": ( + "@GetMapping(\"/documents/{id}\")\n" + "@PreAuthorize(\"hasRole('ADMIN') or @documentService.isOwner(#id, authentication.principal.id)\")\n" + "public Document getDocument(@PathVariable Long id) {\n" + " return documentRepository.findById(id)\n" + " .orElseThrow(() -> new ResourceNotFoundException(\"Document\", id));\n" + "}" + ), + }, + }, + confidence=0.80, + severity="high", + fix_type="code_patch", + testing_guidance=( + "1. Test that users cannot access resources owned by other users.\n" + "2. Test IDOR by substituting resource IDs.\n" + "3. Verify admin users have appropriate elevated access.\n" + "4. Test vertical privilege escalation." + ), + risk_assessment="High impact — broken access control is #1 OWASP risk.", + effort_minutes=25, + mitre_techniques=["T1548", "T1078"], + compliance_refs=["CWE-862", "OWASP A01:2021", "NIST AC-3", "PCI-DSS 7.1"], + title_keywords=[ + "missing authorization", "broken access control", "privilege escalation", + "idor", "insecure direct object", "unauthorized access", "access control", + ], + ), + + # ========================================================================= + # CWE-352: Cross-Site Request Forgery (CSRF) + # ========================================================================= + FixTemplate( + cwe_id="CWE-352", + cwe_name="Cross-Site Request Forgery (CSRF)", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"@app\.route.*methods.*POST", + r"app\.post\s*\(", + ], + fix_description=( + "State-changing requests do not include CSRF tokens, allowing malicious " + "sites to perform actions on behalf of authenticated users. Enable CSRF " + "middleware and require CSRF tokens on all state-changing endpoints." + ), + fix_snippets={ + "python": { + "before": ( + "from flask import Flask, request\n" + "app = Flask(__name__)\n\n" + "@app.route('/transfer', methods=['POST'])\n" + "def transfer_funds():\n" + " amount = request.form['amount']\n" + " process_transfer(amount)" + ), + "after": ( + "from flask import Flask, request\n" + "from flask_wtf.csrf import CSRFProtect\n\n" + "app = Flask(__name__)\n" + "app.config['SECRET_KEY'] = os.environ['SECRET_KEY']\n" + "csrf = CSRFProtect(app) # Applies globally\n\n" + "@app.route('/transfer', methods=['POST'])\n" + "def transfer_funds():\n" + " # CSRF token validated automatically\n" + " amount = request.form['amount']\n" + " process_transfer(amount)" + ), + }, + "javascript": { + "before": ( + "app.post('/api/transfer', async (req, res) => {\n" + " await transferFunds(req.body.amount, req.body.to);\n" + " res.json({ success: true });\n" + "});" + ), + "after": ( + "const csrf = require('csurf');\n" + "const csrfProtection = csrf({ cookie: { httpOnly: true, secure: true } });\n\n" + "app.post('/api/transfer', csrfProtection, async (req, res) => {\n" + " await transferFunds(req.body.amount, req.body.to);\n" + " res.json({ success: true });\n" + "});\n\n" + "app.get('/api/csrf-token', csrfProtection, (req, res) => {\n" + " res.json({ csrfToken: req.csrfToken() });\n" + "});" + ), + }, + }, + confidence=0.80, + severity="medium", + fix_type="code_patch", + testing_guidance=( + "1. Verify POST requests without CSRF token return 403.\n" + "2. Test replay attacks (reusing a token fails).\n" + "3. Verify CSRF tokens are not in URLs or logs.\n" + "4. Test SameSite cookie is set to Strict or Lax." + ), + risk_assessment="Medium impact — prevents cross-origin request forgery.", + effort_minutes=20, + mitre_techniques=["T1185"], + compliance_refs=["CWE-352", "OWASP A01:2021", "PCI-DSS 6.2.4"], + title_keywords=[ + "csrf", "cross-site request forgery", "xsrf", "missing csrf token", + "csrf protection", "state changing without token", + ], + ), + + # ========================================================================= + # CWE-312: Cleartext Storage of Sensitive Information + # ========================================================================= + FixTemplate( + cwe_id="CWE-312", + cwe_name="Cleartext Storage of Sensitive Information", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"json\.dump.*password", + r"logging\.(info|debug|warning).*password", + ], + fix_description=( + "Sensitive data (passwords, PII, secrets, keys) is stored in cleartext. " + "Use bcrypt for passwords (adaptive hash, not reversible) and AES-256-GCM " + "or Fernet for other sensitive fields. Violates GDPR, HIPAA, PCI-DSS." + ), + fix_snippets={ + "python": { + "before": ( + "# BAD: storing plaintext password\n" + "db.execute('INSERT INTO users (username, password) VALUES (?, ?)',\n" + " (username, password))" + ), + "after": ( + "import bcrypt\n\n" + "# GOOD: bcrypt hash (salted + adaptive cost factor)\n" + "password_hash = bcrypt.hashpw(\n" + " password.encode('utf-8'),\n" + " bcrypt.gensalt(rounds=12),\n" + ")\n" + "db.execute('INSERT INTO users (username, password_hash) VALUES (?, ?)',\n" + " (username, password_hash))\n\n" + "# Verification (constant-time):\n" + "# bcrypt.checkpw(password.encode('utf-8'), stored_hash)" + ), + }, + "javascript": { + "before": ( + "// BAD: cleartext storage\n" + "await db.query(\n" + " 'INSERT INTO users (username, password) VALUES ($1, $2)',\n" + " [username, password]\n" + ");" + ), + "after": ( + "const bcrypt = require('bcrypt');\n" + "const SALT_ROUNDS = 12;\n\n" + "const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);\n" + "await db.query(\n" + " 'INSERT INTO users (username, password_hash) VALUES ($1, $2)',\n" + " [username, passwordHash]\n" + ");\n\n" + "// Verification: await bcrypt.compare(inputPwd, storedHash);" + ), + }, + }, + confidence=0.80, + severity="critical", + fix_type="code_patch", + testing_guidance=( + "1. Verify passwords are never stored in plaintext.\n" + "2. Verify bcrypt hash is in the user record.\n" + "3. Test login with correct and incorrect passwords.\n" + "4. Check logs do not contain cleartext passwords." + ), + risk_assessment=( + "Critical — cleartext password/PII storage is a catastrophic breach risk " + "violating GDPR Art.32, HIPAA §164.312(a)(2)(iv), PCI-DSS Req 8.2." + ), + effort_minutes=45, + mitre_techniques=["T1552"], + compliance_refs=["CWE-312", "GDPR Art.32", "HIPAA §164.312(a)(2)(iv)", "PCI-DSS 8.2.1"], + title_keywords=[ + "cleartext password", "plaintext password", "unencrypted password", + "sensitive data storage", "cleartext storage", "password in database", + "pii storage", "unencrypted sensitive", + ], + ), + + # ========================================================================= + # CWE-319: Cleartext Transmission of Sensitive Information + # ========================================================================= + FixTemplate( + cwe_id="CWE-319", + cwe_name="Cleartext Transmission of Sensitive Information", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"requests\.get\s*\(\s*['\"]http://", + r"requests\.post\s*\(\s*['\"]http://", + ], + fix_description=( + "Sensitive data is transmitted over unencrypted channels (HTTP instead of HTTPS). " + "Switch all endpoints to HTTPS/TLS 1.2+. Verify certificates. Redirect HTTP " + "to HTTPS. Set HSTS headers. Required by PCI-DSS 4.2.1, HIPAA §164.312(e)(1)." + ), + fix_snippets={ + "python": { + "before": ( + "import requests\n\n" + "# BAD: HTTP for sensitive API call\n" + "response = requests.post(\n" + " 'http://api.example.com/auth/token',\n" + " json={'username': username, 'password': password},\n" + ")" + ), + "after": ( + "import requests\n" + "import certifi\n\n" + "# GOOD: HTTPS with certificate verification\n" + "session = requests.Session()\n" + "session.verify = certifi.where()\n" + "response = session.post(\n" + " 'https://api.example.com/auth/token',\n" + " json={'username': username, 'password': password},\n" + " timeout=10,\n" + ")" + ), + }, + "javascript": { + "before": ( + "const app = express();\n" + "app.listen(80); // HTTP only" + ), + "after": ( + "const express = require('express');\n" + "const https = require('https');\n" + "const fs = require('fs');\n\n" + "const app = express();\n\n" + "// Redirect HTTP → HTTPS\n" + "const httpApp = express();\n" + "httpApp.use((req, res) => res.redirect(301, `https://${req.hostname}${req.url}`));\n" + "httpApp.listen(80);\n\n" + "// HTTPS server (TLS 1.2+ only)\n" + "https.createServer({\n" + " key: fs.readFileSync(process.env.TLS_KEY_PATH),\n" + " cert: fs.readFileSync(process.env.TLS_CERT_PATH),\n" + " minVersion: 'TLSv1.2',\n" + "}, app).listen(443);" + ), + }, + }, + confidence=0.80, + severity="high", + fix_type="config_hardening", + testing_guidance=( + "1. Verify all API endpoints use HTTPS.\n" + "2. Verify TLS 1.0/1.1 are disabled.\n" + "3. Test HTTP requests are redirected to HTTPS.\n" + "4. Run SSL Labs test targeting A+ rating." + ), + risk_assessment="High — required by PCI-DSS 4.2.1, HIPAA §164.312(e)(1).", + effort_minutes=30, + mitre_techniques=["T1040"], + compliance_refs=["CWE-319", "PCI-DSS 4.2.1", "HIPAA §164.312(e)(1)", "GDPR Art.32"], + title_keywords=[ + "cleartext transmission", "http instead of https", "unencrypted connection", + "missing tls", "missing ssl", "http api call", "no encryption in transit", + ], + ), + + # ========================================================================= + # CWE-400: Uncontrolled Resource Consumption + # ========================================================================= + FixTemplate( + cwe_id="CWE-400", + cwe_name="Uncontrolled Resource Consumption", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"re\.(match|search|fullmatch)\s*\(['\"].*(\\+)+", + r"file\.read\s*\(\s*\)(?!\s*#.*limit)", + ], + fix_description=( + "The application does not limit resource consumption (CPU, memory, file handles). " + "Add rate limiting, input size caps, and avoid catastrophically backtracking regex " + "patterns. Required to prevent DoS and meet SLA obligations." + ), + fix_snippets={ + "python": { + "before": ( + "import re\n\n" + "def validate_input(user_input: str) -> bool:\n" + " # BAD: catastrophically backtracking regex\n" + " pattern = r'^(a+)+$'\n" + " return bool(re.match(pattern, user_input))" + ), + "after": ( + "import re\n\n" + "MAX_INPUT_LENGTH = 10_000\n\n" + "def validate_input(user_input: str) -> bool:\n" + " if len(user_input) > MAX_INPUT_LENGTH:\n" + " raise ValueError(f'Input exceeds {MAX_INPUT_LENGTH} char limit')\n" + " # GOOD: bounded pattern, no backtracking risk\n" + " pattern = r'^a{1,1000}$'\n" + " return bool(re.match(pattern, user_input))" + ), + }, + }, + confidence=0.75, + severity="medium", + fix_type="code_patch", + testing_guidance=( + "1. Test with very large inputs — should return 413/400.\n" + "2. Test ReDoS with crafted input (e.g., 'aaaaaaa!').\n" + "3. Load test API endpoints to verify rate limiting.\n" + "4. Verify memory usage doesn't spike with large payloads." + ), + risk_assessment="Medium — DoS can cause service outages and SLA breaches.", + effort_minutes=20, + mitre_techniques=["T1499"], + compliance_refs=["CWE-400", "OWASP A05:2021", "NIST SC-5"], + title_keywords=[ + "denial of service", "dos", "resource exhaustion", "redos", + "unbounded input", "rate limiting", "resource consumption", + "catastrophic backtracking", "uncontrolled resource", + ], + ), + + # ========================================================================= + # CWE-601: Open Redirect + # ========================================================================= + FixTemplate( + cwe_id="CWE-601", + cwe_name="Open Redirect", + languages=["python", "javascript"], + vulnerable_patterns=[ + r"redirect\s*\(\s*request\.(args|params|form)", + r"res\.redirect\s*\(\s*req\.(query|body|params)", + r"return\s+HttpResponseRedirect\s*\(\s*request\.", + ], + fix_description=( + "The application accepts user-controlled URLs for redirects without validation. " + "Attackers can redirect users to malicious sites. Validate redirects against an " + "allowlist of trusted hosts or require relative URLs only." + ), + fix_snippets={ + "python": { + "before": ( + "from flask import redirect, request\n\n" + "@app.route('/login')\n" + "def login():\n" + " # BAD: open redirect\n" + " next_url = request.args.get('next', '/')\n" + " if authenticated:\n" + " return redirect(next_url)" + ), + "after": ( + "from flask import redirect, request\n" + "from urllib.parse import urlparse\n\n" + "ALLOWED_HOSTS = {'example.com', 'app.example.com'}\n\n" + "def is_safe_redirect_url(target: str) -> bool:\n" + " if not target:\n" + " return False\n" + " if target.startswith('/'):\n" + " return True # Relative URLs always safe\n" + " parsed = urlparse(target)\n" + " return parsed.netloc in ALLOWED_HOSTS\n\n" + "@app.route('/login')\n" + "def login():\n" + " next_url = request.args.get('next', '/')\n" + " if not is_safe_redirect_url(next_url):\n" + " next_url = '/'\n" + " if authenticated:\n" + " return redirect(next_url)" + ), + }, + }, + confidence=0.80, + severity="medium", + fix_type="code_patch", + testing_guidance=( + "1. Test that redirecting to external domain is blocked.\n" + "2. Test bypass attempts: //evil.com, /\\evil.com.\n" + "3. Verify relative redirects (/dashboard) work normally.\n" + "4. Test allowlisted external URLs are permitted." + ), + risk_assessment="Medium — enables phishing and credential theft attacks.", + effort_minutes=15, + mitre_techniques=["T1566"], + compliance_refs=["CWE-601", "OWASP A01:2021"], + title_keywords=[ + "open redirect", "unvalidated redirect", "url redirect", + "phishing redirect", "redirect to external", + ], + ), ] diff --git a/suite-core/core/ml/online_learning.py b/suite-core/core/ml/online_learning.py index fbed08a5f..fc1949cc0 100644 --- a/suite-core/core/ml/online_learning.py +++ b/suite-core/core/ml/online_learning.py @@ -847,6 +847,7 @@ def __init__( self._lock = threading.Lock() self._last_retrain_time = 0.0 self._min_interval_s = min_interval_s + self._training_in_progress = False # Single-flight guard self._retrain_history: List[Dict[str, Any]] = [] self._model_dir = model_dir or DEFAULT_MODEL_DIR self._log_path = DEFAULT_FEEDBACK_LOG @@ -916,12 +917,38 @@ def retrain_now(self) -> RetrainResult: """Force immediate retraining with current buffer contents. Thread-safe: acquires lock, drains buffer, retrains, validates, - and atomically swaps the model if all gates pass. + and atomically swaps the model if all gates pass. A single-flight + guard prevents concurrent training runs which would waste resources + and cause race conditions on model weights. """ with self._lock: + # Single-flight guard: if training is already in progress (in + # another thread), skip this invocation instead of queuing up + # another expensive training run. + if self._training_in_progress: + return RetrainResult( + success=False, + rejection_reason="Training already in progress", + ) + + # Guard against concurrent retrain calls: stamp the time before + # releasing the lock so that any other thread calling + # _should_retrain() will see the update and bail out. + if self._min_interval_s > 0 and ( + time.time() - self._last_retrain_time < self._min_interval_s + ): + return RetrainResult( + success=False, + rejection_reason="Rate-limited: retrain too soon", + ) + # Claim the retrain slot before leaving the lock + self._last_retrain_time = time.time() + self._training_in_progress = True + # Drain buffer examples = self._buffer.drain() if not examples: + self._training_in_progress = False return RetrainResult( success=False, rejection_reason="No feedback examples in buffer", @@ -934,7 +961,12 @@ def retrain_now(self) -> RetrainResult: current_model = get_risk_model() # Run incremental training - result = self._trainer.retrain(examples, current_model) + try: + result = self._trainer.retrain(examples, current_model) + finally: + # Always clear training guard even on exception + with self._lock: + self._training_in_progress = False with self._lock: if result.success: diff --git a/suite-core/core/ml/threat_enricher.py b/suite-core/core/ml/threat_enricher.py index ca5d1a23f..5c6c4f6f2 100644 --- a/suite-core/core/ml/threat_enricher.py +++ b/suite-core/core/ml/threat_enricher.py @@ -570,6 +570,67 @@ def get_kev_details(self, cve_id: str) -> Optional[Dict[str, Any]]: self._load_kev_catalog(skip_api=True) return self._kev_details.get(cve_id) + def enrich( + self, + cve_ids: List[str], + skip_api: bool = False, + ) -> Dict[str, Dict[str, Any]]: + """Enrich a list of CVE IDs with EPSS and KEV data. + + Lightweight companion to :meth:`enrich_findings` that accepts raw CVE + ID strings and returns a per-CVE enrichment dict. Used by the AutoFix + engine and other callers that have CVE IDs but no full finding objects. + + Parameters + ---------- + cve_ids : list of str + CVE identifiers to enrich (e.g. ``["CVE-2021-44228"]``). + skip_api : bool + If ``True``, skip live API calls and rely solely on cached/local + data. Required when running in air-gap mode. + + Returns + ------- + dict + Mapping of CVE ID → ``{"epss": float | None, "kev": bool, + "cvss": float | None, "kev_details": dict | None}``. + """ + if not cve_ids: + return {} + + # Ensure KEV catalog is loaded + if not self._kev_loaded: + self._load_kev_catalog(skip_api=skip_api) + + # Ensure EPSS cache is populated from disk + self._load_epss_cache() + + # Attempt to load CVSS from cached feeds + if not self._cvss_cache: + self._load_cvss_from_nvd_cache() + self._load_cvss_from_daily_intel() + + # Batch-fetch EPSS for any CVEs not yet cached + if not skip_api: + missing = [c for c in cve_ids if c not in self._epss_cache] + if missing: + self._batch_fetch_epss(missing) + + result: Dict[str, Dict[str, Any]] = {} + for cve_id in cve_ids: + epss = self._epss_cache.get(cve_id) + in_kev = cve_id in self._kev_set + cvss = self._cvss_cache.get(cve_id) + kev_details = self._kev_details.get(cve_id) if in_kev else None + result[cve_id] = { + "epss": epss, + "kev": in_kev, + "cvss": cvss, + "kev_details": kev_details, + } + + return result + @property def kev_count(self) -> int: """Total number of CVEs in KEV catalog.""" diff --git a/suite-evidence-risk/compliance/compliance_engine.py b/suite-evidence-risk/compliance/compliance_engine.py index 45fc0c386..505c5480a 100644 --- a/suite-evidence-risk/compliance/compliance_engine.py +++ b/suite-evidence-risk/compliance/compliance_engine.py @@ -1005,18 +1005,25 @@ def generate_audit_bundle( trend = self.db.get_posture_trend(framework.value, limit=10) # Gather evidence per control + fw_controls = self._framework_controls.get(framework, {}) controls_with_evidence = [] for assessment in assessments: + ctrl_id = assessment["control_id"] + ctrl_def = fw_controls.get(ctrl_id, {}) evidence = self.db.get_evidence_for_control( - assessment["control_id"], framework.value + ctrl_id, framework.value ) controls_with_evidence.append({ - "control_id": assessment["control_id"], + "control_id": ctrl_id, + "title": ctrl_def.get("title", assessment.get("title", "")), + "category": ctrl_def.get("category", assessment.get("category", "General")), "status": assessment["status"], "score": assessment.get("score", 0.0), "evidence_count": len(evidence), "evidence_items": evidence[:5], # Top 5 per control "notes": assessment.get("notes", ""), + "automated": ctrl_def.get("automated", True), + "related_cwes": ctrl_def.get("cwes", []), }) bundle = { diff --git a/tests/test_compliance_engine_unit.py b/tests/test_compliance_engine_unit.py index 493cf6e20..d1f575d72 100644 --- a/tests/test_compliance_engine_unit.py +++ b/tests/test_compliance_engine_unit.py @@ -434,3 +434,47 @@ def test_assess_framework(self, engine): assert isinstance(posture, CompliancePosture) assert posture.framework == Framework.PCI_DSS assert posture.total_controls == len(PCI_DSS_CONTROLS) + + +class TestGenerateAuditBundle: + """Tests for generate_audit_bundle — verifies category field is present.""" + + @pytest.fixture + def engine(self, tmp_path): + db = ComplianceDB(db_path=str(tmp_path / "compliance_ab.db")) + return ComplianceEngine(db=db) + + def test_audit_bundle_controls_have_category(self, engine): + """Controls returned by generate_audit_bundle must include 'category'.""" + # Seed some assessments so controls_with_evidence is non-empty + findings = [ + {"id": "f-ab-1", "cwe": "CWE-287", "severity": "high", "title": "Auth Bypass"}, + {"id": "f-ab-2", "cwe": "CWE-862", "severity": "high", "title": "Missing Authz"}, + {"id": "f-ab-3", "cwe": "CWE-89", "severity": "critical", "title": "SQL Injection"}, + ] + engine.map_findings_to_controls(findings) + bundle = engine.generate_audit_bundle(Framework.SOC2, app_id="test-app") + assert "controls" in bundle + for ctrl in bundle["controls"]: + assert "category" in ctrl, ( + f"Control {ctrl.get('control_id')} missing 'category' field" + ) + assert ctrl["category"] is not None, ( + f"Control {ctrl.get('control_id')} has None category" + ) + + def test_audit_bundle_controls_have_title(self, engine): + """Controls in audit bundle must include a non-empty 'title' field.""" + findings = [ + {"id": "f-ab-4", "cwe": "CWE-79", "severity": "medium", "title": "XSS"}, + ] + engine.map_findings_to_controls(findings) + bundle = engine.generate_audit_bundle(Framework.SOC2, app_id="test-app-2") + for ctrl in bundle["controls"]: + assert "title" in ctrl + + def test_audit_bundle_structure(self, engine): + """generate_audit_bundle returns expected top-level keys.""" + bundle = engine.generate_audit_bundle(Framework.PCI_DSS, app_id="test-app-3") + required_keys = {"bundle_id", "generated_at", "framework", "posture", "controls", "gaps"} + assert required_keys <= set(bundle.keys()) diff --git a/tests/test_ml_online_learning.py b/tests/test_ml_online_learning.py index 6760e3038..4c6d33c0d 100644 --- a/tests/test_ml_online_learning.py +++ b/tests/test_ml_online_learning.py @@ -892,3 +892,42 @@ def test_trained_bundle(self): bundle = _TrainedBundle(model=model, scaler=scaler) assert bundle.model is model assert bundle.scaler is scaler + + def test_single_flight_guard_prevents_concurrent_training( + self, pipeline, sample_feedback_decision_correct + ): + """Single-flight guard: retrain_now() rejects if training already in progress.""" + import threading + + # Force the buffer to be ready for retrain + for _ in range(3): + pipeline._buffer.add(FeedbackConverter.convert(sample_feedback_decision_correct)) + + # Manually set training_in_progress to simulate an ongoing training run + with pipeline._lock: + pipeline._training_in_progress = True + + # Now retrain_now() should be rejected + result = pipeline.retrain_now() + assert not result.success + assert "in progress" in result.rejection_reason.lower() or "training" in result.rejection_reason.lower() + + # Clean up + with pipeline._lock: + pipeline._training_in_progress = False + + def test_training_in_progress_cleared_after_retrain( + self, pipeline, sample_feedback_decision_correct + ): + """_training_in_progress flag must be False after retrain completes.""" + # Ensure buffer is ready + for _ in range(3): + pipeline._buffer.add(FeedbackConverter.convert(sample_feedback_decision_correct)) + + assert not pipeline._training_in_progress + + # Run retrain (will set and then clear the flag) + pipeline.retrain_now() + + # After completion, flag must be cleared + assert not pipeline._training_in_progress diff --git a/tests/test_ml_threat_enricher.py b/tests/test_ml_threat_enricher.py index d959ad443..3c6e9fdde 100644 --- a/tests/test_ml_threat_enricher.py +++ b/tests/test_ml_threat_enricher.py @@ -421,3 +421,74 @@ def test_new_estimates_match_first_org_research(self): assert enricher._estimate_epss_from_severity({"severity": "high"}) == 0.10 assert enricher._estimate_epss_from_severity({"severity": "medium"}) == 0.03 assert enricher._estimate_epss_from_severity({"severity": "low"}) == 0.01 + + +# --------------------------------------------------------------------------- +# Tests for the new enrich() method (per-CVE enrichment) +# --------------------------------------------------------------------------- + +class TestEnrichMethod: + """Tests for ThreatEnricher.enrich() — per-CVE-ID enrichment dict.""" + + def test_enrich_empty_returns_empty_dict(self, enricher: ThreatEnricher): + """enrich([]) returns an empty dict, not None or error.""" + result = enricher.enrich([], skip_api=True) + assert result == {} + + def test_enrich_returns_dict_keyed_by_cve_id(self, enricher: ThreatEnricher): + """enrich() returns a mapping CVE ID → enrichment data.""" + cve_ids = ["CVE-2021-44228", "CVE-2022-22965"] + result = enricher.enrich(cve_ids, skip_api=True) + assert set(result.keys()) == set(cve_ids) + + def test_enrich_result_has_required_fields(self, enricher: ThreatEnricher): + """Each per-CVE result must contain epss, kev, cvss, kev_details.""" + result = enricher.enrich(["CVE-2021-44228"], skip_api=True) + entry = result["CVE-2021-44228"] + assert "epss" in entry + assert "kev" in entry + assert "cvss" in entry + assert "kev_details" in entry + + def test_enrich_kev_true_for_known_kev_cve(self, enricher: ThreatEnricher): + """CVEs in the KEV catalog should have kev=True in enrich() output.""" + mock_kev = { + "catalogVersion": "2024.01.01", + "vulnerabilities": [ + {"cveID": "CVE-2021-44228", "vendorProject": "Apache", "product": "Log4j"}, + ], + } + with patch("core.ml.threat_enricher._fetch_json", return_value=mock_kev): + enricher._load_kev_catalog(skip_api=False) + + result = enricher.enrich(["CVE-2021-44228"], skip_api=True) + assert result["CVE-2021-44228"]["kev"] is True + assert result["CVE-2021-44228"]["kev_details"] is not None + + def test_enrich_kev_false_for_unknown_cve(self, enricher: ThreatEnricher): + """Unknown CVEs should have kev=False.""" + result = enricher.enrich(["CVE-9999-99999"], skip_api=True) + assert result["CVE-9999-99999"]["kev"] is False + assert result["CVE-9999-99999"]["kev_details"] is None + + def test_enrich_epss_populated_from_cache(self, enricher: ThreatEnricher): + """If EPSS is in cache, enrich() should return it.""" + enricher._epss_cache["CVE-2021-44228"] = 0.97 + result = enricher.enrich(["CVE-2021-44228"], skip_api=True) + assert result["CVE-2021-44228"]["epss"] == 0.97 + + def test_enrich_multiple_cves(self, enricher: ThreatEnricher): + """enrich() handles a batch of CVEs correctly.""" + cves = ["CVE-2021-44228", "CVE-2022-22965", "CVE-2023-12345"] + result = enricher.enrich(cves, skip_api=True) + assert len(result) == 3 + for cve in cves: + assert cve in result + assert isinstance(result[cve]["kev"], bool) + + def test_enrich_none_epss_when_not_cached(self, enricher: ThreatEnricher): + """When EPSS not in cache and skip_api=True, epss should be None.""" + # Clear any cache + enricher._epss_cache.clear() + result = enricher.enrich(["CVE-9999-11111"], skip_api=True) + assert result["CVE-9999-11111"]["epss"] is None